Add cmd/mcp-tool command for testing MCP servers
Creates sketch/cmd/mcp-tool with discover and call subcommands to manually
test MCP servers using the same mcp-go library as Sketch.
Example calls tested with Context7 MCP servers:
mcp-tool discover -mcp '{"name": "context7", "type": "http", "url": "https://mcp.context7.com/mcp"}'
mcp-tool discover -mcp '{"name": "context7-stdio", "type": "stdio", "command": "npx", "args": ["-y", "@upstash/context7-mcp"]}'
mcp-tool call -mcp '{"name": "context7-sse", "type": "sse", "url": "https://mcp.context7.com/sse"}' resolve-library-id '{"libraryName": "react"}'
mcp-tool call -mcp '{"name": "context7", "type": "http", "url": "https://mcp.context7.com/mcp"}' get-library-docs '{"context7CompatibleLibraryID": "/reactjs/react.dev", "tokens": 1000, "topic": "hooks"}'
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s3c5c4cca2cf302d5k
diff --git a/cmd/mcp-tool/README.md b/cmd/mcp-tool/README.md
new file mode 100644
index 0000000..6a504de
--- /dev/null
+++ b/cmd/mcp-tool/README.md
@@ -0,0 +1,79 @@
+# mcp-tool
+
+A command-line tool for testing MCP (Model Context Protocol) servers. Uses the
+same `mcp-go` library as Sketch to provide manual testing capabilities for MCP
+server implementations.
+
+## Usage
+
+### Discover Tools
+
+List all tools available from an MCP server:
+
+```bash
+mcp-tool discover -mcp '{"name": "server-name", "type": "stdio", "command": "./server"}'
+```
+
+### Call Tools
+
+Invoke a specific tool on an MCP server:
+
+```bash
+mcp-tool call -mcp '{"name": "server-name", "type": "stdio", "command": "./server"}' tool_name '{"arg1": "value1"}'
+```
+
+## MCP Server Configuration
+
+The `-mcp` flag accepts a JSON configuration with the following fields:
+
+- `name` (required): Server name for identification
+- `type`: Transport type - "stdio" (default), "http", or "sse"
+- `command`: Command to run (for stdio transport)
+- `args`: Array of command arguments (for stdio transport)
+- `url`: Server URL (for http/sse transport)
+- `env`: Environment variables as key-value pairs (for stdio transport)
+- `headers`: HTTP headers as key-value pairs (for http/sse transport)
+
+## Examples
+
+### Stdio Transport
+
+```bash
+# Test a Python MCP server
+mcp-tool discover -mcp '{"name": "python-server", "type": "stdio", "command": "python3", "args": ["server.py"]}'
+
+# Call a tool with arguments
+mcp-tool call -mcp '{"name": "python-server", "type": "stdio", "command": "python3", "args": ["server.py"]}' list_files '{"path": "/tmp"}'
+```
+
+### HTTP Transport
+
+```bash
+# Discover tools from HTTP MCP server
+mcp-tool discover -mcp '{"name": "http-server", "type": "http", "url": "http://localhost:8080/mcp"}'
+
+# Call with custom headers
+mcp-tool call -mcp '{"name": "http-server", "type": "http", "url": "http://localhost:8080/mcp", "headers": {"Authorization": "Bearer token"}}' get_data '{}'
+```
+
+### SSE Transport
+
+```bash
+# Use Server-Sent Events transport
+mcp-tool discover -mcp '{"name": "sse-server", "type": "sse", "url": "http://localhost:8080/mcp/sse"}'
+```
+
+## Options
+
+- `-v`: Verbose output for debugging
+- `-timeout duration`: Connection timeout (default: 30s)
+
+## Testing
+
+A test MCP server (`test-mcp-server.py`) is provided in the repository root for testing purposes. It implements basic tools like `echo`, `list_files`, and `get_env`:
+
+```bash
+# Start the test server and test it
+mcp-tool discover -mcp '{"name": "test", "type": "stdio", "command": "python3", "args": ["test-mcp-server.py"]}'
+mcp-tool call -mcp '{"name": "test", "type": "stdio", "command": "python3", "args": ["test-mcp-server.py"]}' echo '{"message": "Hello MCP!"}'
+```
diff --git a/cmd/mcp-tool/mcp-tool.go b/cmd/mcp-tool/mcp-tool.go
new file mode 100644
index 0000000..dfba7d5
--- /dev/null
+++ b/cmd/mcp-tool/mcp-tool.go
@@ -0,0 +1,284 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log/slog"
+ "os"
+ "time"
+
+ "github.com/mark3labs/mcp-go/client"
+ "github.com/mark3labs/mcp-go/client/transport"
+ "github.com/mark3labs/mcp-go/mcp"
+ sketchmcp "sketch.dev/mcp"
+)
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ // Set up basic logging
+ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
+ Level: slog.LevelWarn,
+ })))
+
+ if len(os.Args) < 2 {
+ return fmt.Errorf("usage: %s <discover|call> [options]", os.Args[0])
+ }
+
+ command := os.Args[1]
+ switch command {
+ case "discover":
+ return runDiscover(os.Args[2:])
+ case "call":
+ return runCall(os.Args[2:])
+ default:
+ return fmt.Errorf("unknown command %q. Available commands: discover, call", command)
+ }
+}
+
+func runDiscover(args []string) error {
+ fs := flag.NewFlagSet("discover", flag.ExitOnError)
+ mcpConfig := fs.String("mcp", "", "MCP server configuration as JSON")
+ timeout := fs.Duration("timeout", 30*time.Second, "Connection timeout")
+ verbose := fs.Bool("v", false, "Verbose output")
+
+ fs.Usage = func() {
+ fmt.Fprintf(os.Stderr, "Usage: %s discover -mcp '{...}' [options]\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, "\nOptions:\n")
+ fs.PrintDefaults()
+ fmt.Fprintf(os.Stderr, "\nExample:\n")
+ fmt.Fprintf(os.Stderr, " %s discover -mcp '{\"name\": \"test\", \"type\": \"stdio\", \"command\": \"./test-server\"}' -v\n", os.Args[0])
+ }
+
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ if *mcpConfig == "" {
+ fs.Usage()
+ return fmt.Errorf("-mcp flag is required")
+ }
+
+ if *verbose {
+ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
+ Level: slog.LevelDebug,
+ })))
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), *timeout)
+ defer cancel()
+
+ // Parse the MCP server configuration
+ var serverConfig sketchmcp.ServerConfig
+ if err := json.Unmarshal([]byte(*mcpConfig), &serverConfig); err != nil {
+ return fmt.Errorf("failed to parse MCP config: %w", err)
+ }
+
+ if serverConfig.Name == "" {
+ return fmt.Errorf("server name is required in MCP config")
+ }
+
+ // Connect to the MCP server
+ mcpClient, err := connectToMCPServer(ctx, serverConfig)
+ if err != nil {
+ return fmt.Errorf("failed to connect to MCP server: %w", err)
+ }
+ defer mcpClient.Close()
+
+ // List available tools
+ toolsReq := mcp.ListToolsRequest{}
+ toolsResp, err := mcpClient.ListTools(ctx, toolsReq)
+ if err != nil {
+ return fmt.Errorf("failed to list tools: %w", err)
+ }
+
+ // Output the tools
+ fmt.Printf("MCP Server: %s\n", serverConfig.Name)
+ fmt.Printf("Available tools (%d):\n\n", len(toolsResp.Tools))
+
+ for _, tool := range toolsResp.Tools {
+ fmt.Printf("• %s\n", tool.Name)
+ if tool.Description != "" {
+ fmt.Printf(" Description: %s\n", tool.Description)
+ }
+ if tool.InputSchema.Type != "" {
+ schemaBytes, err := json.MarshalIndent(tool.InputSchema, " ", " ")
+ if err == nil {
+ fmt.Printf(" Input Schema:\n %s\n", string(schemaBytes))
+ }
+ }
+ fmt.Println()
+ }
+
+ return nil
+}
+
+func runCall(args []string) error {
+ fs := flag.NewFlagSet("call", flag.ExitOnError)
+ mcpConfig := fs.String("mcp", "", "MCP server configuration as JSON")
+ timeout := fs.Duration("timeout", 30*time.Second, "Connection and call timeout")
+ verbose := fs.Bool("v", false, "Verbose output")
+
+ fs.Usage = func() {
+ fmt.Fprintf(os.Stderr, "Usage: %s call -mcp '{...}' <tool_name> [tool_args_json]\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, "\nOptions:\n")
+ fs.PrintDefaults()
+ fmt.Fprintf(os.Stderr, "\nExample:\n")
+ fmt.Fprintf(os.Stderr, " %s call -mcp '{\"name\": \"test\", \"type\": \"stdio\", \"command\": \"./test-server\"}' list_files '{\"path\": \"/tmp\"}'\n", os.Args[0])
+ }
+
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ if *mcpConfig == "" {
+ fs.Usage()
+ return fmt.Errorf("-mcp flag is required")
+ }
+
+ remainingArgs := fs.Args()
+ if len(remainingArgs) < 1 {
+ fs.Usage()
+ return fmt.Errorf("tool name is required")
+ }
+
+ toolName := remainingArgs[0]
+ var toolArgsJSON string
+ if len(remainingArgs) > 1 {
+ toolArgsJSON = remainingArgs[1]
+ } else {
+ toolArgsJSON = "{}"
+ }
+
+ if *verbose {
+ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
+ Level: slog.LevelDebug,
+ })))
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), *timeout)
+ defer cancel()
+
+ // Parse the MCP server configuration
+ var serverConfig sketchmcp.ServerConfig
+ if err := json.Unmarshal([]byte(*mcpConfig), &serverConfig); err != nil {
+ return fmt.Errorf("failed to parse MCP config: %w", err)
+ }
+
+ if serverConfig.Name == "" {
+ return fmt.Errorf("server name is required in MCP config")
+ }
+
+ // Parse tool arguments
+ var toolArgs map[string]any
+ if err := json.Unmarshal([]byte(toolArgsJSON), &toolArgs); err != nil {
+ return fmt.Errorf("failed to parse tool arguments JSON: %w", err)
+ }
+
+ // Connect to the MCP server
+ mcpClient, err := connectToMCPServer(ctx, serverConfig)
+ if err != nil {
+ return fmt.Errorf("failed to connect to MCP server: %w", err)
+ }
+ defer mcpClient.Close()
+
+ // Call the tool
+ req := mcp.CallToolRequest{
+ Params: mcp.CallToolParams{
+ Name: toolName,
+ Arguments: toolArgs,
+ },
+ }
+
+ if *verbose {
+ fmt.Fprintf(os.Stderr, "Calling tool %q with arguments: %s\n", toolName, toolArgsJSON)
+ }
+
+ resp, err := mcpClient.CallTool(ctx, req)
+ if err != nil {
+ return fmt.Errorf("tool call failed: %w", err)
+ }
+
+ // Output the result
+ result, err := json.MarshalIndent(resp.Content, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to format response: %w", err)
+ }
+
+ fmt.Printf("Tool call result:\n%s\n", string(result))
+ return nil
+}
+
+// connectToMCPServer creates and initializes an MCP client connection
+func connectToMCPServer(ctx context.Context, config sketchmcp.ServerConfig) (*client.Client, error) {
+ var mcpClient *client.Client
+ var err error
+
+ // Convert environment variables to []string format
+ var envVars []string
+ for k, v := range config.Env {
+ envVars = append(envVars, k+"="+v)
+ }
+
+ switch config.Type {
+ case "stdio", "":
+ if config.Command == "" {
+ return nil, fmt.Errorf("command is required for stdio transport")
+ }
+ mcpClient, err = client.NewStdioMCPClient(config.Command, envVars, config.Args...)
+ case "http":
+ if config.URL == "" {
+ return nil, fmt.Errorf("URL is required for HTTP transport")
+ }
+ // Use streamable HTTP client for HTTP transport
+ var httpOptions []transport.StreamableHTTPCOption
+ if len(config.Headers) > 0 {
+ httpOptions = append(httpOptions, transport.WithHTTPHeaders(config.Headers))
+ }
+ mcpClient, err = client.NewStreamableHttpClient(config.URL, httpOptions...)
+ case "sse":
+ if config.URL == "" {
+ return nil, fmt.Errorf("URL is required for SSE transport")
+ }
+ var sseOptions []transport.ClientOption
+ if len(config.Headers) > 0 {
+ sseOptions = append(sseOptions, transport.WithHeaders(config.Headers))
+ }
+ mcpClient, err = client.NewSSEMCPClient(config.URL, sseOptions...)
+ default:
+ return nil, fmt.Errorf("unsupported MCP transport type: %s", config.Type)
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to create MCP client: %w", err)
+ }
+
+ // Start the client
+ if err := mcpClient.Start(ctx); err != nil {
+ return nil, fmt.Errorf("failed to start MCP client: %w", err)
+ }
+
+ // Initialize the client
+ initReq := mcp.InitializeRequest{
+ Params: mcp.InitializeParams{
+ ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
+ Capabilities: mcp.ClientCapabilities{},
+ ClientInfo: mcp.Implementation{
+ Name: "mcp-tool",
+ Version: "1.0.0",
+ },
+ },
+ }
+ if _, err := mcpClient.Initialize(ctx, initReq); err != nil {
+ return nil, fmt.Errorf("failed to initialize MCP client: %w", err)
+ }
+
+ return mcpClient, nil
+}