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
+}