Skip to content

Commit 435c6a1

Browse files
johnstcnEdwardAngert
authored andcommitted
feat(cli): add coder exp mcp command (#17066)
Adds a `coder exp mcp` command which will start a local MCP server listening on stdio with the following capabilities: * Show logged in user (`coder whoami`) * List workspaces (`coder list`) * List templates (`coder templates list`) * Start a workspace (`coder start`) * Stop a workspace (`coder stop`) * Fetch a single workspace (no direct CLI analogue) * Execute a command inside a workspace (`coder exp rpty`) * Report the status of a task (currently a no-op, pending task support) This can be tested as follows: ``` # Start a local Coder server. ./scripts/develop.sh # Start a workspace. Currently, creating workspaces is not supported. ./scripts/coder-dev.sh create -t docker --yes # Add the MCP to your Claude config. claude mcp add coder ./scripts/coder-dev.sh exp mcp # Tell Claude to do something Coder-related. You may need to nudge it to use the tools. claude 'start a docker workspace and tell me what version of python is installed' ```
1 parent fffa8c0 commit 435c6a1

File tree

9 files changed

+1469
-5
lines changed

9 files changed

+1469
-5
lines changed

cli/clitest/golden.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import (
1111
"strings"
1212
"testing"
1313

14+
"github.com/google/go-cmp/cmp"
1415
"github.com/google/uuid"
16+
"github.com/stretchr/testify/assert"
1517
"github.com/stretchr/testify/require"
1618

1719
"github.com/coder/coder/v2/cli/config"
@@ -117,11 +119,7 @@ func TestGoldenFile(t *testing.T, fileName string, actual []byte, replacements m
117119
require.NoError(t, err, "read golden file, run \"make gen/golden-files\" and commit the changes")
118120

119121
expected = normalizeGoldenFile(t, expected)
120-
require.Equal(
121-
t, string(expected), string(actual),
122-
"golden file mismatch: %s, run \"make gen/golden-files\", verify and commit the changes",
123-
goldenPath,
124-
)
122+
assert.Empty(t, cmp.Diff(string(expected), string(actual)), "golden file mismatch (-want +got): %s, run \"make gen/golden-files\", verify and commit the changes", goldenPath)
125123
}
126124

127125
// normalizeGoldenFile replaces any strings that are system or timing dependent

cli/exp.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ func (r *RootCmd) expCmd() *serpent.Command {
1313
Children: []*serpent.Command{
1414
r.scaletestCmd(),
1515
r.errorExample(),
16+
r.mcpCommand(),
1617
r.promptExample(),
1718
r.rptyCommand(),
1819
},

cli/exp_mcp.go

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"log"
8+
"os"
9+
"path/filepath"
10+
11+
"cdr.dev/slog"
12+
"cdr.dev/slog/sloggers/sloghuman"
13+
"github.com/coder/coder/v2/cli/cliui"
14+
"github.com/coder/coder/v2/codersdk"
15+
codermcp "github.com/coder/coder/v2/mcp"
16+
"github.com/coder/serpent"
17+
)
18+
19+
func (r *RootCmd) mcpCommand() *serpent.Command {
20+
cmd := &serpent.Command{
21+
Use: "mcp",
22+
Short: "Run the Coder MCP server and configure it to work with AI tools.",
23+
Long: "The Coder MCP server allows you to automatically create workspaces with parameters.",
24+
Handler: func(i *serpent.Invocation) error {
25+
return i.Command.HelpHandler(i)
26+
},
27+
Children: []*serpent.Command{
28+
r.mcpConfigure(),
29+
r.mcpServer(),
30+
},
31+
}
32+
return cmd
33+
}
34+
35+
func (r *RootCmd) mcpConfigure() *serpent.Command {
36+
cmd := &serpent.Command{
37+
Use: "configure",
38+
Short: "Automatically configure the MCP server.",
39+
Handler: func(i *serpent.Invocation) error {
40+
return i.Command.HelpHandler(i)
41+
},
42+
Children: []*serpent.Command{
43+
r.mcpConfigureClaudeDesktop(),
44+
r.mcpConfigureClaudeCode(),
45+
r.mcpConfigureCursor(),
46+
},
47+
}
48+
return cmd
49+
}
50+
51+
func (*RootCmd) mcpConfigureClaudeDesktop() *serpent.Command {
52+
cmd := &serpent.Command{
53+
Use: "claude-desktop",
54+
Short: "Configure the Claude Desktop server.",
55+
Handler: func(_ *serpent.Invocation) error {
56+
configPath, err := os.UserConfigDir()
57+
if err != nil {
58+
return err
59+
}
60+
configPath = filepath.Join(configPath, "Claude")
61+
err = os.MkdirAll(configPath, 0o755)
62+
if err != nil {
63+
return err
64+
}
65+
configPath = filepath.Join(configPath, "claude_desktop_config.json")
66+
_, err = os.Stat(configPath)
67+
if err != nil {
68+
if !os.IsNotExist(err) {
69+
return err
70+
}
71+
}
72+
contents := map[string]any{}
73+
data, err := os.ReadFile(configPath)
74+
if err != nil {
75+
if !os.IsNotExist(err) {
76+
return err
77+
}
78+
} else {
79+
err = json.Unmarshal(data, &contents)
80+
if err != nil {
81+
return err
82+
}
83+
}
84+
binPath, err := os.Executable()
85+
if err != nil {
86+
return err
87+
}
88+
contents["mcpServers"] = map[string]any{
89+
"coder": map[string]any{"command": binPath, "args": []string{"exp", "mcp", "server"}},
90+
}
91+
data, err = json.MarshalIndent(contents, "", " ")
92+
if err != nil {
93+
return err
94+
}
95+
err = os.WriteFile(configPath, data, 0o600)
96+
if err != nil {
97+
return err
98+
}
99+
return nil
100+
},
101+
}
102+
return cmd
103+
}
104+
105+
func (*RootCmd) mcpConfigureClaudeCode() *serpent.Command {
106+
cmd := &serpent.Command{
107+
Use: "claude-code",
108+
Short: "Configure the Claude Code server.",
109+
Handler: func(_ *serpent.Invocation) error {
110+
return nil
111+
},
112+
}
113+
return cmd
114+
}
115+
116+
func (*RootCmd) mcpConfigureCursor() *serpent.Command {
117+
var project bool
118+
cmd := &serpent.Command{
119+
Use: "cursor",
120+
Short: "Configure Cursor to use Coder MCP.",
121+
Options: serpent.OptionSet{
122+
serpent.Option{
123+
Flag: "project",
124+
Env: "CODER_MCP_CURSOR_PROJECT",
125+
Description: "Use to configure a local project to use the Cursor MCP.",
126+
Value: serpent.BoolOf(&project),
127+
},
128+
},
129+
Handler: func(_ *serpent.Invocation) error {
130+
dir, err := os.Getwd()
131+
if err != nil {
132+
return err
133+
}
134+
if !project {
135+
dir, err = os.UserHomeDir()
136+
if err != nil {
137+
return err
138+
}
139+
}
140+
cursorDir := filepath.Join(dir, ".cursor")
141+
err = os.MkdirAll(cursorDir, 0o755)
142+
if err != nil {
143+
return err
144+
}
145+
mcpConfig := filepath.Join(cursorDir, "mcp.json")
146+
_, err = os.Stat(mcpConfig)
147+
contents := map[string]any{}
148+
if err != nil {
149+
if !os.IsNotExist(err) {
150+
return err
151+
}
152+
} else {
153+
data, err := os.ReadFile(mcpConfig)
154+
if err != nil {
155+
return err
156+
}
157+
// The config can be empty, so we don't want to return an error if it is.
158+
if len(data) > 0 {
159+
err = json.Unmarshal(data, &contents)
160+
if err != nil {
161+
return err
162+
}
163+
}
164+
}
165+
mcpServers, ok := contents["mcpServers"].(map[string]any)
166+
if !ok {
167+
mcpServers = map[string]any{}
168+
}
169+
binPath, err := os.Executable()
170+
if err != nil {
171+
return err
172+
}
173+
mcpServers["coder"] = map[string]any{
174+
"command": binPath,
175+
"args": []string{"exp", "mcp", "server"},
176+
}
177+
contents["mcpServers"] = mcpServers
178+
data, err := json.MarshalIndent(contents, "", " ")
179+
if err != nil {
180+
return err
181+
}
182+
err = os.WriteFile(mcpConfig, data, 0o600)
183+
if err != nil {
184+
return err
185+
}
186+
return nil
187+
},
188+
}
189+
return cmd
190+
}
191+
192+
func (r *RootCmd) mcpServer() *serpent.Command {
193+
var (
194+
client = new(codersdk.Client)
195+
instructions string
196+
allowedTools []string
197+
)
198+
return &serpent.Command{
199+
Use: "server",
200+
Handler: func(inv *serpent.Invocation) error {
201+
return mcpServerHandler(inv, client, instructions, allowedTools)
202+
},
203+
Short: "Start the Coder MCP server.",
204+
Middleware: serpent.Chain(
205+
r.InitClient(client),
206+
),
207+
Options: []serpent.Option{
208+
{
209+
Name: "instructions",
210+
Description: "The instructions to pass to the MCP server.",
211+
Flag: "instructions",
212+
Value: serpent.StringOf(&instructions),
213+
},
214+
{
215+
Name: "allowed-tools",
216+
Description: "Comma-separated list of allowed tools. If not specified, all tools are allowed.",
217+
Flag: "allowed-tools",
218+
Value: serpent.StringArrayOf(&allowedTools),
219+
},
220+
},
221+
}
222+
}
223+
224+
func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instructions string, allowedTools []string) error {
225+
ctx, cancel := context.WithCancel(inv.Context())
226+
defer cancel()
227+
228+
logger := slog.Make(sloghuman.Sink(inv.Stdout))
229+
230+
me, err := client.User(ctx, codersdk.Me)
231+
if err != nil {
232+
cliui.Errorf(inv.Stderr, "Failed to log in to the Coder deployment.")
233+
cliui.Errorf(inv.Stderr, "Please check your URL and credentials.")
234+
cliui.Errorf(inv.Stderr, "Tip: Run `coder whoami` to check your credentials.")
235+
return err
236+
}
237+
cliui.Infof(inv.Stderr, "Starting MCP server")
238+
cliui.Infof(inv.Stderr, "User : %s", me.Username)
239+
cliui.Infof(inv.Stderr, "URL : %s", client.URL)
240+
cliui.Infof(inv.Stderr, "Instructions : %q", instructions)
241+
if len(allowedTools) > 0 {
242+
cliui.Infof(inv.Stderr, "Allowed Tools : %v", allowedTools)
243+
}
244+
cliui.Infof(inv.Stderr, "Press Ctrl+C to stop the server")
245+
246+
// Capture the original stdin, stdout, and stderr.
247+
invStdin := inv.Stdin
248+
invStdout := inv.Stdout
249+
invStderr := inv.Stderr
250+
defer func() {
251+
inv.Stdin = invStdin
252+
inv.Stdout = invStdout
253+
inv.Stderr = invStderr
254+
}()
255+
256+
options := []codermcp.Option{
257+
codermcp.WithInstructions(instructions),
258+
codermcp.WithLogger(&logger),
259+
}
260+
261+
// Add allowed tools option if specified
262+
if len(allowedTools) > 0 {
263+
options = append(options, codermcp.WithAllowedTools(allowedTools))
264+
}
265+
266+
srv := codermcp.NewStdio(client, options...)
267+
srv.SetErrorLogger(log.New(invStderr, "", log.LstdFlags))
268+
269+
done := make(chan error)
270+
go func() {
271+
defer close(done)
272+
srvErr := srv.Listen(ctx, invStdin, invStdout)
273+
done <- srvErr
274+
}()
275+
276+
if err := <-done; err != nil {
277+
if !errors.Is(err, context.Canceled) {
278+
cliui.Errorf(inv.Stderr, "Failed to start the MCP server: %s", err)
279+
return err
280+
}
281+
}
282+
283+
return nil
284+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy