mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
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' ```
362 lines
12 KiB
Go
362 lines
12 KiB
Go
package codermcp_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"cdr.dev/slog/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/agent/agenttest"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbfake"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
codermcp "github.com/coder/coder/v2/mcp"
|
|
"github.com/coder/coder/v2/pty/ptytest"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// These tests are dependent on the state of the coder server.
|
|
// Running them in parallel is prone to racy behavior.
|
|
// nolint:tparallel,paralleltest
|
|
func TestCoderTools(t *testing.T) {
|
|
if runtime.GOOS != "linux" {
|
|
t.Skip("skipping on non-linux due to pty issues")
|
|
}
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
// Given: a coder server, workspace, and agent.
|
|
client, store := coderdtest.NewWithDatabase(t, nil)
|
|
owner := coderdtest.CreateFirstUser(t, client)
|
|
// Given: a member user with which to test the tools.
|
|
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
|
// Given: a workspace with an agent.
|
|
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
}).WithAgent().Do()
|
|
|
|
// Note: we want to test the list_workspaces tool before starting the
|
|
// workspace agent. Starting the workspace agent will modify the workspace
|
|
// state, which will affect the results of the list_workspaces tool.
|
|
listWorkspacesDone := make(chan struct{})
|
|
agentStarted := make(chan struct{})
|
|
go func() {
|
|
defer close(agentStarted)
|
|
<-listWorkspacesDone
|
|
agt := agenttest.New(t, client.URL, r.AgentToken)
|
|
t.Cleanup(func() {
|
|
_ = agt.Close()
|
|
})
|
|
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait()
|
|
}()
|
|
|
|
// Given: a MCP server listening on a pty.
|
|
pty := ptytest.New(t)
|
|
mcpSrv, closeSrv := startTestMCPServer(ctx, t, pty.Input(), pty.Output())
|
|
t.Cleanup(func() {
|
|
_ = closeSrv()
|
|
})
|
|
|
|
// Register tools using our registry
|
|
logger := slogtest.Make(t, nil)
|
|
codermcp.AllTools().Register(mcpSrv, codermcp.ToolDeps{
|
|
Client: memberClient,
|
|
Logger: &logger,
|
|
})
|
|
|
|
t.Run("coder_list_templates", func(t *testing.T) {
|
|
// When: the coder_list_templates tool is called
|
|
ctr := makeJSONRPCRequest(t, "tools/call", "coder_list_templates", map[string]any{})
|
|
|
|
pty.WriteLine(ctr)
|
|
_ = pty.ReadLine(ctx) // skip the echo
|
|
|
|
templates, err := memberClient.Templates(ctx, codersdk.TemplateFilter{})
|
|
require.NoError(t, err)
|
|
templatesJSON, err := json.Marshal(templates)
|
|
require.NoError(t, err)
|
|
|
|
// Then: the response is a list of templates visible to the user.
|
|
expected := makeJSONRPCTextResponse(t, string(templatesJSON))
|
|
actual := pty.ReadLine(ctx)
|
|
testutil.RequireJSONEq(t, expected, actual)
|
|
})
|
|
|
|
t.Run("coder_report_task", func(t *testing.T) {
|
|
// When: the coder_report_task tool is called
|
|
ctr := makeJSONRPCRequest(t, "tools/call", "coder_report_task", map[string]any{
|
|
"summary": "Test summary",
|
|
"link": "https://example.com",
|
|
"emoji": "🔍",
|
|
"done": false,
|
|
"coder_url": client.URL.String(),
|
|
"coder_session_token": client.SessionToken(),
|
|
})
|
|
|
|
pty.WriteLine(ctr)
|
|
_ = pty.ReadLine(ctx) // skip the echo
|
|
|
|
// Then: the response is a success message.
|
|
// TODO: check the task was created. This functionality is not yet implemented.
|
|
expected := makeJSONRPCTextResponse(t, "Thanks for reporting!")
|
|
actual := pty.ReadLine(ctx)
|
|
testutil.RequireJSONEq(t, expected, actual)
|
|
})
|
|
|
|
t.Run("coder_whoami", func(t *testing.T) {
|
|
// When: the coder_whoami tool is called
|
|
me, err := memberClient.User(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
meJSON, err := json.Marshal(me)
|
|
require.NoError(t, err)
|
|
|
|
ctr := makeJSONRPCRequest(t, "tools/call", "coder_whoami", map[string]any{})
|
|
|
|
pty.WriteLine(ctr)
|
|
_ = pty.ReadLine(ctx) // skip the echo
|
|
|
|
// Then: the response is a valid JSON respresentation of the calling user.
|
|
expected := makeJSONRPCTextResponse(t, string(meJSON))
|
|
actual := pty.ReadLine(ctx)
|
|
testutil.RequireJSONEq(t, expected, actual)
|
|
})
|
|
|
|
t.Run("coder_list_workspaces", func(t *testing.T) {
|
|
defer close(listWorkspacesDone)
|
|
// When: the coder_list_workspaces tool is called
|
|
ctr := makeJSONRPCRequest(t, "tools/call", "coder_list_workspaces", map[string]any{
|
|
"coder_url": client.URL.String(),
|
|
"coder_session_token": client.SessionToken(),
|
|
})
|
|
|
|
pty.WriteLine(ctr)
|
|
_ = pty.ReadLine(ctx) // skip the echo
|
|
|
|
ws, err := memberClient.Workspaces(ctx, codersdk.WorkspaceFilter{})
|
|
require.NoError(t, err)
|
|
wsJSON, err := json.Marshal(ws)
|
|
require.NoError(t, err)
|
|
|
|
// Then: the response is a valid JSON respresentation of the calling user's workspaces.
|
|
expected := makeJSONRPCTextResponse(t, string(wsJSON))
|
|
actual := pty.ReadLine(ctx)
|
|
testutil.RequireJSONEq(t, expected, actual)
|
|
})
|
|
|
|
t.Run("coder_get_workspace", func(t *testing.T) {
|
|
// Given: the workspace agent is connected.
|
|
// The act of starting the agent will modify the workspace state.
|
|
<-agentStarted
|
|
// When: the coder_get_workspace tool is called
|
|
ctr := makeJSONRPCRequest(t, "tools/call", "coder_get_workspace", map[string]any{
|
|
"workspace": r.Workspace.ID.String(),
|
|
})
|
|
|
|
pty.WriteLine(ctr)
|
|
_ = pty.ReadLine(ctx) // skip the echo
|
|
|
|
ws, err := memberClient.Workspace(ctx, r.Workspace.ID)
|
|
require.NoError(t, err)
|
|
wsJSON, err := json.Marshal(ws)
|
|
require.NoError(t, err)
|
|
|
|
// Then: the response is a valid JSON respresentation of the workspace.
|
|
expected := makeJSONRPCTextResponse(t, string(wsJSON))
|
|
actual := pty.ReadLine(ctx)
|
|
testutil.RequireJSONEq(t, expected, actual)
|
|
})
|
|
|
|
// NOTE: this test runs after the list_workspaces tool is called.
|
|
t.Run("coder_workspace_exec", func(t *testing.T) {
|
|
// Given: the workspace agent is connected
|
|
<-agentStarted
|
|
|
|
// When: the coder_workspace_exec tools is called with a command
|
|
randString := testutil.GetRandomName(t)
|
|
ctr := makeJSONRPCRequest(t, "tools/call", "coder_workspace_exec", map[string]any{
|
|
"workspace": r.Workspace.ID.String(),
|
|
"command": "echo " + randString,
|
|
"coder_url": client.URL.String(),
|
|
"coder_session_token": client.SessionToken(),
|
|
})
|
|
|
|
pty.WriteLine(ctr)
|
|
_ = pty.ReadLine(ctx) // skip the echo
|
|
|
|
// Then: the response is the output of the command.
|
|
actual := pty.ReadLine(ctx)
|
|
require.Contains(t, actual, randString)
|
|
})
|
|
|
|
// NOTE: this test runs after the list_workspaces tool is called.
|
|
t.Run("tool_restrictions", func(t *testing.T) {
|
|
// Given: the workspace agent is connected
|
|
<-agentStarted
|
|
|
|
// Given: a restricted MCP server with only allowed tools and commands
|
|
restrictedPty := ptytest.New(t)
|
|
allowedTools := []string{"coder_workspace_exec"}
|
|
restrictedMCPSrv, closeRestrictedSrv := startTestMCPServer(ctx, t, restrictedPty.Input(), restrictedPty.Output())
|
|
t.Cleanup(func() {
|
|
_ = closeRestrictedSrv()
|
|
})
|
|
codermcp.AllTools().
|
|
WithOnlyAllowed(allowedTools...).
|
|
Register(restrictedMCPSrv, codermcp.ToolDeps{
|
|
Client: memberClient,
|
|
Logger: &logger,
|
|
})
|
|
|
|
// When: the tools/list command is called
|
|
toolsListCmd := makeJSONRPCRequest(t, "tools/list", "", nil)
|
|
restrictedPty.WriteLine(toolsListCmd)
|
|
_ = restrictedPty.ReadLine(ctx) // skip the echo
|
|
|
|
// Then: the response is a list of only the allowed tools.
|
|
toolsListResponse := restrictedPty.ReadLine(ctx)
|
|
require.Contains(t, toolsListResponse, "coder_workspace_exec")
|
|
require.NotContains(t, toolsListResponse, "coder_whoami")
|
|
|
|
// When: a disallowed tool is called
|
|
disallowedToolCmd := makeJSONRPCRequest(t, "tools/call", "coder_whoami", map[string]any{})
|
|
restrictedPty.WriteLine(disallowedToolCmd)
|
|
_ = restrictedPty.ReadLine(ctx) // skip the echo
|
|
|
|
// Then: the response is an error indicating the tool is not available.
|
|
disallowedToolResponse := restrictedPty.ReadLine(ctx)
|
|
require.Contains(t, disallowedToolResponse, "error")
|
|
require.Contains(t, disallowedToolResponse, "not found")
|
|
})
|
|
|
|
t.Run("coder_workspace_transition_stop", func(t *testing.T) {
|
|
// Given: a separate workspace in the running state
|
|
stopWs := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
}).WithAgent().Do()
|
|
|
|
// When: the coder_workspace_transition tool is called with a stop transition
|
|
ctr := makeJSONRPCRequest(t, "tools/call", "coder_workspace_transition", map[string]any{
|
|
"workspace": stopWs.Workspace.ID.String(),
|
|
"transition": "stop",
|
|
})
|
|
|
|
pty.WriteLine(ctr)
|
|
_ = pty.ReadLine(ctx) // skip the echo
|
|
|
|
// Then: the response is as expected.
|
|
expected := makeJSONRPCTextResponse(t, `{"status":"pending","transition":"stop"}`) // no provisionerd yet
|
|
actual := pty.ReadLine(ctx)
|
|
testutil.RequireJSONEq(t, expected, actual)
|
|
})
|
|
|
|
t.Run("coder_workspace_transition_start", func(t *testing.T) {
|
|
// Given: a separate workspace in the stopped state
|
|
stopWs := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
|
OrganizationID: owner.OrganizationID,
|
|
OwnerID: member.ID,
|
|
}).Seed(database.WorkspaceBuild{
|
|
Transition: database.WorkspaceTransitionStop,
|
|
}).Do()
|
|
|
|
// When: the coder_workspace_transition tool is called with a start transition
|
|
ctr := makeJSONRPCRequest(t, "tools/call", "coder_workspace_transition", map[string]any{
|
|
"workspace": stopWs.Workspace.ID.String(),
|
|
"transition": "start",
|
|
})
|
|
|
|
pty.WriteLine(ctr)
|
|
_ = pty.ReadLine(ctx) // skip the echo
|
|
|
|
// Then: the response is as expected
|
|
expected := makeJSONRPCTextResponse(t, `{"status":"pending","transition":"start"}`) // no provisionerd yet
|
|
actual := pty.ReadLine(ctx)
|
|
testutil.RequireJSONEq(t, expected, actual)
|
|
})
|
|
}
|
|
|
|
// makeJSONRPCRequest is a helper function that makes a JSON RPC request.
|
|
func makeJSONRPCRequest(t *testing.T, method, name string, args map[string]any) string {
|
|
t.Helper()
|
|
req := mcp.JSONRPCRequest{
|
|
ID: "1",
|
|
JSONRPC: "2.0",
|
|
Request: mcp.Request{Method: method},
|
|
Params: struct { // Unfortunately, there is no type for this yet.
|
|
Name string "json:\"name\""
|
|
Arguments map[string]any "json:\"arguments,omitempty\""
|
|
Meta *struct {
|
|
ProgressToken mcp.ProgressToken "json:\"progressToken,omitempty\""
|
|
} "json:\"_meta,omitempty\""
|
|
}{
|
|
Name: name,
|
|
Arguments: args,
|
|
},
|
|
}
|
|
bs, err := json.Marshal(req)
|
|
require.NoError(t, err, "failed to marshal JSON RPC request")
|
|
return string(bs)
|
|
}
|
|
|
|
// makeJSONRPCTextResponse is a helper function that makes a JSON RPC text response
|
|
func makeJSONRPCTextResponse(t *testing.T, text string) string {
|
|
t.Helper()
|
|
|
|
resp := mcp.JSONRPCResponse{
|
|
ID: "1",
|
|
JSONRPC: "2.0",
|
|
Result: mcp.CallToolResult{
|
|
Content: []mcp.Content{
|
|
mcp.NewTextContent(text),
|
|
},
|
|
},
|
|
}
|
|
bs, err := json.Marshal(resp)
|
|
require.NoError(t, err, "failed to marshal JSON RPC response")
|
|
return string(bs)
|
|
}
|
|
|
|
// startTestMCPServer is a helper function that starts a MCP server listening on
|
|
// a pty. It is the responsibility of the caller to close the server.
|
|
func startTestMCPServer(ctx context.Context, t testing.TB, stdin io.Reader, stdout io.Writer) (*server.MCPServer, func() error) {
|
|
t.Helper()
|
|
|
|
mcpSrv := server.NewMCPServer(
|
|
"Test Server",
|
|
"0.0.0",
|
|
server.WithInstructions(""),
|
|
server.WithLogging(),
|
|
)
|
|
|
|
stdioSrv := server.NewStdioServer(mcpSrv)
|
|
|
|
cancelCtx, cancel := context.WithCancel(ctx)
|
|
closeCh := make(chan struct{})
|
|
done := make(chan error)
|
|
go func() {
|
|
defer close(done)
|
|
srvErr := stdioSrv.Listen(cancelCtx, stdin, stdout)
|
|
done <- srvErr
|
|
}()
|
|
|
|
go func() {
|
|
select {
|
|
case <-closeCh:
|
|
cancel()
|
|
case <-done:
|
|
cancel()
|
|
}
|
|
}()
|
|
|
|
return mcpSrv, func() error {
|
|
close(closeCh)
|
|
return <-done
|
|
}
|
|
}
|