From b89fedb9d7d9473e27b46e322434739ab095aa61 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Thu, 28 May 2026 10:57:39 +0300 Subject: [PATCH] Add 'thv mcp call' for invoking MCP server tools 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) --- cmd/thv/app/mcp.go | 3 + cmd/thv/app/mcp_call.go | 203 ++++++++++++++++++ cmd/thv/app/mcp_call_test.go | 121 +++++++++++ docs/cli/thv_mcp.md | 1 + docs/cli/thv_mcp_call.md | 54 +++++ pkg/mcp/client/call.go | 48 +++++ skills/toolhive-cli-user/SKILL.md | 3 + .../toolhive-cli-user/references/COMMANDS.md | 24 +++ .../toolhive-cli-user/references/EXAMPLES.md | 19 ++ 9 files changed, 476 insertions(+) create mode 100644 cmd/thv/app/mcp_call.go create mode 100644 cmd/thv/app/mcp_call_test.go create mode 100644 docs/cli/thv_mcp_call.md create mode 100644 pkg/mcp/client/call.go 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