mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
Part of #17649 --- # Allow MCP server to run without authentication This PR enhances the MCP server to operate without requiring authentication, making it more flexible for environments where authentication isn't available or necessary. Key changes: - Replaced `InitClient` with `TryInitClient` to allow the MCP server to start without credentials - Added graceful handling when URL or authentication is missing - Made authentication status visible in server logs - Added logic to skip user-dependent tools when no authenticated user is present - Made the `coder_report_task` tool available with just an agent token (no user token required) - Added comprehensive tests to verify operation without authentication These changes allow the MCP server to function in more environments while still using authentication when available, improving flexibility for CI/CD and other automated environments.
824 lines
25 KiB
Go
824 lines
25 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"
|
|
|
|
"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 <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{}
|
|
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 = "<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
|
|
}
|
|
|
|
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
|
|
},
|
|
}
|
|
}
|