diff --git a/cmd/thv/app/mcp.go b/cmd/thv/app/mcp.go index 806da8ac4d..09974cc1a2 100644 --- a/cmd/thv/app/mcp.go +++ b/cmd/thv/app/mcp.go @@ -37,6 +37,9 @@ func newMCPCommand() *cobra.Command { // Add serve subcommand cmd.AddCommand(newMCPServeCommand()) + // Add call subcommand + cmd.AddCommand(newMCPCallCommand()) + // Create list command listCmd := &cobra.Command{ Use: "list [tools|resources|prompts]", diff --git a/cmd/thv/app/mcp_call.go b/cmd/thv/app/mcp_call.go new file mode 100644 index 0000000000..2932124844 --- /dev/null +++ b/cmd/thv/app/mcp_call.go @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "os" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/spf13/cobra" + + thclient "github.com/stacklok/toolhive/pkg/mcp/client" +) + +var ( + mcpCallArgs string + mcpCallArgsFile string + mcpCallIgnoreToolError bool +) + +func newMCPCallCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "call ", + Short: "Invoke a tool on an MCP server", + Long: `Invoke a tool on an MCP server. The server is connected, initialized, +the tool is called with the supplied arguments, and the result is printed. + +Arguments are supplied as a JSON object via --args or --args-file. If neither +flag is set, the tool is called with an empty argument object. + +By default, the command exits with a non-zero status when the tool reports an +error (CallToolResult.IsError=true). Use --ignore-tool-error to exit zero in +that case; transport and protocol failures always exit non-zero.`, + Args: cobra.ExactArgs(1), + RunE: mcpCallCmdFunc, + } + + cmd.Flags().StringVar(&mcpServerURL, "server", "", + "MCP server URL or name from ToolHive registry (required)") + AddFormatFlag(cmd, &mcpFormat) + cmd.Flags().DurationVar(&mcpTimeout, "timeout", 30*time.Second, "Connection timeout") + cmd.Flags().StringVar(&mcpTransport, "transport", "auto", "Transport type (auto, sse, streamable-http)") + cmd.Flags().StringVar(&mcpCallArgs, "args", "", "Tool arguments as a JSON object literal") + cmd.Flags().StringVar(&mcpCallArgsFile, "args-file", "", + "Path to a file containing a JSON object of tool arguments (use '-' to read from stdin)") + cmd.Flags().BoolVar(&mcpCallIgnoreToolError, "ignore-tool-error", false, + "Exit zero even when the tool reports an error (default is non-zero)") + cmd.MarkFlagsMutuallyExclusive("args", "args-file") + + _ = cmd.MarkFlagRequired("server") + cmd.PreRunE = ValidateFormat(&mcpFormat) + + return cmd +} + +func mcpCallCmdFunc(cmd *cobra.Command, posArgs []string) error { + toolName := posArgs[0] + + args, err := readToolArgs(mcpCallArgs, mcpCallArgsFile, cmd.InOrStdin()) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(cmd.Context(), mcpTimeout) + defer cancel() + + serverURL, err := resolveServerURL(ctx, mcpServerURL) + if err != nil { + return err + } + + result, err := thclient.CallTool(ctx, serverURL, mcpTransport, "toolhive-cli", toolName, args) + if err != nil { + return err + } + + if err := renderCallResult(result, mcpFormat); err != nil { + return err + } + + if result.IsError && !mcpCallIgnoreToolError { + // SilenceUsage so the cobra help dump doesn't follow a tool-level error; + // the result has already been rendered above. + cmd.SilenceUsage = true + return fmt.Errorf("tool %q reported an error", toolName) + } + return nil +} + +// readToolArgs returns the parsed JSON object of tool arguments. An empty +// argString and empty argFile yields nil (no arguments). +func readToolArgs(argString, argFile string, stdin io.Reader) (map[string]any, error) { + var raw []byte + switch { + case argString != "": + raw = []byte(argString) + case argFile == "-": + b, err := io.ReadAll(stdin) + if err != nil { + return nil, fmt.Errorf("failed to read args from stdin: %w", err) + } + raw = b + case argFile != "": + // #nosec G304 -- argFile is a user-supplied path passed via --args-file. + b, err := os.ReadFile(argFile) + if err != nil { + return nil, fmt.Errorf("failed to read args file: %w", err) + } + raw = b + default: + return nil, nil + } + + var parsed any + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("failed to parse tool arguments as JSON: %w", err) + } + obj, ok := parsed.(map[string]any) + if !ok { + return nil, fmt.Errorf("tool arguments must be a JSON object, got %T", parsed) + } + return obj, nil +} + +func renderCallResult(result *mcp.CallToolResult, format string) error { + if format == FormatJSON { + out, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal result: %w", err) + } + fmt.Println(string(out)) + return nil + } + return renderCallResultText(result) +} + +func renderCallResultText(result *mcp.CallToolResult) error { + if result.IsError { + _, _ = fmt.Fprintln(os.Stderr, "Error:") + } + for _, content := range result.Content { + fmt.Println(formatContent(content)) + } + if result.StructuredContent != nil { + b, err := json.MarshalIndent(result.StructuredContent, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal structured content: %w", err) + } + fmt.Println("Structured content:") + fmt.Println(string(b)) + } + return nil +} + +// formatContent renders a single Content item for text output. Non-text +// payloads are stubbed (e.g. binary data is shown as a size summary rather +// than dumped to the terminal). +func formatContent(content mcp.Content) string { + switch c := content.(type) { + case mcp.TextContent: + return c.Text + case mcp.ImageContent: + return formatBinaryContent("image", c.MIMEType, c.Data) + case mcp.AudioContent: + return formatBinaryContent("audio", c.MIMEType, c.Data) + case mcp.ResourceLink: + return formatResourceLink(c) + case mcp.EmbeddedResource: + return "[embedded resource]" + default: + return fmt.Sprintf("[unknown content type %T]", content) + } +} + +func formatResourceLink(c mcp.ResourceLink) string { + mimeType := c.MIMEType + if mimeType == "" { + mimeType = "unknown" + } + name := c.Name + if name == "" { + name = c.URI + } + return fmt.Sprintf("[resource link: %s (%s, %s)]", name, c.URI, mimeType) +} + +func formatBinaryContent(kind, mimeType, b64data string) string { + // Report decoded byte length when possible; fall back to encoded length. + size := len(b64data) + if decoded, err := base64.StdEncoding.DecodeString(b64data); err == nil { + size = len(decoded) + } + if mimeType == "" { + mimeType = "unknown" + } + return fmt.Sprintf("[%s: %s, %d bytes]", kind, mimeType, size) +} diff --git a/cmd/thv/app/mcp_call_test.go b/cmd/thv/app/mcp_call_test.go new file mode 100644 index 0000000000..489db112dc --- /dev/null +++ b/cmd/thv/app/mcp_call_test.go @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadToolArgs(t *testing.T) { + t.Parallel() + + t.Run("empty inputs yield nil", func(t *testing.T) { + t.Parallel() + args, err := readToolArgs("", "", strings.NewReader("")) + require.NoError(t, err) + assert.Nil(t, args) + }) + + t.Run("inline JSON object", func(t *testing.T) { + t.Parallel() + args, err := readToolArgs(`{"name":"world","count":3}`, "", strings.NewReader("")) + require.NoError(t, err) + assert.Equal(t, "world", args["name"]) + assert.InDelta(t, 3, args["count"], 0) + }) + + t.Run("stdin via dash", func(t *testing.T) { + t.Parallel() + args, err := readToolArgs("", "-", strings.NewReader(`{"foo":"bar"}`)) + require.NoError(t, err) + assert.Equal(t, "bar", args["foo"]) + }) + + t.Run("file path", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "args.json") + require.NoError(t, os.WriteFile(path, []byte(`{"a":1}`), 0o600)) + args, err := readToolArgs("", path, strings.NewReader("")) + require.NoError(t, err) + assert.InDelta(t, 1, args["a"], 0) + }) + + t.Run("invalid JSON", func(t *testing.T) { + t.Parallel() + _, err := readToolArgs(`{not-json`, "", strings.NewReader("")) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse tool arguments as JSON") + }) + + t.Run("non-object JSON is rejected", func(t *testing.T) { + t.Parallel() + _, err := readToolArgs(`[1,2,3]`, "", strings.NewReader("")) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a JSON object") + }) + + t.Run("missing file returns error", func(t *testing.T) { + t.Parallel() + _, err := readToolArgs("", "/nonexistent/path/args.json", strings.NewReader("")) + require.Error(t, err) + assert.Contains(t, err.Error(), "read args file") + }) +} + +func TestFormatBinaryContent(t *testing.T) { + t.Parallel() + + t.Run("valid base64 reports decoded size", func(t *testing.T) { + t.Parallel() + // "hello" -> aGVsbG8= (5 bytes decoded) + got := formatBinaryContent("image", "image/png", "aGVsbG8=") + assert.Equal(t, "[image: image/png, 5 bytes]", got) + }) + + t.Run("invalid base64 falls back to encoded length", func(t *testing.T) { + t.Parallel() + got := formatBinaryContent("audio", "audio/wav", "!!!not-base64!!!") + assert.Contains(t, got, "audio/wav") + assert.Contains(t, got, "bytes]") + }) + + t.Run("empty mime type", func(t *testing.T) { + t.Parallel() + got := formatBinaryContent("image", "", "aGVsbG8=") + assert.Contains(t, got, "unknown") + }) +} + +func TestFormatContentResourceLink(t *testing.T) { + t.Parallel() + + t.Run("full fields", func(t *testing.T) { + t.Parallel() + got := formatContent(mcp.ResourceLink{ + Type: "resource_link", + URI: "file:///tmp/foo.txt", + Name: "foo.txt", + MIMEType: "text/plain", + }) + assert.Equal(t, "[resource link: foo.txt (file:///tmp/foo.txt, text/plain)]", got) + }) + + t.Run("missing name falls back to URI", func(t *testing.T) { + t.Parallel() + got := formatContent(mcp.ResourceLink{ + Type: "resource_link", + URI: "file:///tmp/foo.txt", + }) + assert.Contains(t, got, "file:///tmp/foo.txt") + assert.Contains(t, got, "unknown") + }) +} diff --git a/docs/cli/thv_mcp.md b/docs/cli/thv_mcp.md index ca6131c317..386dd26996 100644 --- a/docs/cli/thv_mcp.md +++ b/docs/cli/thv_mcp.md @@ -32,6 +32,7 @@ The mcp command provides subcommands to interact with MCP (Model Context Protoco ### SEE ALSO * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers +* [thv mcp call](thv_mcp_call.md) - Invoke a tool on an MCP server * [thv mcp list](thv_mcp_list.md) - List MCP server capabilities * [thv mcp serve](thv_mcp_serve.md) - 🧪 EXPERIMENTAL: Start an MCP server to control ToolHive diff --git a/docs/cli/thv_mcp_call.md b/docs/cli/thv_mcp_call.md new file mode 100644 index 0000000000..0973192fd2 --- /dev/null +++ b/docs/cli/thv_mcp_call.md @@ -0,0 +1,54 @@ +--- +title: thv mcp call +hide_title: true +description: Reference for ToolHive CLI command `thv mcp call` +last_update: + author: autogenerated +slug: thv_mcp_call +mdx: + format: md +--- + +## thv mcp call + +Invoke a tool on an MCP server + +### Synopsis + +Invoke a tool on an MCP server. The server is connected, initialized, +the tool is called with the supplied arguments, and the result is printed. + +Arguments are supplied as a JSON object via --args or --args-file. If neither +flag is set, the tool is called with an empty argument object. + +By default, the command exits with a non-zero status when the tool reports an +error (CallToolResult.IsError=true). Use --ignore-tool-error to exit zero in +that case; transport and protocol failures always exit non-zero. + +``` +thv mcp call [flags] +``` + +### Options + +``` + --args string Tool arguments as a JSON object literal + --args-file string Path to a file containing a JSON object of tool arguments (use '-' to read from stdin) + --format string Output format (json, text) (default "text") + -h, --help help for call + --ignore-tool-error Exit zero even when the tool reports an error (default is non-zero) + --server string MCP server URL or name from ToolHive registry (required) + --timeout duration Connection timeout (default 30s) + --transport string Transport type (auto, sse, streamable-http) (default "auto") +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv mcp](thv_mcp.md) - Interact with MCP servers for debugging + diff --git a/pkg/mcp/client/call.go b/pkg/mcp/client/call.go new file mode 100644 index 0000000000..84e050a696 --- /dev/null +++ b/pkg/mcp/client/call.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "context" + "fmt" + "log/slog" + + "github.com/mark3labs/mcp-go/mcp" +) + +// CallTool connects to the MCP server at serverURL, performs the initialize +// handshake, invokes the named tool with the supplied arguments, and returns +// the result. The connection is closed before returning. +// +// args may be nil; in that case the `arguments` field is omitted from the +// tool call request (equivalent to calling the tool with no inputs). +// +// A nil error indicates the MCP call completed; the returned result may still +// have IsError=true to signal a tool-level failure that the caller should +// surface to the user. +func CallTool( + ctx context.Context, + serverURL, transport, clientName, toolName string, + args map[string]any, +) (*mcp.CallToolResult, error) { + c, err := Connect(ctx, serverURL, transport, clientName) + if err != nil { + return nil, err + } + defer func() { + if cerr := c.Close(); cerr != nil { + slog.Warn("failed to close MCP client", "error", cerr) + } + }() + + req := mcp.CallToolRequest{} + req.Params.Name = toolName + req.Params.Arguments = args + + result, err := c.CallTool(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to call tool %q: %w", toolName, err) + } + return result, nil +} diff --git a/skills/toolhive-cli-user/SKILL.md b/skills/toolhive-cli-user/SKILL.md index a641373472..a5044a0419 100644 --- a/skills/toolhive-cli-user/SKILL.md +++ b/skills/toolhive-cli-user/SKILL.md @@ -220,9 +220,12 @@ thv inspector filesystem # MCP Inspector UI thv mcp list tools --server filesystem thv mcp list resources --server filesystem thv mcp list prompts --server filesystem +thv mcp call read_file --server filesystem --args '{"path":"/etc/hosts"}' # Invoke a tool thv runtime check # Verify container runtime ``` +For tool invocation patterns (file args, stdin, JSON output, error handling), see [EXAMPLES.md](references/EXAMPLES.md#invoke-a-tool). + ## Guardrails - NEVER use `docker rm` or `podman rm` on ToolHive-managed containers -- always use `thv rm` for proper cleanup. diff --git a/skills/toolhive-cli-user/references/COMMANDS.md b/skills/toolhive-cli-user/references/COMMANDS.md index 82c2782e85..4f78759d34 100644 --- a/skills/toolhive-cli-user/references/COMMANDS.md +++ b/skills/toolhive-cli-user/references/COMMANDS.md @@ -560,6 +560,30 @@ thv mcp list prompts --server SERVER | `--timeout` | Connection timeout | | | `--transport` | Transport (auto, sse, streamable-http) | auto | +### thv mcp call + +Invoke a tool on an MCP server. Opens a fresh MCP session, calls the tool, prints +the result, and closes the session. + +``` +thv mcp call TOOL_NAME --server SERVER [--args JSON | --args-file PATH] +``` + +**Flags:** +| Flag | Description | Default | +|------|-------------|---------| +| `--server` | Server URL or name | Required | +| `--args` | Tool arguments as a JSON object literal. Omit both `--args` and `--args-file` to call the tool with no arguments. | | +| `--args-file` | Path to JSON args file (`-` reads stdin); mutually exclusive with `--args` | | +| `--ignore-tool-error` | Exit zero even when the tool reports an error | false | +| `--format` | Output format (text, json) | text | +| `--timeout` | Connection timeout | 30s | +| `--transport` | Transport (auto, sse, streamable-http) | auto | + +Exits non-zero when the tool reports an error (`isError=true` in the result) +unless `--ignore-tool-error` is set. Transport and protocol failures always +exit non-zero. + ### thv runtime check Check container runtime. diff --git a/skills/toolhive-cli-user/references/EXAMPLES.md b/skills/toolhive-cli-user/references/EXAMPLES.md index 4cb8e4bfed..50b603e993 100644 --- a/skills/toolhive-cli-user/references/EXAMPLES.md +++ b/skills/toolhive-cli-user/references/EXAMPLES.md @@ -348,6 +348,25 @@ thv mcp list prompts --server filesystem thv mcp list tools --server filesystem --format json ``` +### Invoke a Tool + +```bash +# Inline JSON args +thv mcp call fetch --server fetch --args '{"url":"https://example.com"}' + +# Args from a file +thv mcp call read_file --server filesystem --args-file ./args.json + +# Args from stdin +echo '{"url":"https://example.com"}' | thv mcp call fetch --server fetch --args-file - + +# JSON output (full CallToolResult, includes content + structuredContent + isError) +thv mcp call fetch --server fetch --args '{"url":"https://example.com"}' --format json + +# Tool-reported errors normally exit non-zero; flip with --ignore-tool-error +thv mcp call fetch --server fetch --args '{"url":"not-a-url"}' --ignore-tool-error +``` + ### Launch Inspector UI ```bash