Skip to content

Commit f2cbaee

Browse files
feat(mcp): unwrap the MCP envelope in JSON output, add --envelope (#30)
praxis mcp <mcp> <fn> --json (and any piped/non-TTY invocation) used to pass the raw gateway envelope through, forcing every scripted caller — the skill tells AI hosts to always use --json — into a `jq -r '.content[0].text' | jq` double-parse. Human mode already unwrapped; the machine-facing mode was the only one that didn't. JSON mode now prints the tool payload directly when the response is a single text content item whose payload is valid JSON. Everything else keeps the canonical envelope so JSON output stays valid JSON: plain-text payloads, multi-item/non-text content, non-envelope bodies, and tool errors (isError: true still exits 1 with the error shape intact). The new --envelope flag disables unwrapping in both output modes. parseMCPResponse is now the one place that knows the envelope shape: a single parse feeds the exit-code decision and both output paths (replacing the envelopeIsError + prettyMCPOutput split, which parsed the same bytes up to three times). The embedded praxis skill text and CHANGELOG call out the break: scripts parsing .content[0].text should drop that step or pass --envelope. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1dad328 commit f2cbaee

4 files changed

Lines changed: 214 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
### Changed
1010

11+
- **Breaking:** `praxis mcp <mcp> <fn>` JSON output (explicit `--json`
12+
*and* any piped/non-TTY invocation) now unwraps the MCP envelope:
13+
when the response is a single text content item whose payload is
14+
valid JSON, the payload is printed directly, so callers consume the
15+
tool result without the `jq -r '.content[0].text' | jq` double-parse.
16+
Tool errors (`isError: true`, exit 1), plain-text payloads,
17+
multi-item content, and non-envelope bodies still emit the canonical
18+
envelope, so JSON output stays valid JSON. Scripts that parse
19+
`.content[0].text` must drop that step — or pass the new `--envelope`
20+
flag, which always prints the raw envelope (in human output mode
21+
too).
1122
- `praxis status --json` now lives up to "small JSON snapshot":
1223
`skills_installed` / `agents_installed` are deduped, sorted name
1324
arrays instead of per-(name, harness) objects with paths and

cmd/mcp.go

Lines changed: 67 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ import (
1919
)
2020

2121
var (
22-
mcpJSON bool
23-
mcpArgs []string
24-
mcpBody string
25-
mcpTimeout time.Duration
22+
mcpJSON bool
23+
mcpArgs []string
24+
mcpBody string
25+
mcpEnvelope bool
26+
mcpTimeout time.Duration
2627
)
2728

2829
func init() {
@@ -33,6 +34,7 @@ func init() {
3334
// tests covering raptor commands like `--arg command='... "quoted" ...'`.
3435
mcpCmd.Flags().StringArrayVar(&mcpArgs, "arg", nil, "key=value pair (repeatable); merged into request body")
3536
mcpCmd.Flags().StringVar(&mcpBody, "body", "", "raw JSON body (use '-' for stdin); overrides --arg")
37+
mcpCmd.Flags().BoolVar(&mcpEnvelope, "envelope", false, "always print the raw MCP envelope ({content:[...],isError?}) instead of unwrapping JSON tool output")
3638
mcpCmd.Flags().DurationVar(&mcpTimeout, "timeout", 60*time.Second, "request timeout")
3739
rootCmd.AddCommand(mcpCmd)
3840
}
@@ -128,18 +130,18 @@ Examples:
128130
os.Exit(exitcode.Usage)
129131
}
130132

131-
// HTTP 200 — print the body verbatim (pass-through MCP envelope).
132-
// If isError is true on a dict-shape response, exit 1 so callers
133-
// can detect tool-level failure even with JSON output.
134-
exitWithToolError := envelopeIsError(resp)
133+
// HTTP 200 — render the body (see parseMCPResponse for the unwrap
134+
// contract). If isError is true on a dict-shape response, exit 1
135+
// so callers can detect tool-level failure even with JSON output.
136+
res := parseMCPResponse(resp)
135137

136138
if asJSON {
137-
_, _ = out.Write(append(bytes.TrimRight(resp, "\n"), '\n'))
139+
_, _ = out.Write(append(mcpJSONOutput(resp, res, mcpEnvelope), '\n'))
138140
} else {
139-
fmt.Fprintln(out, prettyMCPOutput(resp))
141+
fmt.Fprintln(out, mcpHumanOutput(resp, res, mcpEnvelope))
140142
}
141143

142-
if exitWithToolError {
144+
if res.isError {
143145
os.Exit(exitcode.Error)
144146
}
145147
return nil
@@ -395,16 +397,6 @@ func extractDetail(raw []byte, fallback string) string {
395397
return fallback
396398
}
397399

398-
// envelopeIsError detects the MCP `{isError: true, ...}` envelope so the
399-
// process can exit non-zero even though the HTTP call succeeded.
400-
func envelopeIsError(raw []byte) bool {
401-
var probe struct {
402-
IsError bool `json:"isError"`
403-
}
404-
_ = json.Unmarshal(raw, &probe)
405-
return probe.IsError
406-
}
407-
408400
func prettyJSON(raw []byte) string {
409401
var v any
410402
if err := json.Unmarshal(raw, &v); err != nil {
@@ -417,32 +409,69 @@ func prettyJSON(raw []byte) string {
417409
return string(out)
418410
}
419411

420-
// prettyMCPOutput renders an MCP response for human (non-JSON) output.
421412
// The gateway wraps tool output as
422413
//
423-
// {"content":[{"type":"text","text":"<payload>"}]}
414+
// {"content":[{"type":"text","text":"<payload>"}],"isError"?:bool}
424415
//
425-
// where the payload is itself usually escaped JSON. When the envelope
426-
// carries exactly one text item, unwrap it: pretty-print the inner JSON
427-
// if it parses, otherwise surface the text verbatim. Anything that
428-
// isn't this single-text shape (plain responses, multi-item content,
429-
// non-text items) falls through to ordinary pretty-printing so we never
430-
// hide structure by guessing. JSON output (`--json`) is unaffected — it
431-
// still passes the canonical envelope through untouched.
432-
func prettyMCPOutput(raw []byte) string {
416+
// where the payload is itself usually escaped JSON. parseMCPResponse is
417+
// the one place that knows this shape; its single parse feeds the
418+
// exit-code decision and both output modes. Either mode unwraps only
419+
// the single-text shape — anything else (multi-item content, non-text
420+
// items, non-envelope bodies) is passed through so we never hide
421+
// structure by guessing — with one asymmetry: JSON mode keeps the
422+
// envelope on tool errors so the isError shape survives for scripted
423+
// callers, while human mode unwraps them for readability. `--envelope`
424+
// disables unwrapping in both modes.
425+
426+
type mcpResponse struct {
427+
isError bool
428+
singleText string // payload when content is exactly one non-empty text item
429+
isSingle bool
430+
}
431+
432+
func parseMCPResponse(raw []byte) mcpResponse {
433433
var env struct {
434+
IsError bool `json:"isError"`
434435
Content []struct {
435436
Type string `json:"type"`
436437
Text string `json:"text"`
437438
} `json:"content"`
438439
}
439-
if err := json.Unmarshal(raw, &env); err == nil &&
440-
len(env.Content) == 1 && env.Content[0].Type == "text" && env.Content[0].Text != "" {
441-
inner := env.Content[0].Text
442-
if json.Valid([]byte(inner)) {
443-
return prettyJSON([]byte(inner))
440+
var res mcpResponse
441+
if err := json.Unmarshal(raw, &env); err != nil {
442+
return res
443+
}
444+
res.isError = env.IsError
445+
if len(env.Content) == 1 && env.Content[0].Type == "text" && env.Content[0].Text != "" {
446+
res.singleText = env.Content[0].Text
447+
res.isSingle = true
448+
}
449+
return res
450+
}
451+
452+
// mcpJSONOutput picks the bytes to emit in JSON mode (no trailing
453+
// newline). On success, a single-text envelope whose payload is valid
454+
// JSON unwraps to that payload, so callers can consume the tool result
455+
// directly instead of double-parsing `.content[0].text`. Everything
456+
// else — plain-text payloads (output must stay valid JSON), tool
457+
// errors, non-single-text shapes, or --envelope — emits the canonical
458+
// envelope untouched.
459+
func mcpJSONOutput(raw []byte, res mcpResponse, forceEnvelope bool) []byte {
460+
if res.isSingle && !res.isError && !forceEnvelope {
461+
if inner := bytes.TrimSpace([]byte(res.singleText)); json.Valid(inner) {
462+
return inner
444463
}
445-
return inner
446464
}
447-
return prettyJSON(raw)
465+
return bytes.TrimRight(raw, "\n")
466+
}
467+
468+
// mcpHumanOutput renders for human (non-JSON) output: pretty-print the
469+
// unwrapped payload of a single-text envelope (prettyJSON surfaces
470+
// non-JSON payloads verbatim), otherwise pretty-print the raw body.
471+
// forceEnvelope (--envelope) skips unwrapping entirely.
472+
func mcpHumanOutput(raw []byte, res mcpResponse, forceEnvelope bool) string {
473+
if forceEnvelope || !res.isSingle {
474+
return prettyJSON(raw)
475+
}
476+
return prettyJSON([]byte(res.singleText))
448477
}

cmd/mcp_test.go

Lines changed: 130 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func resetMcpFlags() {
1919
mcpJSON = false
2020
mcpArgs = nil
2121
mcpBody = ""
22+
mcpEnvelope = false
2223
mcpTimeout = 60 * time.Second
2324
}
2425

@@ -163,15 +164,6 @@ func TestExtractDetail(t *testing.T) {
163164
}
164165
}
165166

166-
func TestEnvelopeIsError(t *testing.T) {
167-
if !envelopeIsError([]byte(`{"isError":true,"content":[]}`)) {
168-
t.Error("expected true")
169-
}
170-
if envelopeIsError([]byte(`{"ok":true}`)) {
171-
t.Error("expected false")
172-
}
173-
}
174-
175167
func TestPrettyJSON(t *testing.T) {
176168
out := prettyJSON([]byte(`{"a":1}`))
177169
if !strings.Contains(out, " ") {
@@ -297,9 +289,14 @@ func TestIsDomainOrSubdomain(t *testing.T) {
297289
// {"content":[{"type":"text","text":"<escaped JSON>"}]}. For human
298290
// (pretty) output the single text payload should be unwrapped and its
299291
// inner JSON pretty-printed, instead of showing double-encoded escapes.
300-
func TestPrettyMCPOutput_UnwrapsSingleTextEnvelope(t *testing.T) {
301-
envelope := []byte(`{"content":[{"type":"text","text":"{\"projects\":[\"a\",\"b\"]}"}]}`)
302-
out := prettyMCPOutput(envelope)
292+
// humanOutput parses raw and renders it the way the command's human
293+
// (non-JSON) path does.
294+
func humanOutput(raw string, forceEnvelope bool) string {
295+
return mcpHumanOutput([]byte(raw), parseMCPResponse([]byte(raw)), forceEnvelope)
296+
}
297+
298+
func TestMcpHumanOutput_UnwrapsSingleTextEnvelope(t *testing.T) {
299+
out := humanOutput(`{"content":[{"type":"text","text":"{\"projects\":[\"a\",\"b\"]}"}]}`, false)
303300
if strings.Contains(out, `\"`) {
304301
t.Errorf("output still double-encoded: %q", out)
305302
}
@@ -312,17 +309,16 @@ func TestPrettyMCPOutput_UnwrapsSingleTextEnvelope(t *testing.T) {
312309
}
313310

314311
// Inner text that isn't JSON should be surfaced verbatim, not wrapped.
315-
func TestPrettyMCPOutput_UnwrapsPlainTextEnvelope(t *testing.T) {
316-
envelope := []byte(`{"content":[{"type":"text","text":"only for Kubernetes-based environments"}]}`)
317-
out := prettyMCPOutput(envelope)
312+
func TestMcpHumanOutput_UnwrapsPlainTextEnvelope(t *testing.T) {
313+
out := humanOutput(`{"content":[{"type":"text","text":"only for Kubernetes-based environments"}]}`, false)
318314
if out != "only for Kubernetes-based environments" {
319315
t.Errorf("plain-text payload = %q, want unwrapped verbatim", out)
320316
}
321317
}
322318

323319
// A non-envelope payload falls through to ordinary pretty-printing.
324-
func TestPrettyMCPOutput_NonEnvelopeFallsThrough(t *testing.T) {
325-
out := prettyMCPOutput([]byte(`{"integrations":["aws-prod"]}`))
320+
func TestMcpHumanOutput_NonEnvelopeFallsThrough(t *testing.T) {
321+
out := humanOutput(`{"integrations":["aws-prod"]}`, false)
326322
if !strings.Contains(out, " ") {
327323
t.Errorf("expected indented JSON, got %q", out)
328324
}
@@ -333,14 +329,128 @@ func TestPrettyMCPOutput_NonEnvelopeFallsThrough(t *testing.T) {
333329

334330
// A multi-item content array is not the single-text shape, so it should
335331
// be left as the raw (pretty-printed) envelope rather than guessing.
336-
func TestPrettyMCPOutput_MultiContentNotUnwrapped(t *testing.T) {
337-
envelope := []byte(`{"content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]}`)
338-
out := prettyMCPOutput(envelope)
332+
func TestMcpHumanOutput_MultiContentNotUnwrapped(t *testing.T) {
333+
out := humanOutput(`{"content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]}`, false)
339334
if !strings.Contains(out, "content") {
340335
t.Errorf("multi-item envelope should fall through to raw pretty, got %q", out)
341336
}
342337
}
343338

339+
// --envelope means "never unwrap" in human mode too.
340+
func TestMcpHumanOutput_EnvelopeFlag(t *testing.T) {
341+
envelope := `{"content":[{"type":"text","text":"{\"a\":1}"}]}`
342+
if out := humanOutput(envelope, true); !strings.Contains(out, "content") {
343+
t.Errorf("forceEnvelope should preserve the envelope, got %q", out)
344+
}
345+
if out := humanOutput(envelope, false); strings.Contains(out, "content") {
346+
t.Errorf("default human output should unwrap, got %q", out)
347+
}
348+
}
349+
350+
// ---------------------------------------------------------------------------
351+
// JSON-mode envelope unwrapping
352+
// ---------------------------------------------------------------------------
353+
354+
func TestParseMCPResponse(t *testing.T) {
355+
tests := []struct {
356+
name string
357+
raw string
358+
wantText string
359+
isSingle bool
360+
isError bool
361+
}{
362+
{"json inner", `{"content":[{"type":"text","text":"{\"a\":1}"}]}`, `{"a":1}`, true, false},
363+
{"plain text inner", `{"content":[{"type":"text","text":"all good"}]}`, "all good", true, false},
364+
{"error envelope", `{"isError":true,"content":[{"type":"text","text":"boom"}]}`, "boom", true, true},
365+
{"multi item", `{"content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]}`, "", false, false},
366+
{"empty content", `{"content":[]}`, "", false, false},
367+
{"missing content", `{"isError":false}`, "", false, false},
368+
{"non-text item", `{"content":[{"type":"image","text":"x"}]}`, "", false, false},
369+
{"empty text", `{"content":[{"type":"text","text":""}]}`, "", false, false},
370+
{"non-envelope", `{"integrations":["aws-prod"]}`, "", false, false},
371+
{"malformed", `not json`, "", false, false},
372+
{"non-string text", `{"content":[{"type":"text","text":42}]}`, "", false, false},
373+
}
374+
for _, tt := range tests {
375+
t.Run(tt.name, func(t *testing.T) {
376+
got := parseMCPResponse([]byte(tt.raw))
377+
if got.isSingle != tt.isSingle || got.singleText != tt.wantText || got.isError != tt.isError {
378+
t.Errorf("parseMCPResponse(%s) = %+v, want {singleText:%q isSingle:%v isError:%v}",
379+
tt.raw, got, tt.wantText, tt.isSingle, tt.isError)
380+
}
381+
})
382+
}
383+
}
384+
385+
func TestMCPJSONOutput(t *testing.T) {
386+
jsonEnvelope := `{"content":[{"type":"text","text":"{\"projects\":[\"a\"]}"}]}`
387+
proseEnvelope := `{"content":[{"type":"text","text":"deployment succeeded"}]}`
388+
errorEnvelope := `{"isError":true,"content":[{"type":"text","text":"{\"detail\":\"boom\"}"}]}`
389+
tests := []struct {
390+
name string
391+
raw string
392+
forceEnvelope bool
393+
want string
394+
}{
395+
{"object inner unwrapped", jsonEnvelope, false, `{"projects":["a"]}`},
396+
{"bare scalar inner unwrapped", `{"content":[{"type":"text","text":"42"}]}`, false, `42`},
397+
{"padded inner trimmed", `{"content":[{"type":"text","text":"\n{\"a\":1}\n"}]}`, false, `{"a":1}`},
398+
{"prose inner keeps envelope", proseEnvelope, false, proseEnvelope},
399+
{"non-single-text shape passes through", `{"integrations":["aws-prod"]}`, false, `{"integrations":["aws-prod"]}`},
400+
{"trailing newline stripped from envelope", proseEnvelope + "\n", false, proseEnvelope},
401+
{"isError keeps envelope", errorEnvelope, false, errorEnvelope},
402+
{"forceEnvelope keeps envelope", jsonEnvelope, true, jsonEnvelope},
403+
}
404+
for _, tt := range tests {
405+
t.Run(tt.name, func(t *testing.T) {
406+
raw := []byte(tt.raw)
407+
got := string(mcpJSONOutput(raw, parseMCPResponse(raw), tt.forceEnvelope))
408+
if got != tt.want {
409+
t.Errorf("mcpJSONOutput() = %q, want %q", got, tt.want)
410+
}
411+
})
412+
}
413+
}
414+
415+
// Wiring test: RunE routes the response through mcpJSONOutput with the
416+
// mcpEnvelope flag (shape rules themselves are pinned by TestMCPJSONOutput).
417+
func TestMcpCmd_JsonOutputWiring(t *testing.T) {
418+
envelope := `{"content":[{"type":"text","text":"{\"projects\":[\"a\"]}"}]}`
419+
tests := []struct {
420+
name string
421+
forceEnvelope bool
422+
want string
423+
}{
424+
{"unwraps by default", false, `{"projects":["a"]}` + "\n"},
425+
{"--envelope passes through", true, envelope + "\n"},
426+
}
427+
for _, tt := range tests {
428+
t.Run(tt.name, func(t *testing.T) {
429+
resetMcpFlags()
430+
defer resetMcpFlags()
431+
mcpJSON = true
432+
mcpEnvelope = tt.forceEnvelope
433+
434+
seedDefaultProfile(t)
435+
orig := callMCP
436+
callMCP = func(_, _, _, _ string, _ []byte, _ time.Duration) ([]byte, int, error) {
437+
return []byte(envelope), http.StatusOK, nil
438+
}
439+
defer func() { callMCP = orig }()
440+
441+
var buf bytes.Buffer
442+
mcpCmd.SetOut(&buf)
443+
mcpCmd.SetErr(&buf)
444+
if err := mcpCmd.RunE(mcpCmd, []string{"cloud_cli", "list_cloud_integrations"}); err != nil {
445+
t.Fatalf("RunE err = %v", err)
446+
}
447+
if got := buf.String(); got != tt.want {
448+
t.Errorf("output = %q, want %q", got, tt.want)
449+
}
450+
})
451+
}
452+
}
453+
344454
func TestMcpCmd_HappyPath(t *testing.T) {
345455
t.Setenv("HOME", t.TempDir())
346456
t.Setenv("PRAXIS_PROFILE", "")

internal/skillinstall/dummy.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,12 @@ holds AWS / kube secrets.
144144
every ` + "`praxis login`" + ` and ` + "`praxis refresh-skills`" + `. Grep when you
145145
need tool names without going to the network.
146146
- **Call**: ` + "`praxis mcp <mcp> <fn> --arg k=v ... --json`" + ` (or
147-
` + "`--body '<json>'`" + ` for nested args). Output is the raw MCP envelope
148-
(` + "`{content: [...], isError?: bool}`" + `).
147+
` + "`--body '<json>'`" + ` for nested args). Output is the tool's JSON
148+
result directly — the CLI unwraps the MCP envelope when the payload
149+
is a single JSON text item. On tool error (exit 1) or non-JSON
150+
payloads you get the raw envelope
151+
(` + "`{content: [...], isError?: bool}`" + `). Pass ` + "`--envelope`" + ` to
152+
always get the raw envelope.
149153
150154
Example flow:
151155
` + "```bash" + `

0 commit comments

Comments
 (0)