@@ -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-
175167func 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+
344454func TestMcpCmd_HappyPath (t * testing.T ) {
345455 t .Setenv ("HOME" , t .TempDir ())
346456 t .Setenv ("PRAXIS_PROFILE" , "" )
0 commit comments