Files
coder/cli/exp_mcp.go
Asher 4bd5609e13 feat: add status watcher to MCP server (#18320)
This is meant to complement the existing task reporter since the LLM
does not call it reliably.

It also includes refactoring to use the common agent flags/env vars.
2025-06-13 12:53:43 -08:00

957 lines
30 KiB
Go

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"
agentapi "github.com/coder/agentapi-sdk-go"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/cliutil"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/toolsdk"
"github.com/coder/serpent"
)
const (
envAppStatusSlug = "CODER_MCP_APP_STATUS_SLUG"
envAIAgentAPIURL = "CODER_MCP_AI_AGENTAPI_URL"
)
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 (r *RootCmd) mcpConfigureClaudeCode() *serpent.Command {
var (
claudeAPIKey string
claudeConfigPath string
claudeMDPath string
systemPrompt string
coderPrompt string
appStatusSlug string
testBinaryName string
aiAgentAPIURL url.URL
deprecatedCoderMCPClaudeAPIKey string
)
cmd := &serpent.Command{
Use: "claude-code <project-directory>",
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{}
agentClient, err := r.createAgentClient()
if err != nil {
cliui.Warnf(inv.Stderr, "failed to create agent client: %s", err)
} else {
configureClaudeEnv[envAgentURL] = agentClient.SDK.URL.String()
configureClaudeEnv[envAgentToken] = agentClient.SDK.SessionToken()
}
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[envAppStatusSlug] = appStatusSlug
}
if aiAgentAPIURL.String() != "" {
configureClaudeEnv[envAIAgentAPIURL] = aiAgentAPIURL.String()
}
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 agentClient != nil && appStatusSlug != "" {
// Only include the report task prompt if both the agent client and app
// status slug are defined. Otherwise, reporting a task will fail and
// confuse the agent (and by extension, the user).
reportTaskPrompt = defaultReportTaskPrompt
}
// The Coder Prompt just allows users to extend our
if coderPrompt != "" {
reportTaskPrompt += "\n\n" + coderPrompt
}
// We also write the system prompt to the CLAUDE.md file.
if err := injectClaudeMD(fs, reportTaskPrompt, 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: envAppStatusSlug,
Flag: "claude-app-status-slug",
Value: serpent.StringOf(&appStatusSlug),
},
{
Flag: "ai-agentapi-url",
Description: "The URL of the AI AgentAPI, used to listen for status updates.",
Env: envAIAgentAPIURL,
Value: serpent.URLOf(&aiAgentAPIURL),
},
{
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
}
type taskReport struct {
link string
messageID int64
selfReported bool
state codersdk.WorkspaceAppStatusState
summary string
}
type mcpServer struct {
agentClient *agentsdk.Client
appStatusSlug string
client *codersdk.Client
aiAgentAPIClient *agentapi.Client
queue *cliutil.Queue[taskReport]
}
func (r *RootCmd) mcpServer() *serpent.Command {
var (
client = new(codersdk.Client)
instructions string
allowedTools []string
appStatusSlug string
aiAgentAPIURL url.URL
)
return &serpent.Command{
Use: "server",
Handler: func(inv *serpent.Invocation) error {
// lastUserMessageID is the ID of the last *user* message that we saw. A
// user message only happens when interacting via the AI AgentAPI (as
// opposed to interacting with the terminal directly).
var lastUserMessageID int64
var lastReport taskReport
// Create a queue that skips duplicates and preserves summaries.
queue := cliutil.NewQueue[taskReport](512).WithPredicate(func(report taskReport) (taskReport, bool) {
// Use "working" status if this is a new user message. If this is not a
// new user message, and the status is "working" and not self-reported
// (meaning it came from the screen watcher), then it means one of two
// things:
// 1. The AI agent is still working, so there is nothing to update.
// 2. The AI agent stopped working, then the user has interacted with
// the terminal directly. For now, we are ignoring these updates.
// This risks missing cases where the user manually submits a new
// prompt and the AI agent becomes active and does not update itself,
// but it avoids spamming useless status updates as the user is
// typing, so the tradeoff is worth it. In the future, if we can
// reliably distinguish between user and AI agent activity, we can
// change this.
if report.messageID > lastUserMessageID {
report.state = codersdk.WorkspaceAppStatusStateWorking
} else if report.state == codersdk.WorkspaceAppStatusStateWorking && !report.selfReported {
return report, false
}
// Preserve previous message and URI if there was no message.
if report.summary == "" {
report.summary = lastReport.summary
if report.link == "" {
report.link = lastReport.link
}
}
// Avoid queueing duplicate updates.
if report.state == lastReport.state &&
report.link == lastReport.link &&
report.summary == lastReport.summary {
return report, false
}
lastReport = report
return report, true
})
srv := &mcpServer{
appStatusSlug: appStatusSlug,
queue: queue,
}
// Display client 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")
}
// Validate the client.
if client != nil && client.URL != nil && client.SessionToken() != "" {
me, err := client.User(inv.Context(), codersdk.Me)
if err == nil {
username := me.Username
cliui.Infof(inv.Stderr, "Authentication : Successful")
cliui.Infof(inv.Stderr, "User : %s", username)
srv.client = client
} else {
cliui.Infof(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")
}
// Try to create an agent client for status reporting. Not validated.
agentClient, err := r.createAgentClient()
if err == nil {
cliui.Infof(inv.Stderr, "Agent URL : %s", agentClient.SDK.URL.String())
srv.agentClient = agentClient
}
if err != nil || appStatusSlug == "" {
cliui.Infof(inv.Stderr, "Task reporter : Disabled")
if err != nil {
cliui.Warnf(inv.Stderr, "%s", err)
}
if appStatusSlug == "" {
cliui.Warnf(inv.Stderr, "%s must be set", envAppStatusSlug)
}
} else {
cliui.Infof(inv.Stderr, "Task reporter : Enabled")
}
// Try to create a client for the AI AgentAPI, which is used to get the
// screen status to make the status reporting more robust. No auth
// needed, so no validation.
if aiAgentAPIURL.String() == "" {
cliui.Infof(inv.Stderr, "AI AgentAPI URL : Not configured")
} else {
cliui.Infof(inv.Stderr, "AI AgentAPI URL : %s", aiAgentAPIURL.String())
aiAgentAPIClient, err := agentapi.NewClient(aiAgentAPIURL.String())
if err != nil {
cliui.Infof(inv.Stderr, "Screen events : Disabled")
cliui.Warnf(inv.Stderr, "%s must be set", envAIAgentAPIURL)
} else {
cliui.Infof(inv.Stderr, "Screen events : Enabled")
srv.aiAgentAPIClient = aiAgentAPIClient
}
}
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
defer srv.queue.Close()
cliui.Infof(inv.Stderr, "Failed to watch screen events")
// Start the reporter, watcher, and server. These are all tied to the
// lifetime of the MCP server, which is itself tied to the lifetime of the
// AI agent.
if srv.agentClient != nil && appStatusSlug != "" {
srv.startReporter(ctx, inv)
if srv.aiAgentAPIClient != nil {
srv.startWatcher(ctx, inv)
}
}
return srv.startServer(ctx, inv, instructions, allowedTools)
},
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: envAppStatusSlug,
Value: serpent.StringOf(&appStatusSlug),
Default: "",
},
{
Flag: "ai-agentapi-url",
Description: "The URL of the AI AgentAPI, used to listen for status updates.",
Env: envAIAgentAPIURL,
Value: serpent.URLOf(&aiAgentAPIURL),
},
},
}
}
func (s *mcpServer) startReporter(ctx context.Context, inv *serpent.Invocation) {
go func() {
for {
// TODO: Even with the queue, there is still the potential that a message
// from the screen watcher and a message from the AI agent could arrive
// out of order if the timing is just right. We might want to wait a bit,
// then check if the status has changed before committing.
item, ok := s.queue.Pop()
if !ok {
return
}
err := s.agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
AppSlug: s.appStatusSlug,
Message: item.summary,
URI: item.link,
State: item.state,
})
if err != nil && !errors.Is(err, context.Canceled) {
cliui.Warnf(inv.Stderr, "Failed to report task status: %s", err)
}
}
}()
}
func (s *mcpServer) startWatcher(ctx context.Context, inv *serpent.Invocation) {
eventsCh, errCh, err := s.aiAgentAPIClient.SubscribeEvents(ctx)
if err != nil {
cliui.Warnf(inv.Stderr, "Failed to watch screen events: %s", err)
return
}
go func() {
for {
select {
case <-ctx.Done():
return
case event := <-eventsCh:
switch ev := event.(type) {
case agentapi.EventStatusChange:
// If the screen is stable, assume complete.
state := codersdk.WorkspaceAppStatusStateWorking
if ev.Status == agentapi.StatusStable {
state = codersdk.WorkspaceAppStatusStateComplete
}
err := s.queue.Push(taskReport{
state: state,
})
if err != nil {
cliui.Warnf(inv.Stderr, "Failed to queue update: %s", err)
return
}
case agentapi.EventMessageUpdate:
if ev.Role == agentapi.RoleUser {
err := s.queue.Push(taskReport{
messageID: ev.Id,
})
if err != nil {
cliui.Warnf(inv.Stderr, "Failed to queue update: %s", err)
return
}
}
}
case err := <-errCh:
if !errors.Is(err, context.Canceled) {
cliui.Warnf(inv.Stderr, "Received error from screen event watcher: %s", err)
}
return
}
}
}()
}
func (s *mcpServer) startServer(ctx context.Context, inv *serpent.Invocation, instructions string, allowedTools []string) error {
cliui.Infof(inv.Stderr, "Starting MCP server")
cliui.Infof(inv.Stderr, "Instructions : %q", instructions)
if len(allowedTools) > 0 {
cliui.Infof(inv.Stderr, "Allowed Tools : %v", allowedTools)
}
// 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),
)
// If both clients are unauthorized, there are no tools we can enable.
if s.client == nil && s.agentClient == nil {
return xerrors.New(notLoggedInMessage)
}
// Add tool dependencies.
toolOpts := []func(*toolsdk.Deps){
toolsdk.WithTaskReporter(func(args toolsdk.ReportTaskArgs) error {
return s.queue.Push(taskReport{
link: args.Link,
selfReported: true,
state: codersdk.WorkspaceAppStatusState(args.State),
summary: args.Summary,
})
}),
}
toolDeps, err := toolsdk.NewDeps(s.client, toolOpts...)
if err != nil {
return xerrors.Errorf("failed to initialize tool dependencies: %w", err)
}
// Register tools based on the allowlist. Zero length means allow everything.
for _, tool := range toolsdk.All {
// Skip if not allowed.
if len(allowedTools) > 0 && !slices.ContainsFunc(allowedTools, func(t string) bool {
return t == tool.Tool.Name
}) {
continue
}
// Skip user-dependent tools if no authenticated user client.
if !tool.UserClientOptional && s.client == nil {
cliui.Warnf(inv.Stderr, "Tool %q requires authentication and will not be available", tool.Tool.Name)
continue
}
// Skip the coder_report_task tool if there is no agent client or slug.
if tool.Tool.Name == "coder_report_task" && (s.agentClient == nil || s.appStatusSlug == "") {
cliui.Warnf(inv.Stderr, "Tool %q requires the task reporter and will not be available", tool.Tool.Name)
continue
}
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
}()
cliui.Infof(inv.Stderr, "Press Ctrl+C to stop the server")
if err := <-done; err != nil && !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 (
defaultReportTaskPrompt = `Respect the requirements of the "coder_report_task" tool. It is pertinent to provide a fantastic user-experience.`
// Define the guard strings
coderPromptStartGuard = "<coder-prompt>"
coderPromptEndGuard = "</coder-prompt>"
systemPromptStartGuard = "<system-prompt>"
systemPromptEndGuard = "</system-prompt>"
)
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
}
// 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
},
}
}