Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 057cbd4

Browse files
authored
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 8ea956f commit 057cbd4

File tree

9 files changed

+1469
-5
lines changed

9 files changed

+1469
-5
lines changed

cli/clitest/golden.go

+3-5
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

+1
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

+284
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)