Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 110 additions & 10 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ var mcpCmd = &cobra.Command{

var (
mcpServer string
mcpJSON bool
)

var mcpToolsListCmd = &cobra.Command{
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down
125 changes: 125 additions & 0 deletions cmd/mcp_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"])
}
}
Loading