@@ -6,8 +6,11 @@ import (
6
6
"errors"
7
7
"os"
8
8
"path/filepath"
9
+ "strings"
9
10
10
11
"github.com/mark3labs/mcp-go/server"
12
+ "github.com/spf13/afero"
13
+ "golang.org/x/xerrors"
11
14
12
15
"cdr.dev/slog"
13
16
"cdr.dev/slog/sloggers/sloghuman"
@@ -106,12 +109,118 @@ func (*RootCmd) mcpConfigureClaudeDesktop() *serpent.Command {
106
109
}
107
110
108
111
func (* RootCmd ) mcpConfigureClaudeCode () * serpent.Command {
112
+ var (
113
+ apiKey string
114
+ claudeConfigPath string
115
+ claudeMDPath string
116
+ systemPrompt string
117
+ appStatusSlug string
118
+ testBinaryName string
119
+ )
109
120
cmd := & serpent.Command {
110
- Use : "claude-code" ,
111
- Short : "Configure the Claude Code server." ,
112
- Handler : func (_ * serpent.Invocation ) error {
121
+ Use : "claude-code <project-directory>" ,
122
+ 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." ,
123
+ Handler : func (inv * serpent.Invocation ) error {
124
+ if len (inv .Args ) == 0 {
125
+ return xerrors .Errorf ("project directory is required" )
126
+ }
127
+ projectDirectory := inv .Args [0 ]
128
+ fs := afero .NewOsFs ()
129
+ binPath , err := os .Executable ()
130
+ if err != nil {
131
+ return xerrors .Errorf ("failed to get executable path: %w" , err )
132
+ }
133
+ if testBinaryName != "" {
134
+ binPath = testBinaryName
135
+ }
136
+ configureClaudeEnv := map [string ]string {}
137
+ agentToken , err := getAgentToken (fs )
138
+ if err != nil {
139
+ cliui .Warnf (inv .Stderr , "failed to get agent token: %s" , err )
140
+ } else {
141
+ configureClaudeEnv ["CODER_AGENT_TOKEN" ] = agentToken
142
+ }
143
+ if appStatusSlug != "" {
144
+ configureClaudeEnv ["CODER_MCP_APP_STATUS_SLUG" ] = appStatusSlug
145
+ }
146
+ if deprecatedSystemPromptEnv , ok := os .LookupEnv ("SYSTEM_PROMPT" ); ok {
147
+ cliui .Warnf (inv .Stderr , "SYSTEM_PROMPT is deprecated, use CODER_MCP_CLAUDE_SYSTEM_PROMPT instead" )
148
+ systemPrompt = deprecatedSystemPromptEnv
149
+ }
150
+
151
+ if err := configureClaude (fs , ClaudeConfig {
152
+ // TODO: will this always be stable?
153
+ AllowedTools : []string {`mcp__coder__coder_report_task` },
154
+ APIKey : apiKey ,
155
+ ConfigPath : claudeConfigPath ,
156
+ ProjectDirectory : projectDirectory ,
157
+ MCPServers : map [string ]ClaudeConfigMCP {
158
+ "coder" : {
159
+ Command : binPath ,
160
+ Args : []string {"exp" , "mcp" , "server" },
161
+ Env : configureClaudeEnv ,
162
+ },
163
+ },
164
+ }); err != nil {
165
+ return xerrors .Errorf ("failed to modify claude.json: %w" , err )
166
+ }
167
+ cliui .Infof (inv .Stderr , "Wrote config to %s" , claudeConfigPath )
168
+
169
+ // We also write the system prompt to the CLAUDE.md file.
170
+ if err := injectClaudeMD (fs , systemPrompt , claudeMDPath ); err != nil {
171
+ return xerrors .Errorf ("failed to modify CLAUDE.md: %w" , err )
172
+ }
173
+ cliui .Infof (inv .Stderr , "Wrote CLAUDE.md to %s" , claudeMDPath )
113
174
return nil
114
175
},
176
+ Options : []serpent.Option {
177
+ {
178
+ Name : "claude-config-path" ,
179
+ Description : "The path to the Claude config file." ,
180
+ Env : "CODER_MCP_CLAUDE_CONFIG_PATH" ,
181
+ Flag : "claude-config-path" ,
182
+ Value : serpent .StringOf (& claudeConfigPath ),
183
+ Default : filepath .Join (os .Getenv ("HOME" ), ".claude.json" ),
184
+ },
185
+ {
186
+ Name : "claude-md-path" ,
187
+ Description : "The path to CLAUDE.md." ,
188
+ Env : "CODER_MCP_CLAUDE_MD_PATH" ,
189
+ Flag : "claude-md-path" ,
190
+ Value : serpent .StringOf (& claudeMDPath ),
191
+ Default : filepath .Join (os .Getenv ("HOME" ), ".claude" , "CLAUDE.md" ),
192
+ },
193
+ {
194
+ Name : "api-key" ,
195
+ Description : "The API key to use for the Claude Code server." ,
196
+ Env : "CODER_MCP_CLAUDE_API_KEY" ,
197
+ Flag : "claude-api-key" ,
198
+ Value : serpent .StringOf (& apiKey ),
199
+ },
200
+ {
201
+ Name : "system-prompt" ,
202
+ Description : "The system prompt to use for the Claude Code server." ,
203
+ Env : "CODER_MCP_CLAUDE_SYSTEM_PROMPT" ,
204
+ Flag : "claude-system-prompt" ,
205
+ Value : serpent .StringOf (& systemPrompt ),
206
+ Default : "Send a task status update to notify the user that you are ready for input, and then wait for user input." ,
207
+ },
208
+ {
209
+ Name : "app-status-slug" ,
210
+ Description : "The app status slug to use when running the Coder MCP server." ,
211
+ Env : "CODER_MCP_CLAUDE_APP_STATUS_SLUG" ,
212
+ Flag : "claude-app-status-slug" ,
213
+ Value : serpent .StringOf (& appStatusSlug ),
214
+ },
215
+ {
216
+ Name : "test-binary-name" ,
217
+ Description : "Only used for testing." ,
218
+ Env : "CODER_MCP_CLAUDE_TEST_BINARY_NAME" ,
219
+ Flag : "claude-test-binary-name" ,
220
+ Value : serpent .StringOf (& testBinaryName ),
221
+ Hidden : true ,
222
+ },
223
+ },
115
224
}
116
225
return cmd
117
226
}
@@ -317,3 +426,247 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct
317
426
318
427
return nil
319
428
}
429
+
430
+ type ClaudeConfig struct {
431
+ ConfigPath string
432
+ ProjectDirectory string
433
+ APIKey string
434
+ AllowedTools []string
435
+ MCPServers map [string ]ClaudeConfigMCP
436
+ }
437
+
438
+ type ClaudeConfigMCP struct {
439
+ Command string `json:"command"`
440
+ Args []string `json:"args"`
441
+ Env map [string ]string `json:"env"`
442
+ }
443
+
444
+ func configureClaude (fs afero.Fs , cfg ClaudeConfig ) error {
445
+ if cfg .ConfigPath == "" {
446
+ cfg .ConfigPath = filepath .Join (os .Getenv ("HOME" ), ".claude.json" )
447
+ }
448
+ var config map [string ]any
449
+ _ , err := fs .Stat (cfg .ConfigPath )
450
+ if err != nil {
451
+ if ! os .IsNotExist (err ) {
452
+ return xerrors .Errorf ("failed to stat claude config: %w" , err )
453
+ }
454
+ // Touch the file to create it if it doesn't exist.
455
+ if err = afero .WriteFile (fs , cfg .ConfigPath , []byte (`{}` ), 0o600 ); err != nil {
456
+ return xerrors .Errorf ("failed to touch claude config: %w" , err )
457
+ }
458
+ }
459
+ oldConfigBytes , err := afero .ReadFile (fs , cfg .ConfigPath )
460
+ if err != nil {
461
+ return xerrors .Errorf ("failed to read claude config: %w" , err )
462
+ }
463
+ err = json .Unmarshal (oldConfigBytes , & config )
464
+ if err != nil {
465
+ return xerrors .Errorf ("failed to unmarshal claude config: %w" , err )
466
+ }
467
+
468
+ if cfg .APIKey != "" {
469
+ // Stops Claude from requiring the user to generate
470
+ // a Claude-specific API key.
471
+ config ["primaryApiKey" ] = cfg .APIKey
472
+ }
473
+ // Stops Claude from asking for onboarding.
474
+ config ["hasCompletedOnboarding" ] = true
475
+ // Stops Claude from asking for permissions.
476
+ config ["bypassPermissionsModeAccepted" ] = true
477
+ config ["autoUpdaterStatus" ] = "disabled"
478
+ // Stops Claude from asking for cost threshold.
479
+ config ["hasAcknowledgedCostThreshold" ] = true
480
+
481
+ projects , ok := config ["projects" ].(map [string ]any )
482
+ if ! ok {
483
+ projects = make (map [string ]any )
484
+ }
485
+
486
+ project , ok := projects [cfg .ProjectDirectory ].(map [string ]any )
487
+ if ! ok {
488
+ project = make (map [string ]any )
489
+ }
490
+
491
+ allowedTools , ok := project ["allowedTools" ].([]string )
492
+ if ! ok {
493
+ allowedTools = []string {}
494
+ }
495
+
496
+ // Add cfg.AllowedTools to the list if they're not already present.
497
+ for _ , tool := range cfg .AllowedTools {
498
+ for _ , existingTool := range allowedTools {
499
+ if tool == existingTool {
500
+ continue
501
+ }
502
+ }
503
+ allowedTools = append (allowedTools , tool )
504
+ }
505
+ project ["allowedTools" ] = allowedTools
506
+ project ["hasTrustDialogAccepted" ] = true
507
+ project ["hasCompletedProjectOnboarding" ] = true
508
+
509
+ mcpServers , ok := project ["mcpServers" ].(map [string ]any )
510
+ if ! ok {
511
+ mcpServers = make (map [string ]any )
512
+ }
513
+ for name , mcp := range cfg .MCPServers {
514
+ mcpServers [name ] = mcp
515
+ }
516
+ project ["mcpServers" ] = mcpServers
517
+ // Prevents Claude from asking the user to complete the project onboarding.
518
+ project ["hasCompletedProjectOnboarding" ] = true
519
+
520
+ history , ok := project ["history" ].([]string )
521
+ injectedHistoryLine := "make sure to read claude.md and report tasks properly"
522
+
523
+ if ! ok || len (history ) == 0 {
524
+ // History doesn't exist or is empty, create it with our injected line
525
+ history = []string {injectedHistoryLine }
526
+ } else if history [0 ] != injectedHistoryLine {
527
+ // Check if our line is already the first item
528
+ // Prepend our line to the existing history
529
+ history = append ([]string {injectedHistoryLine }, history ... )
530
+ }
531
+ project ["history" ] = history
532
+
533
+ projects [cfg .ProjectDirectory ] = project
534
+ config ["projects" ] = projects
535
+
536
+ newConfigBytes , err := json .MarshalIndent (config , "" , " " )
537
+ if err != nil {
538
+ return xerrors .Errorf ("failed to marshal claude config: %w" , err )
539
+ }
540
+ err = afero .WriteFile (fs , cfg .ConfigPath , newConfigBytes , 0o644 )
541
+ if err != nil {
542
+ return xerrors .Errorf ("failed to write claude config: %w" , err )
543
+ }
544
+ return nil
545
+ }
546
+
547
+ var (
548
+ coderPrompt = `YOU MUST REPORT YOUR STATUS IMMEDIATELY AFTER EACH USER MESSAGE.
549
+ INTERRUPT READING FILES OR ANY OTHER TOOL CALL IF YOU HAVE NOT REPORTED A STATUS YET.
550
+ You MUST use the mcp__coder__coder_report_task function with all required parameters:
551
+ - summary: Short description of what you're doing
552
+ - link: A relevant link for the status
553
+ - done: Boolean indicating if the task is complete (true/false)
554
+ - emoji: Relevant emoji for the status
555
+ - need_user_attention: Boolean indicating if the task needs user attention (true/false)
556
+ WHEN TO REPORT (MANDATORY):
557
+ 1. IMMEDIATELY after receiving ANY user message, before any other actions
558
+ 2. After completing any task
559
+ 3. When making significant progress
560
+ 4. When encountering roadblocks
561
+ 5. When asking questions
562
+ 6. Before and after using search tools or making code changes
563
+ FAILING TO REPORT STATUS PROPERLY WILL RESULT IN INCORRECT BEHAVIOR.`
564
+
565
+ // Define the guard strings
566
+ coderPromptStartGuard = "<coder-prompt>"
567
+ coderPromptEndGuard = "</coder-prompt>"
568
+ systemPromptStartGuard = "<system-prompt>"
569
+ systemPromptEndGuard = "</system-prompt>"
570
+ )
571
+
572
+ func injectClaudeMD (fs afero.Fs , systemPrompt string , claudeMDPath string ) error {
573
+ _ , err := fs .Stat (claudeMDPath )
574
+ if err != nil {
575
+ if ! os .IsNotExist (err ) {
576
+ return xerrors .Errorf ("failed to stat claude config: %w" , err )
577
+ }
578
+ // Write a new file with the system prompt.
579
+ if err = fs .MkdirAll (filepath .Dir (claudeMDPath ), 0o700 ); err != nil {
580
+ return xerrors .Errorf ("failed to create claude config directory: %w" , err )
581
+ }
582
+
583
+ return afero .WriteFile (fs , claudeMDPath , []byte (promptsBlock (coderPrompt , systemPrompt , "" )), 0o600 )
584
+ }
585
+
586
+ bs , err := afero .ReadFile (fs , claudeMDPath )
587
+ if err != nil {
588
+ return xerrors .Errorf ("failed to read claude config: %w" , err )
589
+ }
590
+
591
+ // Extract the content without the guarded sections
592
+ cleanContent := string (bs )
593
+
594
+ // Remove existing coder prompt section if it exists
595
+ coderStartIdx := indexOf (cleanContent , coderPromptStartGuard )
596
+ coderEndIdx := indexOf (cleanContent , coderPromptEndGuard )
597
+ if coderStartIdx != - 1 && coderEndIdx != - 1 && coderStartIdx < coderEndIdx {
598
+ beforeCoderPrompt := cleanContent [:coderStartIdx ]
599
+ afterCoderPrompt := cleanContent [coderEndIdx + len (coderPromptEndGuard ):]
600
+ cleanContent = beforeCoderPrompt + afterCoderPrompt
601
+ }
602
+
603
+ // Remove existing system prompt section if it exists
604
+ systemStartIdx := indexOf (cleanContent , systemPromptStartGuard )
605
+ systemEndIdx := indexOf (cleanContent , systemPromptEndGuard )
606
+ if systemStartIdx != - 1 && systemEndIdx != - 1 && systemStartIdx < systemEndIdx {
607
+ beforeSystemPrompt := cleanContent [:systemStartIdx ]
608
+ afterSystemPrompt := cleanContent [systemEndIdx + len (systemPromptEndGuard ):]
609
+ cleanContent = beforeSystemPrompt + afterSystemPrompt
610
+ }
611
+
612
+ // Trim any leading whitespace from the clean content
613
+ cleanContent = strings .TrimSpace (cleanContent )
614
+
615
+ // Create the new content with coder and system prompt prepended
616
+ newContent := promptsBlock (coderPrompt , systemPrompt , cleanContent )
617
+
618
+ // Write the updated content back to the file
619
+ err = afero .WriteFile (fs , claudeMDPath , []byte (newContent ), 0o600 )
620
+ if err != nil {
621
+ return xerrors .Errorf ("failed to write claude config: %w" , err )
622
+ }
623
+
624
+ return nil
625
+ }
626
+
627
+ func promptsBlock (coderPrompt , systemPrompt , existingContent string ) string {
628
+ var newContent strings.Builder
629
+ _ , _ = newContent .WriteString (coderPromptStartGuard )
630
+ _ , _ = newContent .WriteRune ('\n' )
631
+ _ , _ = newContent .WriteString (coderPrompt )
632
+ _ , _ = newContent .WriteRune ('\n' )
633
+ _ , _ = newContent .WriteString (coderPromptEndGuard )
634
+ _ , _ = newContent .WriteRune ('\n' )
635
+ _ , _ = newContent .WriteString (systemPromptStartGuard )
636
+ _ , _ = newContent .WriteRune ('\n' )
637
+ _ , _ = newContent .WriteString (systemPrompt )
638
+ _ , _ = newContent .WriteRune ('\n' )
639
+ _ , _ = newContent .WriteString (systemPromptEndGuard )
640
+ _ , _ = newContent .WriteRune ('\n' )
641
+ if existingContent != "" {
642
+ _ , _ = newContent .WriteString (existingContent )
643
+ }
644
+ return newContent .String ()
645
+ }
646
+
647
+ // indexOf returns the index of the first instance of substr in s,
648
+ // or -1 if substr is not present in s.
649
+ func indexOf (s , substr string ) int {
650
+ for i := 0 ; i <= len (s )- len (substr ); i ++ {
651
+ if s [i :i + len (substr )] == substr {
652
+ return i
653
+ }
654
+ }
655
+ return - 1
656
+ }
657
+
658
+ func getAgentToken (fs afero.Fs ) (string , error ) {
659
+ token , ok := os .LookupEnv ("CODER_AGENT_TOKEN" )
660
+ if ok {
661
+ return token , nil
662
+ }
663
+ tokenFile , ok := os .LookupEnv ("CODER_AGENT_TOKEN_FILE" )
664
+ if ! ok {
665
+ return "" , xerrors .Errorf ("CODER_AGENT_TOKEN or CODER_AGENT_TOKEN_FILE must be set for token auth" )
666
+ }
667
+ bs , err := afero .ReadFile (fs , tokenFile )
668
+ if err != nil {
669
+ return "" , xerrors .Errorf ("failed to read agent token file: %w" , err )
670
+ }
671
+ return string (bs ), nil
672
+ }
0 commit comments