Skip to content

Commit 19a504c

Browse files
committed
feat(cli): add experimental MCP server command
1 parent c679991 commit 19a504c

File tree

12 files changed

+1441
-1
lines changed

12 files changed

+1441
-1
lines changed

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: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"errors"
6+
7+
"cdr.dev/slog"
8+
"cdr.dev/slog/sloggers/sloghuman"
9+
"github.com/coder/coder/v2/cli/cliui"
10+
"github.com/coder/coder/v2/codersdk"
11+
codermcp "github.com/coder/coder/v2/mcp"
12+
"github.com/coder/serpent"
13+
)
14+
15+
func (r *RootCmd) mcpCommand() *serpent.Command {
16+
var (
17+
client = new(codersdk.Client)
18+
instructions string
19+
allowedTools []string
20+
allowedExecCommands []string
21+
)
22+
return &serpent.Command{
23+
Use: "mcp",
24+
Handler: func(inv *serpent.Invocation) error {
25+
return mcpHandler(inv, client, instructions, allowedTools, allowedExecCommands)
26+
},
27+
Short: "Start an MCP server that can be used to interact with a Coder depoyment.",
28+
Middleware: serpent.Chain(
29+
r.InitClient(client),
30+
),
31+
Options: []serpent.Option{
32+
{
33+
Name: "instructions",
34+
Description: "The instructions to pass to the MCP server.",
35+
Flag: "instructions",
36+
Value: serpent.StringOf(&instructions),
37+
},
38+
{
39+
Name: "allowed-tools",
40+
Description: "Comma-separated list of allowed tools. If not specified, all tools are allowed.",
41+
Flag: "allowed-tools",
42+
Value: serpent.StringArrayOf(&allowedTools),
43+
},
44+
{
45+
Name: "allowed-exec-commands",
46+
Description: "Comma-separated list of allowed commands for workspace execution. If not specified, all commands are allowed.",
47+
Flag: "allowed-exec-commands",
48+
Value: serpent.StringArrayOf(&allowedExecCommands),
49+
},
50+
},
51+
}
52+
}
53+
54+
func mcpHandler(inv *serpent.Invocation, client *codersdk.Client, instructions string, allowedTools []string, allowedExecCommands []string) error {
55+
ctx, cancel := context.WithCancel(inv.Context())
56+
defer cancel()
57+
58+
logger := slog.Make(sloghuman.Sink(inv.Stdout))
59+
60+
me, err := client.User(ctx, codersdk.Me)
61+
if err != nil {
62+
cliui.Errorf(inv.Stderr, "Failed to log in to the Coder deployment.")
63+
cliui.Errorf(inv.Stderr, "Please check your URL and credentials.")
64+
cliui.Errorf(inv.Stderr, "Tip: Run `coder whoami` to check your credentials.")
65+
return err
66+
}
67+
cliui.Infof(inv.Stderr, "Starting MCP server")
68+
cliui.Infof(inv.Stderr, "User : %s", me.Username)
69+
cliui.Infof(inv.Stderr, "URL : %s", client.URL)
70+
cliui.Infof(inv.Stderr, "Instructions : %q", instructions)
71+
if len(allowedTools) > 0 {
72+
cliui.Infof(inv.Stderr, "Allowed Tools : %v", allowedTools)
73+
}
74+
if len(allowedExecCommands) > 0 {
75+
cliui.Infof(inv.Stderr, "Allowed Exec Commands : %v", allowedExecCommands)
76+
}
77+
cliui.Infof(inv.Stderr, "Press Ctrl+C to stop the server")
78+
79+
// Capture the original stdin, stdout, and stderr.
80+
invStdin := inv.Stdin
81+
invStdout := inv.Stdout
82+
invStderr := inv.Stderr
83+
defer func() {
84+
inv.Stdin = invStdin
85+
inv.Stdout = invStdout
86+
inv.Stderr = invStderr
87+
}()
88+
89+
options := []codermcp.Option{
90+
codermcp.WithInstructions(instructions),
91+
codermcp.WithLogger(&logger),
92+
codermcp.WithStdin(invStdin),
93+
codermcp.WithStdout(invStdout),
94+
}
95+
96+
// Add allowed tools option if specified
97+
if len(allowedTools) > 0 {
98+
options = append(options, codermcp.WithAllowedTools(allowedTools))
99+
}
100+
101+
// Add allowed exec commands option if specified
102+
if len(allowedExecCommands) > 0 {
103+
options = append(options, codermcp.WithAllowedExecCommands(allowedExecCommands))
104+
}
105+
106+
closer := codermcp.New(ctx, client, options...)
107+
108+
<-ctx.Done()
109+
if err := closer.Close(); err != nil {
110+
if !errors.Is(err, context.Canceled) {
111+
cliui.Errorf(inv.Stderr, "Failed to stop the MCP server: %s", err)
112+
return err
113+
}
114+
}
115+
return nil
116+
}

cli/exp_mcp_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package cli_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"slices"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/coder/coder/v2/cli/clitest"
13+
"github.com/coder/coder/v2/coderd/coderdtest"
14+
"github.com/coder/coder/v2/pty/ptytest"
15+
"github.com/coder/coder/v2/testutil"
16+
)
17+
18+
func TestExpMcp(t *testing.T) {
19+
t.Parallel()
20+
21+
t.Run("AllowedTools", func(t *testing.T) {
22+
t.Parallel()
23+
24+
ctx := testutil.Context(t, testutil.WaitShort)
25+
cancelCtx, cancel := context.WithCancel(ctx)
26+
t.Cleanup(cancel)
27+
28+
// Given: a running coder deployment
29+
client := coderdtest.New(t, nil)
30+
_ = coderdtest.CreateFirstUser(t, client)
31+
32+
// Given: we run the exp mcp command with allowed tools set
33+
inv, root := clitest.New(t, "exp", "mcp", "--allowed-tools=coder_whoami,coder_list_templates")
34+
inv = inv.WithContext(cancelCtx)
35+
36+
pty := ptytest.New(t)
37+
inv.Stdin = pty.Input()
38+
inv.Stdout = pty.Output()
39+
clitest.SetupConfig(t, client, root)
40+
41+
cmdDone := make(chan struct{})
42+
go func() {
43+
defer close(cmdDone)
44+
err := inv.Run()
45+
assert.NoError(t, err)
46+
}()
47+
48+
// When: we send a tools/list request
49+
toolsPayload := `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`
50+
pty.WriteLine(toolsPayload)
51+
_ = pty.ReadLine(ctx) // ignore echoed output
52+
output := pty.ReadLine(ctx)
53+
54+
cancel()
55+
<-cmdDone
56+
57+
// Then: we should only see the allowed tools in the response
58+
var toolsResponse struct {
59+
Result struct {
60+
Tools []struct {
61+
Name string `json:"name"`
62+
} `json:"tools"`
63+
} `json:"result"`
64+
}
65+
err := json.Unmarshal([]byte(output), &toolsResponse)
66+
require.NoError(t, err)
67+
require.Len(t, toolsResponse.Result.Tools, 2, "should have exactly 2 tools")
68+
foundTools := make([]string, 0, 2)
69+
for _, tool := range toolsResponse.Result.Tools {
70+
foundTools = append(foundTools, tool.Name)
71+
}
72+
slices.Sort(foundTools)
73+
require.Equal(t, []string{"coder_list_templates", "coder_whoami"}, foundTools)
74+
})
75+
76+
t.Run("OK", func(t *testing.T) {
77+
t.Parallel()
78+
79+
ctx := testutil.Context(t, testutil.WaitShort)
80+
cancelCtx, cancel := context.WithCancel(ctx)
81+
t.Cleanup(cancel)
82+
83+
client := coderdtest.New(t, nil)
84+
_ = coderdtest.CreateFirstUser(t, client)
85+
inv, root := clitest.New(t, "exp", "mcp")
86+
inv = inv.WithContext(cancelCtx)
87+
88+
pty := ptytest.New(t)
89+
inv.Stdin = pty.Input()
90+
inv.Stdout = pty.Output()
91+
clitest.SetupConfig(t, client, root)
92+
93+
cmdDone := make(chan struct{})
94+
go func() {
95+
defer close(cmdDone)
96+
err := inv.Run()
97+
assert.NoError(t, err)
98+
}()
99+
100+
payload := `{"jsonrpc":"2.0","id":1,"method":"initialize"}`
101+
pty.WriteLine(payload)
102+
_ = pty.ReadLine(ctx) // ignore echoed output
103+
output := pty.ReadLine(ctx)
104+
cancel()
105+
<-cmdDone
106+
107+
// Ensure the initialize output is valid JSON
108+
t.Logf("/initialize output: %s", output)
109+
var initializeResponse map[string]interface{}
110+
err := json.Unmarshal([]byte(output), &initializeResponse)
111+
require.NoError(t, err)
112+
require.Equal(t, "2.0", initializeResponse["jsonrpc"])
113+
require.Equal(t, 1.0, initializeResponse["id"])
114+
require.NotNil(t, initializeResponse["result"])
115+
})
116+
117+
t.Run("NoCredentials", func(t *testing.T) {
118+
t.Parallel()
119+
120+
ctx := testutil.Context(t, testutil.WaitShort)
121+
cancelCtx, cancel := context.WithCancel(ctx)
122+
t.Cleanup(cancel)
123+
124+
client := coderdtest.New(t, nil)
125+
inv, root := clitest.New(t, "exp", "mcp")
126+
inv = inv.WithContext(cancelCtx)
127+
128+
pty := ptytest.New(t)
129+
inv.Stdin = pty.Input()
130+
inv.Stdout = pty.Output()
131+
clitest.SetupConfig(t, client, root)
132+
133+
err := inv.Run()
134+
assert.ErrorContains(t, err, "your session has expired")
135+
})
136+
}

go.mod

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ require (
320320
github.com/google/nftables v0.2.0 // indirect
321321
github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b // indirect
322322
github.com/google/s2a-go v0.1.9 // indirect
323-
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
323+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
324324
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
325325
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
326326
github.com/gorilla/css v1.0.1 // indirect
@@ -480,3 +480,7 @@ require (
480480
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
481481
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
482482
)
483+
484+
require github.com/mark3labs/mcp-go v0.15.0
485+
486+
require github.com/yosida95/uritemplate/v3 v3.0.2 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r
658658
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
659659
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
660660
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
661+
github.com/mark3labs/mcp-go v0.15.0 h1:lViiC4dk6chJHZccezaTzZLMOQVUXJDGNQPtzExr5NQ=
662+
github.com/mark3labs/mcp-go v0.15.0/go.mod h1:xBB350hekQsJAK7gJAii8bcEoWemboLm2mRm5/+KBaU=
661663
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
662664
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
663665
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@@ -972,6 +974,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
972974
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
973975
github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg=
974976
github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
977+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
978+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
975979
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
976980
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
977981
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=

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