mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Exposes the experimental Coder Agents chats API through the MCP tool registry, so MCP clients (the hosted `/api/experimental/mcp/http` server and `coder exp mcp server`) can start and drive server-side coding agents. New tools in `codersdk/toolsdk`, all thin wrappers over existing `codersdk.ExperimentalClient` methods: | Tool | Wraps | |---|---| | `coder_create_chat` | `CreateChat` (prompt, optional org, model config, labels) | | `coder_get_chat` | `GetChat` (status, last error, last turn summary, workspace, files) | | `coder_get_chat_messages` | `GetChatMessages` (user-facing parts, chronological, cursor pagination, queued prompts) | | `coder_send_chat_message` | `CreateChatMessage` (queue or interrupt busy behavior) | | `coder_interrupt_chat` | `InterruptChat` | | `coder_archive_chat` | `UpdateChat` with `archived: true` | | `coder_list_chat_model_configs` | `ListChatModelConfigs` (enabled configs with default flag) | Both MCP servers register tools from `toolsdk.All`, so no additional wiring is needed. Responses are trimmed to what an MCP caller needs (IDs as strings, user-facing transcripts) rather than full SDK payloads. No new endpoints and no database changes. Also adds MCP [prompts](https://modelcontextprotocol.io/specification/2026-07-28/server/prompts) for the chat workflows, defined once in `codersdk/toolsdk` and registered by both servers: | Prompt | Purpose | |---|---| | `coder_agents_delegate` | delegate a task to a Coder Agents chat and monitor it to completion | | `coder_agents_check` | check the status and recent activity of an existing chat | Each prompt declares the tools its workflow needs; the stdio server skips prompts whose tools are excluded by `--allowed-tools`. Tests run the tools against a chat-enabled coderdtest instance (fake OpenAI-compatible provider plus in-process AI bridge), covering the full lifecycle, an interrupt against a blocked turn, pagination cursors, permission-dependent model config filtering, and argument validation. Prompt coverage spans SDK rendering, the hosted `prompts/list`/`prompts/get` round trip, and the stdio server including allowlist gating. > Mux created this PR on Mike's behalf.
1011 lines
31 KiB
Go
1011 lines
31 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"github.com/spf13/afero"
|
|
"golang.org/x/xerrors"
|
|
|
|
agentapi "github.com/coder/agentapi-sdk-go"
|
|
"github.com/coder/coder/v2/agent/agentsocket"
|
|
"github.com/coder/coder/v2/buildinfo"
|
|
"github.com/coder/coder/v2/cli/cliui"
|
|
"github.com/coder/coder/v2/cli/cliutil"
|
|
coderdmcp "github.com/coder/coder/v2/coderd/mcp"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/agentsdk"
|
|
"github.com/coder/coder/v2/codersdk/toolsdk"
|
|
"github.com/coder/retry"
|
|
"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(),
|
|
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 mcpConfigureClaudeCode() *serpent.Command {
|
|
var (
|
|
claudeAPIKey string
|
|
claudeConfigPath string
|
|
claudeMDPath string
|
|
systemPrompt string
|
|
coderPrompt string
|
|
appStatusSlug string
|
|
testBinaryName string
|
|
aiAgentAPIURL url.URL
|
|
claudeUseBedrock string
|
|
|
|
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{}
|
|
|
|
if deprecatedCoderMCPClaudeAPIKey != "" {
|
|
cliui.Warnf(inv.Stderr, "CODER_MCP_CLAUDE_API_KEY is deprecated, use CLAUDE_API_KEY instead")
|
|
claudeAPIKey = deprecatedCoderMCPClaudeAPIKey
|
|
}
|
|
if claudeAPIKey == "" && claudeUseBedrock != "1" {
|
|
cliui.Warnf(inv.Stderr, "CLAUDE_API_KEY is not set.")
|
|
}
|
|
|
|
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)
|
|
|
|
// Include the report task prompt when an app status slug is
|
|
// configured. The agent socket is available at runtime, so we
|
|
// only check the slug here.
|
|
var reportTaskPrompt string
|
|
if appStatusSlug != "" {
|
|
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,
|
|
},
|
|
{
|
|
Name: "claude-code-use-bedrock",
|
|
Description: "Use Amazon Bedrock.",
|
|
Env: "CLAUDE_CODE_USE_BEDROCK",
|
|
Flag: "claude-code-use-bedrock",
|
|
Value: serpent.StringOf(&claudeUseBedrock),
|
|
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 is optional.
|
|
link string
|
|
// messageID must be set if this update is from a *user* message. A user
|
|
// message only happens when interacting via the AI AgentAPI (as opposed to
|
|
// interacting with the terminal directly).
|
|
messageID *int64
|
|
// selfReported must be set if the update is directly from the AI agent
|
|
// (as opposed to the screen watcher).
|
|
selfReported bool
|
|
// state must always be set.
|
|
state codersdk.WorkspaceAppStatusState
|
|
// summary is optional.
|
|
summary string
|
|
}
|
|
|
|
type mcpServer struct {
|
|
socketClient *agentsocket.Client
|
|
appStatusSlug string
|
|
client *codersdk.Client
|
|
aiAgentAPIClient *agentapi.Client
|
|
queue *cliutil.Queue[taskReport]
|
|
// wg tracks the reporter and watcher goroutines, which write to
|
|
// inv.Stderr and must not outlive the handler.
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
func (r *RootCmd) mcpServer() *serpent.Command {
|
|
var (
|
|
instructions string
|
|
allowedTools []string
|
|
appStatusSlug string
|
|
aiAgentAPIURL url.URL
|
|
socketPath string
|
|
)
|
|
cmd := &serpent.Command{
|
|
Use: "server",
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
client, err := r.TryInitClient(inv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var lastReport taskReport
|
|
// Create a queue that skips duplicates and preserves summaries.
|
|
queue := cliutil.NewQueue[taskReport](512).WithPredicate(func(report taskReport) (taskReport, bool) {
|
|
// Avoid queuing empty statuses (this would probably indicate a
|
|
// developer error)
|
|
if report.state == "" {
|
|
return report, false
|
|
}
|
|
// If this is a user message, discard if it is not new.
|
|
if report.messageID != nil && lastReport.messageID != nil &&
|
|
*lastReport.messageID >= *report.messageID {
|
|
return report, false
|
|
}
|
|
// If this is not a 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 not working; the user is interacting with the
|
|
// terminal directly.
|
|
// 2. The AI agent is working.
|
|
//
|
|
// At the moment, we have no way to tell the difference between these
|
|
// two states. In the future, if we can reliably distinguish between
|
|
// user and AI agent activity, we can change this.
|
|
//
|
|
// If this is our first update, we assume it is the AI agent working and
|
|
// accept the update.
|
|
//
|
|
// Otherwise we discard the update. 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.
|
|
if report.messageID == nil &&
|
|
report.state == codersdk.WorkspaceAppStatusStateWorking &&
|
|
!report.selfReported && lastReport.state != "" {
|
|
return report, false
|
|
}
|
|
// Keep track of the last message ID so we can tell when a message is
|
|
// new or if it has been re-emitted.
|
|
if report.messageID == nil {
|
|
report.messageID = lastReport.messageID
|
|
}
|
|
// 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 connect to the agent socket for status reporting.
|
|
if appStatusSlug == "" {
|
|
cliui.Infof(inv.Stderr, "Task reporter : Disabled")
|
|
cliui.Warnf(inv.Stderr, "%s must be set", envAppStatusSlug)
|
|
} else {
|
|
socketClient, err := agentsocket.NewClient(
|
|
inv.Context(),
|
|
agentsocket.WithPath(socketPath),
|
|
)
|
|
if err != nil {
|
|
cliui.Infof(inv.Stderr, "Task reporter : Disabled")
|
|
cliui.Warnf(inv.Stderr, "Failed to connect to agent socket: %s", err)
|
|
} else if err := socketClient.Ping(inv.Context()); err != nil {
|
|
cliui.Infof(inv.Stderr, "Task reporter : Disabled")
|
|
cliui.Warnf(inv.Stderr, "Agent socket ping failed: %s", err)
|
|
_ = socketClient.Close()
|
|
} else {
|
|
cliui.Infof(inv.Stderr, "Task reporter : Enabled")
|
|
srv.socketClient = socketClient
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
|
|
// 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.socketClient != nil && appStatusSlug != "" {
|
|
srv.startReporter(ctx, inv)
|
|
if srv.aiAgentAPIClient != nil {
|
|
srv.startWatcher(ctx, inv)
|
|
}
|
|
}
|
|
serveErr := srv.startServer(ctx, inv, instructions, allowedTools)
|
|
|
|
cancel()
|
|
srv.queue.Close()
|
|
if srv.socketClient != nil {
|
|
_ = srv.socketClient.Close()
|
|
}
|
|
srv.wg.Wait()
|
|
return serveErr
|
|
},
|
|
Short: "Start the Coder MCP server.",
|
|
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),
|
|
},
|
|
{
|
|
Flag: "socket-path",
|
|
Description: "Specify the path for the agent socket.",
|
|
Env: "CODER_AGENT_SOCKET_PATH",
|
|
Value: serpent.StringOf(&socketPath),
|
|
},
|
|
},
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
func (s *mcpServer) startReporter(ctx context.Context, inv *serpent.Invocation) {
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
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
|
|
}
|
|
|
|
req, err := agentsdk.ProtoFromPatchAppStatus(agentsdk.PatchAppStatus{
|
|
AppSlug: s.appStatusSlug,
|
|
Message: item.summary,
|
|
URI: item.link,
|
|
State: item.state,
|
|
})
|
|
if err != nil {
|
|
cliui.Warnf(inv.Stderr, "Failed to convert task status: %s", err)
|
|
continue
|
|
}
|
|
_, err = s.socketClient.UpdateAppStatus(ctx, req)
|
|
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) {
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
for retrier := retry.New(time.Second, 30*time.Second); retrier.Wait(ctx); {
|
|
eventsCh, errCh, err := s.aiAgentAPIClient.SubscribeEvents(ctx)
|
|
if err == nil {
|
|
retrier.Reset()
|
|
loop:
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case event := <-eventsCh:
|
|
switch ev := event.(type) {
|
|
case agentapi.EventStatusChange:
|
|
state := codersdk.WorkspaceAppStatusStateWorking
|
|
if ev.Status == agentapi.StatusStable {
|
|
state = codersdk.WorkspaceAppStatusStateIdle
|
|
}
|
|
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,
|
|
state: codersdk.WorkspaceAppStatusStateWorking,
|
|
})
|
|
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)
|
|
}
|
|
break loop
|
|
}
|
|
}
|
|
} else {
|
|
cliui.Warnf(inv.Stderr, "Failed to watch screen events: %s", err)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
mcpSrv := mcp.NewServer(&mcp.Implementation{
|
|
Name: "Coder Agent",
|
|
Version: buildinfo.Version(),
|
|
}, &mcp.ServerOptions{
|
|
Instructions: instructions,
|
|
})
|
|
|
|
// If neither the user client nor the agent socket is available, there
|
|
// are no tools we can enable.
|
|
if s.client == nil && s.socketClient == nil {
|
|
return xerrors.New(notLoggedInMessage)
|
|
}
|
|
|
|
// Add tool dependencies.
|
|
toolOpts := []func(*toolsdk.Deps){
|
|
toolsdk.WithTaskReporter(func(args toolsdk.ReportTaskArgs) error {
|
|
state := codersdk.WorkspaceAppStatusState(args.State)
|
|
// The agent does not reliably report idle, so when AgentAPI is
|
|
// enabled we override idle to working and let the screen watcher
|
|
// detect the real idle via StatusStable. Final states (failure,
|
|
// complete) are trusted from the agent since the screen watcher
|
|
// cannot produce them.
|
|
if s.aiAgentAPIClient != nil && state == codersdk.WorkspaceAppStatusStateIdle {
|
|
state = codersdk.WorkspaceAppStatusStateWorking
|
|
}
|
|
return s.queue.Push(taskReport{
|
|
link: args.Link,
|
|
selfReported: true,
|
|
state: 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.
|
|
registeredTools := make(map[string]bool, len(toolsdk.All))
|
|
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 socket client or slug.
|
|
if tool.Tool.Name == "coder_report_task" && (s.socketClient == nil || s.appStatusSlug == "") {
|
|
cliui.Warnf(inv.Stderr, "Tool %q requires the task reporter and will not be available", tool.Tool.Name)
|
|
continue
|
|
}
|
|
|
|
coderdmcp.RegisterSDKTool(mcpSrv, tool, toolDeps)
|
|
registeredTools[tool.Tool.Name] = true
|
|
}
|
|
|
|
// Skip prompts whose referenced tools are unavailable so clients are
|
|
// not offered workflows they cannot run.
|
|
for _, prompt := range toolsdk.AllPrompts {
|
|
if slices.ContainsFunc(prompt.RequiredTools, func(name string) bool {
|
|
return !registeredTools[name]
|
|
}) {
|
|
continue
|
|
}
|
|
coderdmcp.RegisterSDKPrompt(mcpSrv, prompt)
|
|
}
|
|
|
|
done := make(chan error)
|
|
go func() {
|
|
defer close(done)
|
|
srvErr := mcpSrv.Run(ctx, &mcp.IOTransport{
|
|
Reader: io.NopCloser(inv.Stdin),
|
|
Writer: nopWriteCloser{inv.Stdout},
|
|
})
|
|
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
|
|
}
|
|
|
|
// nopWriteCloser adapts the invocation's stdout to the WriteCloser
|
|
// the SDK transport requires without closing the underlying stream.
|
|
type nopWriteCloser struct {
|
|
io.Writer
|
|
}
|
|
|
|
func (nopWriteCloser) Close() error { 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
|
|
}
|