package cli import ( "bytes" "context" "encoding/json" "errors" "net/url" "os" "path/filepath" "slices" "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/spf13/afero" "golang.org/x/xerrors" "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/toolsdk" "github.com/coder/serpent" ) func (r *RootCmd) mcpCommand() *serpent.Command { cmd := &serpent.Command{ Use: "mcp", Short: "Run the Coder MCP server and configure it to work with AI tools.", Long: "The Coder MCP server allows you to automatically create workspaces with parameters.", Handler: func(i *serpent.Invocation) error { return i.Command.HelpHandler(i) }, Children: []*serpent.Command{ r.mcpConfigure(), r.mcpServer(), }, } return cmd } func (r *RootCmd) mcpConfigure() *serpent.Command { cmd := &serpent.Command{ Use: "configure", Short: "Automatically configure the MCP server.", Handler: func(i *serpent.Invocation) error { return i.Command.HelpHandler(i) }, Children: []*serpent.Command{ r.mcpConfigureClaudeDesktop(), r.mcpConfigureClaudeCode(), r.mcpConfigureCursor(), }, } return cmd } func (*RootCmd) mcpConfigureClaudeDesktop() *serpent.Command { cmd := &serpent.Command{ Use: "claude-desktop", Short: "Configure the Claude Desktop server.", Handler: func(_ *serpent.Invocation) error { configPath, err := os.UserConfigDir() if err != nil { return err } configPath = filepath.Join(configPath, "Claude") err = os.MkdirAll(configPath, 0o755) if err != nil { return err } configPath = filepath.Join(configPath, "claude_desktop_config.json") _, err = os.Stat(configPath) if err != nil { if !os.IsNotExist(err) { return err } } contents := map[string]any{} data, err := os.ReadFile(configPath) if err != nil { if !os.IsNotExist(err) { return err } } else { err = json.Unmarshal(data, &contents) if err != nil { return err } } binPath, err := os.Executable() if err != nil { return err } contents["mcpServers"] = map[string]any{ "coder": map[string]any{"command": binPath, "args": []string{"exp", "mcp", "server"}}, } data, err = json.MarshalIndent(contents, "", " ") if err != nil { return err } err = os.WriteFile(configPath, data, 0o600) if err != nil { return err } return nil }, } return cmd } func (*RootCmd) mcpConfigureClaudeCode() *serpent.Command { var ( claudeAPIKey string claudeConfigPath string claudeMDPath string systemPrompt string coderPrompt string appStatusSlug string testBinaryName string deprecatedCoderMCPClaudeAPIKey string ) cmd := &serpent.Command{ Use: "claude-code ", Short: "Configure the Claude Code server. You will need to run this command for each project you want to use. Specify the project directory as the first argument.", Handler: func(inv *serpent.Invocation) error { if len(inv.Args) == 0 { return xerrors.Errorf("project directory is required") } projectDirectory := inv.Args[0] fs := afero.NewOsFs() binPath, err := os.Executable() if err != nil { return xerrors.Errorf("failed to get executable path: %w", err) } if testBinaryName != "" { binPath = testBinaryName } configureClaudeEnv := map[string]string{} agentToken, err := getAgentToken(fs) if err != nil { cliui.Warnf(inv.Stderr, "failed to get agent token: %s", err) } else { configureClaudeEnv["CODER_AGENT_TOKEN"] = agentToken } if claudeAPIKey == "" { if deprecatedCoderMCPClaudeAPIKey == "" { cliui.Warnf(inv.Stderr, "CLAUDE_API_KEY is not set.") } else { cliui.Warnf(inv.Stderr, "CODER_MCP_CLAUDE_API_KEY is deprecated, use CLAUDE_API_KEY instead") claudeAPIKey = deprecatedCoderMCPClaudeAPIKey } } if appStatusSlug != "" { configureClaudeEnv["CODER_MCP_APP_STATUS_SLUG"] = appStatusSlug } if deprecatedSystemPromptEnv, ok := os.LookupEnv("SYSTEM_PROMPT"); ok { cliui.Warnf(inv.Stderr, "SYSTEM_PROMPT is deprecated, use CODER_MCP_CLAUDE_SYSTEM_PROMPT instead") systemPrompt = deprecatedSystemPromptEnv } if err := configureClaude(fs, ClaudeConfig{ // TODO: will this always be stable? AllowedTools: []string{`mcp__coder__coder_report_task`}, APIKey: claudeAPIKey, ConfigPath: claudeConfigPath, ProjectDirectory: projectDirectory, MCPServers: map[string]ClaudeConfigMCP{ "coder": { Command: binPath, Args: []string{"exp", "mcp", "server"}, Env: configureClaudeEnv, }, }, }); err != nil { return xerrors.Errorf("failed to modify claude.json: %w", err) } cliui.Infof(inv.Stderr, "Wrote config to %s", claudeConfigPath) // Determine if we should include the reportTaskPrompt var reportTaskPrompt string if agentToken != "" && appStatusSlug != "" { // Only include the report task prompt if both agent token and app // status slug are defined. Otherwise, reporting a task will fail // and confuse the agent (and by extension, the user). reportTaskPrompt = defaultReportTaskPrompt } // If a user overrides the coder prompt, we don't want to append // the report task prompt, as it then becomes the responsibility // of the user. actualCoderPrompt := defaultCoderPrompt if coderPrompt != "" { actualCoderPrompt = coderPrompt } else if reportTaskPrompt != "" { actualCoderPrompt += "\n\n" + reportTaskPrompt } // We also write the system prompt to the CLAUDE.md file. if err := injectClaudeMD(fs, actualCoderPrompt, systemPrompt, claudeMDPath); err != nil { return xerrors.Errorf("failed to modify CLAUDE.md: %w", err) } cliui.Infof(inv.Stderr, "Wrote CLAUDE.md to %s", claudeMDPath) return nil }, Options: []serpent.Option{ { Name: "claude-config-path", Description: "The path to the Claude config file.", Env: "CODER_MCP_CLAUDE_CONFIG_PATH", Flag: "claude-config-path", Value: serpent.StringOf(&claudeConfigPath), Default: filepath.Join(os.Getenv("HOME"), ".claude.json"), }, { Name: "claude-md-path", Description: "The path to CLAUDE.md.", Env: "CODER_MCP_CLAUDE_MD_PATH", Flag: "claude-md-path", Value: serpent.StringOf(&claudeMDPath), Default: filepath.Join(os.Getenv("HOME"), ".claude", "CLAUDE.md"), }, { Name: "claude-api-key", Description: "The API key to use for the Claude Code server. This is also read from CLAUDE_API_KEY.", Env: "CLAUDE_API_KEY", Flag: "claude-api-key", Value: serpent.StringOf(&claudeAPIKey), }, { Name: "mcp-claude-api-key", Description: "Hidden alias for CLAUDE_API_KEY. This will be removed in a future version.", Env: "CODER_MCP_CLAUDE_API_KEY", Value: serpent.StringOf(&deprecatedCoderMCPClaudeAPIKey), Hidden: true, }, { Name: "system-prompt", Description: "The system prompt to use for the Claude Code server.", Env: "CODER_MCP_CLAUDE_SYSTEM_PROMPT", Flag: "claude-system-prompt", Value: serpent.StringOf(&systemPrompt), Default: "Send a task status update to notify the user that you are ready for input, and then wait for user input.", }, { Name: "coder-prompt", Description: "The coder prompt to use for the Claude Code server.", Env: "CODER_MCP_CLAUDE_CODER_PROMPT", Flag: "claude-coder-prompt", Value: serpent.StringOf(&coderPrompt), Default: "", // Empty default means we'll use defaultCoderPrompt from the variable }, { Name: "app-status-slug", Description: "The app status slug to use when running the Coder MCP server.", Env: "CODER_MCP_CLAUDE_APP_STATUS_SLUG", Flag: "claude-app-status-slug", Value: serpent.StringOf(&appStatusSlug), }, { Name: "test-binary-name", Description: "Only used for testing.", Env: "CODER_MCP_CLAUDE_TEST_BINARY_NAME", Flag: "claude-test-binary-name", Value: serpent.StringOf(&testBinaryName), Hidden: true, }, }, } return cmd } func (*RootCmd) mcpConfigureCursor() *serpent.Command { var project bool cmd := &serpent.Command{ Use: "cursor", Short: "Configure Cursor to use Coder MCP.", Options: serpent.OptionSet{ serpent.Option{ Flag: "project", Env: "CODER_MCP_CURSOR_PROJECT", Description: "Use to configure a local project to use the Cursor MCP.", Value: serpent.BoolOf(&project), }, }, Handler: func(_ *serpent.Invocation) error { dir, err := os.Getwd() if err != nil { return err } if !project { dir, err = os.UserHomeDir() if err != nil { return err } } cursorDir := filepath.Join(dir, ".cursor") err = os.MkdirAll(cursorDir, 0o755) if err != nil { return err } mcpConfig := filepath.Join(cursorDir, "mcp.json") _, err = os.Stat(mcpConfig) contents := map[string]any{} if err != nil { if !os.IsNotExist(err) { return err } } else { data, err := os.ReadFile(mcpConfig) if err != nil { return err } // The config can be empty, so we don't want to return an error if it is. if len(data) > 0 { err = json.Unmarshal(data, &contents) if err != nil { return err } } } mcpServers, ok := contents["mcpServers"].(map[string]any) if !ok { mcpServers = map[string]any{} } binPath, err := os.Executable() if err != nil { return err } mcpServers["coder"] = map[string]any{ "command": binPath, "args": []string{"exp", "mcp", "server"}, } contents["mcpServers"] = mcpServers data, err := json.MarshalIndent(contents, "", " ") if err != nil { return err } err = os.WriteFile(mcpConfig, data, 0o600) if err != nil { return err } return nil }, } return cmd } func (r *RootCmd) mcpServer() *serpent.Command { var ( client = new(codersdk.Client) instructions string allowedTools []string appStatusSlug string ) return &serpent.Command{ Use: "server", Handler: func(inv *serpent.Invocation) error { return mcpServerHandler(inv, client, instructions, allowedTools, appStatusSlug) }, Short: "Start the Coder MCP server.", Middleware: serpent.Chain( r.TryInitClient(client), ), Options: []serpent.Option{ { Name: "instructions", Description: "The instructions to pass to the MCP server.", Flag: "instructions", Env: "CODER_MCP_INSTRUCTIONS", Value: serpent.StringOf(&instructions), }, { Name: "allowed-tools", Description: "Comma-separated list of allowed tools. If not specified, all tools are allowed.", Flag: "allowed-tools", Env: "CODER_MCP_ALLOWED_TOOLS", Value: serpent.StringArrayOf(&allowedTools), }, { Name: "app-status-slug", Description: "When reporting a task, the coder_app slug under which to report the task.", Flag: "app-status-slug", Env: "CODER_MCP_APP_STATUS_SLUG", Value: serpent.StringOf(&appStatusSlug), Default: "", }, }, } } func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instructions string, allowedTools []string, appStatusSlug string) error { ctx, cancel := context.WithCancel(inv.Context()) defer cancel() fs := afero.NewOsFs() cliui.Infof(inv.Stderr, "Starting MCP server") // Check authentication status var username string // Check authentication status first if client != nil && client.URL != nil && client.SessionToken() != "" { // Try to validate the client me, err := client.User(ctx, codersdk.Me) if err == nil { username = me.Username cliui.Infof(inv.Stderr, "Authentication : Successful") cliui.Infof(inv.Stderr, "User : %s", username) } else { // Authentication failed but we have a client URL cliui.Warnf(inv.Stderr, "Authentication : Failed (%s)", err) cliui.Warnf(inv.Stderr, "Some tools that require authentication will not be available.") } } else { cliui.Infof(inv.Stderr, "Authentication : None") } // Display URL separately from authentication status if client != nil && client.URL != nil { cliui.Infof(inv.Stderr, "URL : %s", client.URL.String()) } else { cliui.Infof(inv.Stderr, "URL : Not configured") } cliui.Infof(inv.Stderr, "Instructions : %q", instructions) if len(allowedTools) > 0 { cliui.Infof(inv.Stderr, "Allowed Tools : %v", allowedTools) } cliui.Infof(inv.Stderr, "Press Ctrl+C to stop the server") // Capture the original stdin, stdout, and stderr. invStdin := inv.Stdin invStdout := inv.Stdout invStderr := inv.Stderr defer func() { inv.Stdin = invStdin inv.Stdout = invStdout inv.Stderr = invStderr }() mcpSrv := server.NewMCPServer( "Coder Agent", buildinfo.Version(), server.WithInstructions(instructions), ) // Get the workspace agent token from the environment. toolOpts := make([]func(*toolsdk.Deps), 0) var hasAgentClient bool var agentURL *url.URL if client != nil && client.URL != nil { agentURL = client.URL } else if agntURL, err := getAgentURL(); err == nil { agentURL = agntURL } // First check if we have a valid client URL, which is required for agent client if agentURL == nil { cliui.Infof(inv.Stderr, "Agent URL : Not configured") } else { cliui.Infof(inv.Stderr, "Agent URL : %s", agentURL.String()) agentToken, err := getAgentToken(fs) if err != nil || agentToken == "" { cliui.Warnf(inv.Stderr, "CODER_AGENT_TOKEN is not set, task reporting will not be available") } else { // Happy path: we have both URL and agent token agentClient := agentsdk.New(agentURL) agentClient.SetSessionToken(agentToken) toolOpts = append(toolOpts, toolsdk.WithAgentClient(agentClient)) hasAgentClient = true } } if (client == nil || client.URL == nil || client.SessionToken() == "") && !hasAgentClient { return xerrors.New(notLoggedInMessage) } if appStatusSlug != "" { toolOpts = append(toolOpts, toolsdk.WithAppStatusSlug(appStatusSlug)) } else { cliui.Warnf(inv.Stderr, "CODER_MCP_APP_STATUS_SLUG is not set, task reporting will not be available.") } toolDeps, err := toolsdk.NewDeps(client, toolOpts...) if err != nil { return xerrors.Errorf("failed to initialize tool dependencies: %w", err) } // Register tools based on the allowlist (if specified) for _, tool := range toolsdk.All { // Skip adding the coder_report_task tool if there is no agent client if !hasAgentClient && tool.Tool.Name == "coder_report_task" { cliui.Warnf(inv.Stderr, "Task reporting not available") continue } // Skip user-dependent tools if no authenticated user if !tool.UserClientOptional && username == "" { cliui.Warnf(inv.Stderr, "Tool %q requires authentication and will not be available", tool.Tool.Name) continue } if len(allowedTools) == 0 || slices.ContainsFunc(allowedTools, func(t string) bool { return t == tool.Tool.Name }) { mcpSrv.AddTools(mcpFromSDK(tool, toolDeps)) } } srv := server.NewStdioServer(mcpSrv) done := make(chan error) go func() { defer close(done) srvErr := srv.Listen(ctx, invStdin, invStdout) done <- srvErr }() if err := <-done; err != nil { if !errors.Is(err, context.Canceled) { cliui.Errorf(inv.Stderr, "Failed to start the MCP server: %s", err) return err } } return nil } type ClaudeConfig struct { ConfigPath string ProjectDirectory string APIKey string AllowedTools []string MCPServers map[string]ClaudeConfigMCP } type ClaudeConfigMCP struct { Command string `json:"command"` Args []string `json:"args"` Env map[string]string `json:"env"` } func configureClaude(fs afero.Fs, cfg ClaudeConfig) error { if cfg.ConfigPath == "" { cfg.ConfigPath = filepath.Join(os.Getenv("HOME"), ".claude.json") } var config map[string]any _, err := fs.Stat(cfg.ConfigPath) if err != nil { if !os.IsNotExist(err) { return xerrors.Errorf("failed to stat claude config: %w", err) } // Touch the file to create it if it doesn't exist. if err = afero.WriteFile(fs, cfg.ConfigPath, []byte(`{}`), 0o600); err != nil { return xerrors.Errorf("failed to touch claude config: %w", err) } } oldConfigBytes, err := afero.ReadFile(fs, cfg.ConfigPath) if err != nil { return xerrors.Errorf("failed to read claude config: %w", err) } err = json.Unmarshal(oldConfigBytes, &config) if err != nil { return xerrors.Errorf("failed to unmarshal claude config: %w", err) } if cfg.APIKey != "" { // Stops Claude from requiring the user to generate // a Claude-specific API key. config["primaryApiKey"] = cfg.APIKey } // Stops Claude from asking for onboarding. config["hasCompletedOnboarding"] = true // Stops Claude from asking for permissions. config["bypassPermissionsModeAccepted"] = true config["autoUpdaterStatus"] = "disabled" // Stops Claude from asking for cost threshold. config["hasAcknowledgedCostThreshold"] = true projects, ok := config["projects"].(map[string]any) if !ok { projects = make(map[string]any) } project, ok := projects[cfg.ProjectDirectory].(map[string]any) if !ok { project = make(map[string]any) } allowedTools, ok := project["allowedTools"].([]string) if !ok { allowedTools = []string{} } // Add cfg.AllowedTools to the list if they're not already present. for _, tool := range cfg.AllowedTools { for _, existingTool := range allowedTools { if tool == existingTool { continue } } allowedTools = append(allowedTools, tool) } project["allowedTools"] = allowedTools project["hasTrustDialogAccepted"] = true project["hasCompletedProjectOnboarding"] = true mcpServers, ok := project["mcpServers"].(map[string]any) if !ok { mcpServers = make(map[string]any) } for name, cfgmcp := range cfg.MCPServers { mcpServers[name] = cfgmcp } project["mcpServers"] = mcpServers // Prevents Claude from asking the user to complete the project onboarding. project["hasCompletedProjectOnboarding"] = true history, ok := project["history"].([]string) injectedHistoryLine := "make sure to read claude.md and report tasks properly" if !ok || len(history) == 0 { // History doesn't exist or is empty, create it with our injected line history = []string{injectedHistoryLine} } else if history[0] != injectedHistoryLine { // Check if our line is already the first item // Prepend our line to the existing history history = append([]string{injectedHistoryLine}, history...) } project["history"] = history projects[cfg.ProjectDirectory] = project config["projects"] = projects newConfigBytes, err := json.MarshalIndent(config, "", " ") if err != nil { return xerrors.Errorf("failed to marshal claude config: %w", err) } err = afero.WriteFile(fs, cfg.ConfigPath, newConfigBytes, 0o644) if err != nil { return xerrors.Errorf("failed to write claude config: %w", err) } return nil } var ( defaultCoderPrompt = `You are a helpful Coding assistant. Aim to autonomously investigate and solve issues the user gives you and test your work, whenever possible. Avoid shortcuts like mocking tests. When you get stuck, you can ask the user but opt for autonomy.` defaultReportTaskPrompt = `YOU MUST REPORT ALL TASKS TO CODER. When reporting tasks, you MUST follow these EXACT instructions: - IMMEDIATELY report status after receiving ANY user message. - Be granular. If you are investigating with multiple steps, report each step to coder. Task state MUST be one of the following: - Use "state": "working" when actively processing WITHOUT needing additional user input. - Use "state": "complete" only when finished with a task. - Use "state": "failure" when you need ANY user input, lack sufficient details, or encounter blockers. Task summaries MUST: - Include specifics about what you're doing. - Include clear and actionable steps for the user. - Be less than 160 characters in length.` // Define the guard strings coderPromptStartGuard = "" coderPromptEndGuard = "" systemPromptStartGuard = "" systemPromptEndGuard = "" ) func injectClaudeMD(fs afero.Fs, coderPrompt, systemPrompt, claudeMDPath string) error { _, err := fs.Stat(claudeMDPath) if err != nil { if !os.IsNotExist(err) { return xerrors.Errorf("failed to stat claude config: %w", err) } // Write a new file with the system prompt. if err = fs.MkdirAll(filepath.Dir(claudeMDPath), 0o700); err != nil { return xerrors.Errorf("failed to create claude config directory: %w", err) } return afero.WriteFile(fs, claudeMDPath, []byte(promptsBlock(coderPrompt, systemPrompt, "")), 0o600) } bs, err := afero.ReadFile(fs, claudeMDPath) if err != nil { return xerrors.Errorf("failed to read claude config: %w", err) } // Extract the content without the guarded sections cleanContent := string(bs) // Remove existing coder prompt section if it exists coderStartIdx := indexOf(cleanContent, coderPromptStartGuard) coderEndIdx := indexOf(cleanContent, coderPromptEndGuard) if coderStartIdx != -1 && coderEndIdx != -1 && coderStartIdx < coderEndIdx { beforeCoderPrompt := cleanContent[:coderStartIdx] afterCoderPrompt := cleanContent[coderEndIdx+len(coderPromptEndGuard):] cleanContent = beforeCoderPrompt + afterCoderPrompt } // Remove existing system prompt section if it exists systemStartIdx := indexOf(cleanContent, systemPromptStartGuard) systemEndIdx := indexOf(cleanContent, systemPromptEndGuard) if systemStartIdx != -1 && systemEndIdx != -1 && systemStartIdx < systemEndIdx { beforeSystemPrompt := cleanContent[:systemStartIdx] afterSystemPrompt := cleanContent[systemEndIdx+len(systemPromptEndGuard):] cleanContent = beforeSystemPrompt + afterSystemPrompt } // Trim any leading whitespace from the clean content cleanContent = strings.TrimSpace(cleanContent) // Create the new content with coder and system prompt prepended newContent := promptsBlock(coderPrompt, systemPrompt, cleanContent) // Write the updated content back to the file err = afero.WriteFile(fs, claudeMDPath, []byte(newContent), 0o600) if err != nil { return xerrors.Errorf("failed to write claude config: %w", err) } return nil } func promptsBlock(coderPrompt, systemPrompt, existingContent string) string { var newContent strings.Builder _, _ = newContent.WriteString(coderPromptStartGuard) _, _ = newContent.WriteRune('\n') _, _ = newContent.WriteString(coderPrompt) _, _ = newContent.WriteRune('\n') _, _ = newContent.WriteString(coderPromptEndGuard) _, _ = newContent.WriteRune('\n') _, _ = newContent.WriteString(systemPromptStartGuard) _, _ = newContent.WriteRune('\n') _, _ = newContent.WriteString(systemPrompt) _, _ = newContent.WriteRune('\n') _, _ = newContent.WriteString(systemPromptEndGuard) _, _ = newContent.WriteRune('\n') if existingContent != "" { _, _ = newContent.WriteString(existingContent) } return newContent.String() } // indexOf returns the index of the first instance of substr in s, // or -1 if substr is not present in s. func indexOf(s, substr string) int { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return i } } return -1 } func getAgentToken(fs afero.Fs) (string, error) { token, ok := os.LookupEnv("CODER_AGENT_TOKEN") if ok && token != "" { return token, nil } tokenFile, ok := os.LookupEnv("CODER_AGENT_TOKEN_FILE") if !ok { return "", xerrors.Errorf("CODER_AGENT_TOKEN or CODER_AGENT_TOKEN_FILE must be set for token auth") } bs, err := afero.ReadFile(fs, tokenFile) if err != nil { return "", xerrors.Errorf("failed to read agent token file: %w", err) } return string(bs), nil } func getAgentURL() (*url.URL, error) { urlString, ok := os.LookupEnv("CODER_AGENT_URL") if !ok || urlString == "" { return nil, xerrors.New("CODEDR_AGENT_URL is empty") } return url.Parse(urlString) } // mcpFromSDK adapts a toolsdk.Tool to go-mcp's server.ServerTool. // It assumes that the tool responds with a valid JSON object. func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool { // NOTE: some clients will silently refuse to use tools if there is an issue // with the tool's schema or configuration. if sdkTool.Schema.Properties == nil { panic("developer error: schema properties cannot be nil") } return server.ServerTool{ Tool: mcp.Tool{ Name: sdkTool.Tool.Name, Description: sdkTool.Description, InputSchema: mcp.ToolInputSchema{ Type: "object", // Default of mcp.NewTool() Properties: sdkTool.Schema.Properties, Required: sdkTool.Schema.Required, }, }, Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(request.Params.Arguments); err != nil { return nil, xerrors.Errorf("failed to encode request arguments: %w", err) } result, err := sdkTool.Handler(ctx, tb, buf.Bytes()) if err != nil { return nil, err } return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.NewTextContent(string(result)), }, }, nil }, } }