Skip to content

Commit aa18be7

Browse files
authored
feat(mcp): add --json mode that unwraps tool response envelope (#15)
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": <parsed>} Tool-level error (isError=true): {"ok": false, "error": "...", "data": "..."} + exit 1 Transport/auth error: {"ok": false, "error": "..."} + exit 1
1 parent 94fcd91 commit aa18be7

2 files changed

Lines changed: 235 additions & 10 deletions

File tree

‎cmd/mcp.go‎

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ var mcpCmd = &cobra.Command{
1717

1818
var (
1919
mcpServer string
20+
mcpJSON bool
2021
)
2122

2223
var mcpToolsListCmd = &cobra.Command{
@@ -44,6 +45,7 @@ var (
4445
func init() {
4546
mcpCmd.PersistentFlags().StringVar(&mcpServer, "server", "", "MCP server path, e.g. meta-ads-mcp (required)")
4647
mcpCmd.MarkPersistentFlagRequired("server")
48+
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.")
4749

4850
mcpToolsCallCmd.Flags().StringVar(&mcpToolName, "tool", "", "Tool name to call (required)")
4951
mcpToolsCallCmd.Flags().StringVar(&mcpToolArgs, "args", "{}", "JSON object of tool arguments")
@@ -65,20 +67,27 @@ func newMCPClient() (*client.Client, error) {
6567
}
6668

6769
func runMCPToolsList(cmd *cobra.Command, args []string) error {
70+
if mcpJSON {
71+
cmd.SilenceUsage = true
72+
cmd.SilenceErrors = true
73+
}
74+
6875
c, err := newMCPClient()
6976
if err != nil {
70-
return err
77+
return emitJSONError(err)
7178
}
7279

73-
fmt.Fprintf(os.Stderr, "Connecting to %s/%s...\n", apiURL, mcpServer)
80+
if !mcpJSON {
81+
fmt.Fprintf(os.Stderr, "Connecting to %s/%s...\n", apiURL, mcpServer)
82+
}
7483

7584
if err := c.Initialize(mcpServer); err != nil {
76-
return fmt.Errorf("handshake failed: %w", err)
85+
return emitJSONError(fmt.Errorf("handshake failed: %w", err))
7786
}
7887

7988
result, err := c.ListTools(mcpServer)
8089
if err != nil {
81-
return fmt.Errorf("listing tools: %w", err)
90+
return emitJSONError(fmt.Errorf("listing tools: %w", err))
8291
}
8392

8493
// Filter if requested
@@ -93,6 +102,14 @@ func runMCPToolsList(cmd *cobra.Command, args []string) error {
93102
tools = filtered
94103
}
95104

105+
if mcpJSON {
106+
return emitJSON(map[string]interface{}{
107+
"ok": true,
108+
"tools": tools,
109+
"total": len(result.Tools),
110+
})
111+
}
112+
96113
fmt.Fprintf(os.Stderr, "Found %d tools (showing %d)\n\n", len(result.Tools), len(tools))
97114

98115
if mcpToolsListVerbose {
@@ -118,27 +135,40 @@ func runMCPToolsList(cmd *cobra.Command, args []string) error {
118135
}
119136

120137
func runMCPToolsCall(cmd *cobra.Command, args []string) error {
138+
if mcpJSON {
139+
cmd.SilenceUsage = true
140+
cmd.SilenceErrors = true
141+
}
142+
121143
c, err := newMCPClient()
122144
if err != nil {
123-
return err
145+
return emitJSONError(err)
124146
}
125147

126-
fmt.Fprintf(os.Stderr, "Connecting to %s/%s...\n", apiURL, mcpServer)
148+
if !mcpJSON {
149+
fmt.Fprintf(os.Stderr, "Connecting to %s/%s...\n", apiURL, mcpServer)
150+
}
127151

128152
if err := c.Initialize(mcpServer); err != nil {
129-
return fmt.Errorf("handshake failed: %w", err)
153+
return emitJSONError(fmt.Errorf("handshake failed: %w", err))
130154
}
131155

132156
var toolArgs map[string]interface{}
133157
if err := json.Unmarshal([]byte(mcpToolArgs), &toolArgs); err != nil {
134-
return fmt.Errorf("invalid JSON in --args: %w", err)
158+
return emitJSONError(fmt.Errorf("invalid JSON in --args: %w", err))
135159
}
136160

137-
fmt.Fprintf(os.Stderr, "Calling %s...\n", mcpToolName)
161+
if !mcpJSON {
162+
fmt.Fprintf(os.Stderr, "Calling %s...\n", mcpToolName)
163+
}
138164

139165
result, err := c.CallTool(mcpServer, mcpToolName, toolArgs)
140166
if err != nil {
141-
return fmt.Errorf("tool call failed: %w", err)
167+
return emitJSONError(fmt.Errorf("tool call failed: %w", err))
168+
}
169+
170+
if mcpJSON {
171+
return emitToolResultJSON(result)
142172
}
143173

144174
// Pretty-print the result
@@ -154,6 +184,76 @@ func runMCPToolsCall(cmd *cobra.Command, args []string) error {
154184
return enc.Encode(parsed)
155185
}
156186

187+
// emitJSON writes a structured JSON envelope to stdout with pretty indentation.
188+
func emitJSON(payload map[string]interface{}) error {
189+
enc := json.NewEncoder(os.Stdout)
190+
enc.SetIndent("", " ")
191+
return enc.Encode(payload)
192+
}
193+
194+
// emitJSONError emits a structured error on stdout in JSON mode, then returns
195+
// the original error so the command still exits non-zero. In non-JSON mode it
196+
// just returns the error (cobra prints it to stderr).
197+
func emitJSONError(err error) error {
198+
if !mcpJSON {
199+
return err
200+
}
201+
_ = emitJSON(map[string]interface{}{
202+
"ok": false,
203+
"error": err.Error(),
204+
})
205+
return err
206+
}
207+
208+
// emitToolResultJSON unwraps the MCP tools/call envelope (content[].text +
209+
// isError) into a flatter shape that callers can consume without a second
210+
// parse step. If content[0].text is valid JSON, it's parsed into data;
211+
// otherwise data holds the raw text.
212+
func emitToolResultJSON(raw json.RawMessage) error {
213+
var env struct {
214+
Content []struct {
215+
Type string `json:"type"`
216+
Text string `json:"text"`
217+
} `json:"content"`
218+
IsError bool `json:"isError"`
219+
}
220+
221+
out := map[string]interface{}{"ok": true}
222+
223+
if err := json.Unmarshal(raw, &env); err != nil || len(env.Content) == 0 {
224+
// Fallback: return the raw result under data.
225+
var parsed interface{}
226+
if json.Unmarshal(raw, &parsed) != nil {
227+
parsed = string(raw)
228+
}
229+
out["data"] = parsed
230+
return emitJSON(out)
231+
}
232+
233+
text := env.Content[0].Text
234+
var parsed interface{}
235+
if json.Unmarshal([]byte(text), &parsed) == nil {
236+
out["data"] = parsed
237+
} else {
238+
out["data"] = text
239+
}
240+
241+
if env.IsError {
242+
out["ok"] = false
243+
if s, ok := out["data"].(string); ok {
244+
out["error"] = s
245+
} else {
246+
out["error"] = "tool returned isError=true"
247+
}
248+
if err := emitJSON(out); err != nil {
249+
return err
250+
}
251+
return fmt.Errorf("tool reported error")
252+
}
253+
254+
return emitJSON(out)
255+
}
256+
157257
func truncate(s string, max int) string {
158258
s = strings.ReplaceAll(s, "\n", " ")
159259
if len(s) <= max {

‎cmd/mcp_test.go‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,36 @@
11
package cmd
22

33
import (
4+
"encoding/json"
5+
"io"
6+
"os"
47
"testing"
58
)
69

10+
// captureStdout runs fn with os.Stdout redirected to a pipe and returns the
11+
// captured bytes. Used to test emitJSON-family helpers without plumbing a
12+
// writer through every caller.
13+
func captureStdout(t *testing.T, fn func()) []byte {
14+
t.Helper()
15+
orig := os.Stdout
16+
r, w, err := os.Pipe()
17+
if err != nil {
18+
t.Fatalf("pipe: %v", err)
19+
}
20+
os.Stdout = w
21+
defer func() { os.Stdout = orig }()
22+
23+
done := make(chan []byte, 1)
24+
go func() {
25+
b, _ := io.ReadAll(r)
26+
done <- b
27+
}()
28+
29+
fn()
30+
w.Close()
31+
return <-done
32+
}
33+
734
func TestTruncate(t *testing.T) {
835
tests := []struct {
936
input string
@@ -24,3 +51,101 @@ func TestTruncate(t *testing.T) {
2451
}
2552
}
2653
}
54+
55+
func TestEmitToolResultJSON_UnwrapsJSONTextContent(t *testing.T) {
56+
raw := json.RawMessage(`{"content":[{"type":"text","text":"{\"name\":\"ad-1\",\"id\":42}"}],"isError":false}`)
57+
58+
out := captureStdout(t, func() {
59+
if err := emitToolResultJSON(raw); err != nil {
60+
t.Fatalf("emitToolResultJSON: %v", err)
61+
}
62+
})
63+
64+
var got map[string]interface{}
65+
if err := json.Unmarshal(out, &got); err != nil {
66+
t.Fatalf("stdout not valid JSON: %v\n%s", err, out)
67+
}
68+
if got["ok"] != true {
69+
t.Errorf("ok = %v, want true", got["ok"])
70+
}
71+
data, ok := got["data"].(map[string]interface{})
72+
if !ok {
73+
t.Fatalf("data not an object: %T", got["data"])
74+
}
75+
if data["name"] != "ad-1" {
76+
t.Errorf("data.name = %v, want ad-1", data["name"])
77+
}
78+
}
79+
80+
func TestEmitToolResultJSON_IsErrorSetsOkFalse(t *testing.T) {
81+
raw := json.RawMessage(`{"content":[{"type":"text","text":"auth expired"}],"isError":true}`)
82+
83+
var retErr error
84+
out := captureStdout(t, func() {
85+
retErr = emitToolResultJSON(raw)
86+
})
87+
if retErr == nil {
88+
t.Fatalf("expected error return so exit code is non-zero")
89+
}
90+
91+
var got map[string]interface{}
92+
if err := json.Unmarshal(out, &got); err != nil {
93+
t.Fatalf("stdout not valid JSON: %v\n%s", err, out)
94+
}
95+
if got["ok"] != false {
96+
t.Errorf("ok = %v, want false", got["ok"])
97+
}
98+
if got["error"] != "auth expired" {
99+
t.Errorf("error = %v, want %q", got["error"], "auth expired")
100+
}
101+
if got["data"] != "auth expired" {
102+
t.Errorf("data = %v, want raw text", got["data"])
103+
}
104+
}
105+
106+
func TestEmitToolResultJSON_PassthroughWhenNotEnvelope(t *testing.T) {
107+
raw := json.RawMessage(`{"foo":"bar"}`)
108+
109+
out := captureStdout(t, func() {
110+
if err := emitToolResultJSON(raw); err != nil {
111+
t.Fatalf("emitToolResultJSON: %v", err)
112+
}
113+
})
114+
115+
var got map[string]interface{}
116+
if err := json.Unmarshal(out, &got); err != nil {
117+
t.Fatalf("stdout not valid JSON: %v\n%s", err, out)
118+
}
119+
data, ok := got["data"].(map[string]interface{})
120+
if !ok {
121+
t.Fatalf("data not an object: %T", got["data"])
122+
}
123+
if data["foo"] != "bar" {
124+
t.Errorf("data.foo = %v, want bar", data["foo"])
125+
}
126+
}
127+
128+
func TestEmitJSONError_InJSONMode(t *testing.T) {
129+
prev := mcpJSON
130+
mcpJSON = true
131+
defer func() { mcpJSON = prev }()
132+
133+
var retErr error
134+
out := captureStdout(t, func() {
135+
retErr = emitJSONError(io.EOF)
136+
})
137+
if retErr != io.EOF {
138+
t.Errorf("expected io.EOF to propagate for exit code, got %v", retErr)
139+
}
140+
141+
var got map[string]interface{}
142+
if err := json.Unmarshal(out, &got); err != nil {
143+
t.Fatalf("stdout not valid JSON: %v\n%s", err, out)
144+
}
145+
if got["ok"] != false {
146+
t.Errorf("ok = %v, want false", got["ok"])
147+
}
148+
if got["error"] != "EOF" {
149+
t.Errorf("error = %v, want EOF", got["error"])
150+
}
151+
}

0 commit comments

Comments
 (0)