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

Skip to content

Commit c034b6f

Browse files
committed
feat(cli): add experimental MCP server command
1 parent 8ea956f commit c034b6f

12 files changed

+1441
-1
lines changed

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

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

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

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

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