Skip to content

Commit 6f63ac0

Browse files
JAORMXclaude
andauthored
Add 'thv mcp call' for invoking MCP server tools (#5389)
Listing tools via 'thv mcp list' showed what an MCP server exposes but not how it behaves. Adding 'thv mcp call' lets the user open a session, invoke a named tool, and inspect the result without leaving the CLI -- useful for ad-hoc debugging, registry verification, and shell pipelines. The subcommand accepts a positional tool name, server URL or workload name via --server, and JSON arguments via --args, --args-file, or stdin (--args-file -). Output is rendered in text by default or as the full CallToolResult in JSON. Tool-level errors exit non-zero by default; --ignore-tool-error inverts that for pipelines that want to inspect the result regardless. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0e7a0a9 commit 6f63ac0

9 files changed

Lines changed: 476 additions & 0 deletions

File tree

cmd/thv/app/mcp.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ func newMCPCommand() *cobra.Command {
3737
// Add serve subcommand
3838
cmd.AddCommand(newMCPServeCommand())
3939

40+
// Add call subcommand
41+
cmd.AddCommand(newMCPCallCommand())
42+
4043
// Create list command
4144
listCmd := &cobra.Command{
4245
Use: "list [tools|resources|prompts]",

cmd/thv/app/mcp_call.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package app
5+
6+
import (
7+
"context"
8+
"encoding/base64"
9+
"encoding/json"
10+
"fmt"
11+
"io"
12+
"os"
13+
"time"
14+
15+
"github.com/mark3labs/mcp-go/mcp"
16+
"github.com/spf13/cobra"
17+
18+
thclient "github.com/stacklok/toolhive/pkg/mcp/client"
19+
)
20+
21+
var (
22+
mcpCallArgs string
23+
mcpCallArgsFile string
24+
mcpCallIgnoreToolError bool
25+
)
26+
27+
func newMCPCallCommand() *cobra.Command {
28+
cmd := &cobra.Command{
29+
Use: "call <tool-name>",
30+
Short: "Invoke a tool on an MCP server",
31+
Long: `Invoke a tool on an MCP server. The server is connected, initialized,
32+
the tool is called with the supplied arguments, and the result is printed.
33+
34+
Arguments are supplied as a JSON object via --args or --args-file. If neither
35+
flag is set, the tool is called with an empty argument object.
36+
37+
By default, the command exits with a non-zero status when the tool reports an
38+
error (CallToolResult.IsError=true). Use --ignore-tool-error to exit zero in
39+
that case; transport and protocol failures always exit non-zero.`,
40+
Args: cobra.ExactArgs(1),
41+
RunE: mcpCallCmdFunc,
42+
}
43+
44+
cmd.Flags().StringVar(&mcpServerURL, "server", "",
45+
"MCP server URL or name from ToolHive registry (required)")
46+
AddFormatFlag(cmd, &mcpFormat)
47+
cmd.Flags().DurationVar(&mcpTimeout, "timeout", 30*time.Second, "Connection timeout")
48+
cmd.Flags().StringVar(&mcpTransport, "transport", "auto", "Transport type (auto, sse, streamable-http)")
49+
cmd.Flags().StringVar(&mcpCallArgs, "args", "", "Tool arguments as a JSON object literal")
50+
cmd.Flags().StringVar(&mcpCallArgsFile, "args-file", "",
51+
"Path to a file containing a JSON object of tool arguments (use '-' to read from stdin)")
52+
cmd.Flags().BoolVar(&mcpCallIgnoreToolError, "ignore-tool-error", false,
53+
"Exit zero even when the tool reports an error (default is non-zero)")
54+
cmd.MarkFlagsMutuallyExclusive("args", "args-file")
55+
56+
_ = cmd.MarkFlagRequired("server")
57+
cmd.PreRunE = ValidateFormat(&mcpFormat)
58+
59+
return cmd
60+
}
61+
62+
func mcpCallCmdFunc(cmd *cobra.Command, posArgs []string) error {
63+
toolName := posArgs[0]
64+
65+
args, err := readToolArgs(mcpCallArgs, mcpCallArgsFile, cmd.InOrStdin())
66+
if err != nil {
67+
return err
68+
}
69+
70+
ctx, cancel := context.WithTimeout(cmd.Context(), mcpTimeout)
71+
defer cancel()
72+
73+
serverURL, err := resolveServerURL(ctx, mcpServerURL)
74+
if err != nil {
75+
return err
76+
}
77+
78+
result, err := thclient.CallTool(ctx, serverURL, mcpTransport, "toolhive-cli", toolName, args)
79+
if err != nil {
80+
return err
81+
}
82+
83+
if err := renderCallResult(result, mcpFormat); err != nil {
84+
return err
85+
}
86+
87+
if result.IsError && !mcpCallIgnoreToolError {
88+
// SilenceUsage so the cobra help dump doesn't follow a tool-level error;
89+
// the result has already been rendered above.
90+
cmd.SilenceUsage = true
91+
return fmt.Errorf("tool %q reported an error", toolName)
92+
}
93+
return nil
94+
}
95+
96+
// readToolArgs returns the parsed JSON object of tool arguments. An empty
97+
// argString and empty argFile yields nil (no arguments).
98+
func readToolArgs(argString, argFile string, stdin io.Reader) (map[string]any, error) {
99+
var raw []byte
100+
switch {
101+
case argString != "":
102+
raw = []byte(argString)
103+
case argFile == "-":
104+
b, err := io.ReadAll(stdin)
105+
if err != nil {
106+
return nil, fmt.Errorf("failed to read args from stdin: %w", err)
107+
}
108+
raw = b
109+
case argFile != "":
110+
// #nosec G304 -- argFile is a user-supplied path passed via --args-file.
111+
b, err := os.ReadFile(argFile)
112+
if err != nil {
113+
return nil, fmt.Errorf("failed to read args file: %w", err)
114+
}
115+
raw = b
116+
default:
117+
return nil, nil
118+
}
119+
120+
var parsed any
121+
if err := json.Unmarshal(raw, &parsed); err != nil {
122+
return nil, fmt.Errorf("failed to parse tool arguments as JSON: %w", err)
123+
}
124+
obj, ok := parsed.(map[string]any)
125+
if !ok {
126+
return nil, fmt.Errorf("tool arguments must be a JSON object, got %T", parsed)
127+
}
128+
return obj, nil
129+
}
130+
131+
func renderCallResult(result *mcp.CallToolResult, format string) error {
132+
if format == FormatJSON {
133+
out, err := json.MarshalIndent(result, "", " ")
134+
if err != nil {
135+
return fmt.Errorf("failed to marshal result: %w", err)
136+
}
137+
fmt.Println(string(out))
138+
return nil
139+
}
140+
return renderCallResultText(result)
141+
}
142+
143+
func renderCallResultText(result *mcp.CallToolResult) error {
144+
if result.IsError {
145+
_, _ = fmt.Fprintln(os.Stderr, "Error:")
146+
}
147+
for _, content := range result.Content {
148+
fmt.Println(formatContent(content))
149+
}
150+
if result.StructuredContent != nil {
151+
b, err := json.MarshalIndent(result.StructuredContent, "", " ")
152+
if err != nil {
153+
return fmt.Errorf("failed to marshal structured content: %w", err)
154+
}
155+
fmt.Println("Structured content:")
156+
fmt.Println(string(b))
157+
}
158+
return nil
159+
}
160+
161+
// formatContent renders a single Content item for text output. Non-text
162+
// payloads are stubbed (e.g. binary data is shown as a size summary rather
163+
// than dumped to the terminal).
164+
func formatContent(content mcp.Content) string {
165+
switch c := content.(type) {
166+
case mcp.TextContent:
167+
return c.Text
168+
case mcp.ImageContent:
169+
return formatBinaryContent("image", c.MIMEType, c.Data)
170+
case mcp.AudioContent:
171+
return formatBinaryContent("audio", c.MIMEType, c.Data)
172+
case mcp.ResourceLink:
173+
return formatResourceLink(c)
174+
case mcp.EmbeddedResource:
175+
return "[embedded resource]"
176+
default:
177+
return fmt.Sprintf("[unknown content type %T]", content)
178+
}
179+
}
180+
181+
func formatResourceLink(c mcp.ResourceLink) string {
182+
mimeType := c.MIMEType
183+
if mimeType == "" {
184+
mimeType = "unknown"
185+
}
186+
name := c.Name
187+
if name == "" {
188+
name = c.URI
189+
}
190+
return fmt.Sprintf("[resource link: %s (%s, %s)]", name, c.URI, mimeType)
191+
}
192+
193+
func formatBinaryContent(kind, mimeType, b64data string) string {
194+
// Report decoded byte length when possible; fall back to encoded length.
195+
size := len(b64data)
196+
if decoded, err := base64.StdEncoding.DecodeString(b64data); err == nil {
197+
size = len(decoded)
198+
}
199+
if mimeType == "" {
200+
mimeType = "unknown"
201+
}
202+
return fmt.Sprintf("[%s: %s, %d bytes]", kind, mimeType, size)
203+
}

cmd/thv/app/mcp_call_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package app
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
12+
"github.com/mark3labs/mcp-go/mcp"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestReadToolArgs(t *testing.T) {
18+
t.Parallel()
19+
20+
t.Run("empty inputs yield nil", func(t *testing.T) {
21+
t.Parallel()
22+
args, err := readToolArgs("", "", strings.NewReader(""))
23+
require.NoError(t, err)
24+
assert.Nil(t, args)
25+
})
26+
27+
t.Run("inline JSON object", func(t *testing.T) {
28+
t.Parallel()
29+
args, err := readToolArgs(`{"name":"world","count":3}`, "", strings.NewReader(""))
30+
require.NoError(t, err)
31+
assert.Equal(t, "world", args["name"])
32+
assert.InDelta(t, 3, args["count"], 0)
33+
})
34+
35+
t.Run("stdin via dash", func(t *testing.T) {
36+
t.Parallel()
37+
args, err := readToolArgs("", "-", strings.NewReader(`{"foo":"bar"}`))
38+
require.NoError(t, err)
39+
assert.Equal(t, "bar", args["foo"])
40+
})
41+
42+
t.Run("file path", func(t *testing.T) {
43+
t.Parallel()
44+
dir := t.TempDir()
45+
path := filepath.Join(dir, "args.json")
46+
require.NoError(t, os.WriteFile(path, []byte(`{"a":1}`), 0o600))
47+
args, err := readToolArgs("", path, strings.NewReader(""))
48+
require.NoError(t, err)
49+
assert.InDelta(t, 1, args["a"], 0)
50+
})
51+
52+
t.Run("invalid JSON", func(t *testing.T) {
53+
t.Parallel()
54+
_, err := readToolArgs(`{not-json`, "", strings.NewReader(""))
55+
require.Error(t, err)
56+
assert.Contains(t, err.Error(), "parse tool arguments as JSON")
57+
})
58+
59+
t.Run("non-object JSON is rejected", func(t *testing.T) {
60+
t.Parallel()
61+
_, err := readToolArgs(`[1,2,3]`, "", strings.NewReader(""))
62+
require.Error(t, err)
63+
assert.Contains(t, err.Error(), "must be a JSON object")
64+
})
65+
66+
t.Run("missing file returns error", func(t *testing.T) {
67+
t.Parallel()
68+
_, err := readToolArgs("", "/nonexistent/path/args.json", strings.NewReader(""))
69+
require.Error(t, err)
70+
assert.Contains(t, err.Error(), "read args file")
71+
})
72+
}
73+
74+
func TestFormatBinaryContent(t *testing.T) {
75+
t.Parallel()
76+
77+
t.Run("valid base64 reports decoded size", func(t *testing.T) {
78+
t.Parallel()
79+
// "hello" -> aGVsbG8= (5 bytes decoded)
80+
got := formatBinaryContent("image", "image/png", "aGVsbG8=")
81+
assert.Equal(t, "[image: image/png, 5 bytes]", got)
82+
})
83+
84+
t.Run("invalid base64 falls back to encoded length", func(t *testing.T) {
85+
t.Parallel()
86+
got := formatBinaryContent("audio", "audio/wav", "!!!not-base64!!!")
87+
assert.Contains(t, got, "audio/wav")
88+
assert.Contains(t, got, "bytes]")
89+
})
90+
91+
t.Run("empty mime type", func(t *testing.T) {
92+
t.Parallel()
93+
got := formatBinaryContent("image", "", "aGVsbG8=")
94+
assert.Contains(t, got, "unknown")
95+
})
96+
}
97+
98+
func TestFormatContentResourceLink(t *testing.T) {
99+
t.Parallel()
100+
101+
t.Run("full fields", func(t *testing.T) {
102+
t.Parallel()
103+
got := formatContent(mcp.ResourceLink{
104+
Type: "resource_link",
105+
URI: "file:///tmp/foo.txt",
106+
Name: "foo.txt",
107+
MIMEType: "text/plain",
108+
})
109+
assert.Equal(t, "[resource link: foo.txt (file:///tmp/foo.txt, text/plain)]", got)
110+
})
111+
112+
t.Run("missing name falls back to URI", func(t *testing.T) {
113+
t.Parallel()
114+
got := formatContent(mcp.ResourceLink{
115+
Type: "resource_link",
116+
URI: "file:///tmp/foo.txt",
117+
})
118+
assert.Contains(t, got, "file:///tmp/foo.txt")
119+
assert.Contains(t, got, "unknown")
120+
})
121+
}

docs/cli/thv_mcp.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli/thv_mcp_call.md

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)