From afb08e82a014984b3798fe0ee47818312d59b4de Mon Sep 17 00:00:00 2001 From: Yves Junqueira Date: Tue, 21 Apr 2026 01:11:14 -0300 Subject: [PATCH] feat(mcp): add --json mode that unwraps tool response envelope Adds a --json flag to pipeboard mcp that emits a clean structured envelope on stdout and suppresses status messages. The MCP content[].text wrapper is auto-unwrapped and parsed as JSON when possible, so callers no longer need to hunt for the start of the JSON in the output or double-parse content[0].text themselves. Success: {"ok": true, "data": } Tool-level error (isError=true): {"ok": false, "error": "...", "data": "..."} + exit 1 Transport/auth error: {"ok": false, "error": "..."} + exit 1 --- cmd/mcp.go | 120 ++++++++++++++++++++++++++++++++++++++++++---- cmd/mcp_test.go | 125 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 10 deletions(-) diff --git a/cmd/mcp.go b/cmd/mcp.go index ac35fa5..4e57f2e 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -17,6 +17,7 @@ var mcpCmd = &cobra.Command{ var ( mcpServer string + mcpJSON bool ) var mcpToolsListCmd = &cobra.Command{ @@ -44,6 +45,7 @@ var ( func init() { mcpCmd.PersistentFlags().StringVar(&mcpServer, "server", "", "MCP server path, e.g. meta-ads-mcp (required)") mcpCmd.MarkPersistentFlagRequired("server") + mcpCmd.PersistentFlags().BoolVar(&mcpJSON, "json", false, "Emit a structured JSON envelope on stdout and suppress status messages on stderr. Unwraps the MCP content wrapper and parses text payloads as JSON when possible.") mcpToolsCallCmd.Flags().StringVar(&mcpToolName, "tool", "", "Tool name to call (required)") mcpToolsCallCmd.Flags().StringVar(&mcpToolArgs, "args", "{}", "JSON object of tool arguments") @@ -65,20 +67,27 @@ func newMCPClient() (*client.Client, error) { } func runMCPToolsList(cmd *cobra.Command, args []string) error { + if mcpJSON { + cmd.SilenceUsage = true + cmd.SilenceErrors = true + } + c, err := newMCPClient() if err != nil { - return err + return emitJSONError(err) } - fmt.Fprintf(os.Stderr, "Connecting to %s/%s...\n", apiURL, mcpServer) + if !mcpJSON { + fmt.Fprintf(os.Stderr, "Connecting to %s/%s...\n", apiURL, mcpServer) + } if err := c.Initialize(mcpServer); err != nil { - return fmt.Errorf("handshake failed: %w", err) + return emitJSONError(fmt.Errorf("handshake failed: %w", err)) } result, err := c.ListTools(mcpServer) if err != nil { - return fmt.Errorf("listing tools: %w", err) + return emitJSONError(fmt.Errorf("listing tools: %w", err)) } // Filter if requested @@ -93,6 +102,14 @@ func runMCPToolsList(cmd *cobra.Command, args []string) error { tools = filtered } + if mcpJSON { + return emitJSON(map[string]interface{}{ + "ok": true, + "tools": tools, + "total": len(result.Tools), + }) + } + fmt.Fprintf(os.Stderr, "Found %d tools (showing %d)\n\n", len(result.Tools), len(tools)) if mcpToolsListVerbose { @@ -118,27 +135,40 @@ func runMCPToolsList(cmd *cobra.Command, args []string) error { } func runMCPToolsCall(cmd *cobra.Command, args []string) error { + if mcpJSON { + cmd.SilenceUsage = true + cmd.SilenceErrors = true + } + c, err := newMCPClient() if err != nil { - return err + return emitJSONError(err) } - fmt.Fprintf(os.Stderr, "Connecting to %s/%s...\n", apiURL, mcpServer) + if !mcpJSON { + fmt.Fprintf(os.Stderr, "Connecting to %s/%s...\n", apiURL, mcpServer) + } if err := c.Initialize(mcpServer); err != nil { - return fmt.Errorf("handshake failed: %w", err) + return emitJSONError(fmt.Errorf("handshake failed: %w", err)) } var toolArgs map[string]interface{} if err := json.Unmarshal([]byte(mcpToolArgs), &toolArgs); err != nil { - return fmt.Errorf("invalid JSON in --args: %w", err) + return emitJSONError(fmt.Errorf("invalid JSON in --args: %w", err)) } - fmt.Fprintf(os.Stderr, "Calling %s...\n", mcpToolName) + if !mcpJSON { + fmt.Fprintf(os.Stderr, "Calling %s...\n", mcpToolName) + } result, err := c.CallTool(mcpServer, mcpToolName, toolArgs) if err != nil { - return fmt.Errorf("tool call failed: %w", err) + return emitJSONError(fmt.Errorf("tool call failed: %w", err)) + } + + if mcpJSON { + return emitToolResultJSON(result) } // Pretty-print the result @@ -154,6 +184,76 @@ func runMCPToolsCall(cmd *cobra.Command, args []string) error { return enc.Encode(parsed) } +// emitJSON writes a structured JSON envelope to stdout with pretty indentation. +func emitJSON(payload map[string]interface{}) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(payload) +} + +// emitJSONError emits a structured error on stdout in JSON mode, then returns +// the original error so the command still exits non-zero. In non-JSON mode it +// just returns the error (cobra prints it to stderr). +func emitJSONError(err error) error { + if !mcpJSON { + return err + } + _ = emitJSON(map[string]interface{}{ + "ok": false, + "error": err.Error(), + }) + return err +} + +// emitToolResultJSON unwraps the MCP tools/call envelope (content[].text + +// isError) into a flatter shape that callers can consume without a second +// parse step. If content[0].text is valid JSON, it's parsed into data; +// otherwise data holds the raw text. +func emitToolResultJSON(raw json.RawMessage) error { + var env struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } + + out := map[string]interface{}{"ok": true} + + if err := json.Unmarshal(raw, &env); err != nil || len(env.Content) == 0 { + // Fallback: return the raw result under data. + var parsed interface{} + if json.Unmarshal(raw, &parsed) != nil { + parsed = string(raw) + } + out["data"] = parsed + return emitJSON(out) + } + + text := env.Content[0].Text + var parsed interface{} + if json.Unmarshal([]byte(text), &parsed) == nil { + out["data"] = parsed + } else { + out["data"] = text + } + + if env.IsError { + out["ok"] = false + if s, ok := out["data"].(string); ok { + out["error"] = s + } else { + out["error"] = "tool returned isError=true" + } + if err := emitJSON(out); err != nil { + return err + } + return fmt.Errorf("tool reported error") + } + + return emitJSON(out) +} + func truncate(s string, max int) string { s = strings.ReplaceAll(s, "\n", " ") if len(s) <= max { diff --git a/cmd/mcp_test.go b/cmd/mcp_test.go index b59276f..d99c773 100644 --- a/cmd/mcp_test.go +++ b/cmd/mcp_test.go @@ -1,9 +1,36 @@ package cmd import ( + "encoding/json" + "io" + "os" "testing" ) +// captureStdout runs fn with os.Stdout redirected to a pipe and returns the +// captured bytes. Used to test emitJSON-family helpers without plumbing a +// writer through every caller. +func captureStdout(t *testing.T, fn func()) []byte { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + defer func() { os.Stdout = orig }() + + done := make(chan []byte, 1) + go func() { + b, _ := io.ReadAll(r) + done <- b + }() + + fn() + w.Close() + return <-done +} + func TestTruncate(t *testing.T) { tests := []struct { input string @@ -24,3 +51,101 @@ func TestTruncate(t *testing.T) { } } } + +func TestEmitToolResultJSON_UnwrapsJSONTextContent(t *testing.T) { + raw := json.RawMessage(`{"content":[{"type":"text","text":"{\"name\":\"ad-1\",\"id\":42}"}],"isError":false}`) + + out := captureStdout(t, func() { + if err := emitToolResultJSON(raw); err != nil { + t.Fatalf("emitToolResultJSON: %v", err) + } + }) + + var got map[string]interface{} + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("stdout not valid JSON: %v\n%s", err, out) + } + if got["ok"] != true { + t.Errorf("ok = %v, want true", got["ok"]) + } + data, ok := got["data"].(map[string]interface{}) + if !ok { + t.Fatalf("data not an object: %T", got["data"]) + } + if data["name"] != "ad-1" { + t.Errorf("data.name = %v, want ad-1", data["name"]) + } +} + +func TestEmitToolResultJSON_IsErrorSetsOkFalse(t *testing.T) { + raw := json.RawMessage(`{"content":[{"type":"text","text":"auth expired"}],"isError":true}`) + + var retErr error + out := captureStdout(t, func() { + retErr = emitToolResultJSON(raw) + }) + if retErr == nil { + t.Fatalf("expected error return so exit code is non-zero") + } + + var got map[string]interface{} + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("stdout not valid JSON: %v\n%s", err, out) + } + if got["ok"] != false { + t.Errorf("ok = %v, want false", got["ok"]) + } + if got["error"] != "auth expired" { + t.Errorf("error = %v, want %q", got["error"], "auth expired") + } + if got["data"] != "auth expired" { + t.Errorf("data = %v, want raw text", got["data"]) + } +} + +func TestEmitToolResultJSON_PassthroughWhenNotEnvelope(t *testing.T) { + raw := json.RawMessage(`{"foo":"bar"}`) + + out := captureStdout(t, func() { + if err := emitToolResultJSON(raw); err != nil { + t.Fatalf("emitToolResultJSON: %v", err) + } + }) + + var got map[string]interface{} + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("stdout not valid JSON: %v\n%s", err, out) + } + data, ok := got["data"].(map[string]interface{}) + if !ok { + t.Fatalf("data not an object: %T", got["data"]) + } + if data["foo"] != "bar" { + t.Errorf("data.foo = %v, want bar", data["foo"]) + } +} + +func TestEmitJSONError_InJSONMode(t *testing.T) { + prev := mcpJSON + mcpJSON = true + defer func() { mcpJSON = prev }() + + var retErr error + out := captureStdout(t, func() { + retErr = emitJSONError(io.EOF) + }) + if retErr != io.EOF { + t.Errorf("expected io.EOF to propagate for exit code, got %v", retErr) + } + + var got map[string]interface{} + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("stdout not valid JSON: %v\n%s", err, out) + } + if got["ok"] != false { + t.Errorf("ok = %v, want false", got["ok"]) + } + if got["error"] != "EOF" { + t.Errorf("error = %v, want EOF", got["error"]) + } +}