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