From c034b6f521d3e5adcd7eac97ced0f99bf54b0a1b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 21 Mar 2025 16:38:48 +0000 Subject: [PATCH 01/20] feat(cli): add experimental MCP server command --- cli/exp.go | 1 + cli/exp_mcp.go | 116 +++++++++ cli/exp_mcp_test.go | 136 ++++++++++ go.mod | 6 +- go.sum | 4 + mcp/mcp.go | 124 +++++++++ mcp/tools/command_validator.go | 46 ++++ mcp/tools/command_validator_test.go | 82 ++++++ mcp/tools/tools_coder.go | 381 ++++++++++++++++++++++++++++ mcp/tools/tools_coder_test.go | 380 +++++++++++++++++++++++++++ mcp/tools/tools_registry.go | 139 ++++++++++ testutil/json.go | 27 ++ 12 files changed, 1441 insertions(+), 1 deletion(-) create mode 100644 cli/exp_mcp.go create mode 100644 cli/exp_mcp_test.go create mode 100644 mcp/mcp.go create mode 100644 mcp/tools/command_validator.go create mode 100644 mcp/tools/command_validator_test.go create mode 100644 mcp/tools/tools_coder.go create mode 100644 mcp/tools/tools_coder_test.go create mode 100644 mcp/tools/tools_registry.go create mode 100644 testutil/json.go diff --git a/cli/exp.go b/cli/exp.go index 2339da86313a6..dafd85402663e 100644 --- a/cli/exp.go +++ b/cli/exp.go @@ -13,6 +13,7 @@ func (r *RootCmd) expCmd() *serpent.Command { Children: []*serpent.Command{ r.scaletestCmd(), r.errorExample(), + r.mcpCommand(), r.promptExample(), r.rptyCommand(), }, diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go new file mode 100644 index 0000000000000..0a1c529932a9b --- /dev/null +++ b/cli/exp_mcp.go @@ -0,0 +1,116 @@ +package cli + +import ( + "context" + "errors" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + codermcp "github.com/coder/coder/v2/mcp" + "github.com/coder/serpent" +) + +func (r *RootCmd) mcpCommand() *serpent.Command { + var ( + client = new(codersdk.Client) + instructions string + allowedTools []string + allowedExecCommands []string + ) + return &serpent.Command{ + Use: "mcp", + Handler: func(inv *serpent.Invocation) error { + return mcpHandler(inv, client, instructions, allowedTools, allowedExecCommands) + }, + Short: "Start an MCP server that can be used to interact with a Coder depoyment.", + Middleware: serpent.Chain( + r.InitClient(client), + ), + Options: []serpent.Option{ + { + Name: "instructions", + Description: "The instructions to pass to the MCP server.", + Flag: "instructions", + Value: serpent.StringOf(&instructions), + }, + { + Name: "allowed-tools", + Description: "Comma-separated list of allowed tools. If not specified, all tools are allowed.", + Flag: "allowed-tools", + Value: serpent.StringArrayOf(&allowedTools), + }, + { + Name: "allowed-exec-commands", + Description: "Comma-separated list of allowed commands for workspace execution. If not specified, all commands are allowed.", + Flag: "allowed-exec-commands", + Value: serpent.StringArrayOf(&allowedExecCommands), + }, + }, + } +} + +func mcpHandler(inv *serpent.Invocation, client *codersdk.Client, instructions string, allowedTools []string, allowedExecCommands []string) error { + ctx, cancel := context.WithCancel(inv.Context()) + defer cancel() + + logger := slog.Make(sloghuman.Sink(inv.Stdout)) + + me, err := client.User(ctx, codersdk.Me) + if err != nil { + cliui.Errorf(inv.Stderr, "Failed to log in to the Coder deployment.") + cliui.Errorf(inv.Stderr, "Please check your URL and credentials.") + cliui.Errorf(inv.Stderr, "Tip: Run `coder whoami` to check your credentials.") + return err + } + cliui.Infof(inv.Stderr, "Starting MCP server") + cliui.Infof(inv.Stderr, "User : %s", me.Username) + cliui.Infof(inv.Stderr, "URL : %s", client.URL) + cliui.Infof(inv.Stderr, "Instructions : %q", instructions) + if len(allowedTools) > 0 { + cliui.Infof(inv.Stderr, "Allowed Tools : %v", allowedTools) + } + if len(allowedExecCommands) > 0 { + cliui.Infof(inv.Stderr, "Allowed Exec Commands : %v", allowedExecCommands) + } + cliui.Infof(inv.Stderr, "Press Ctrl+C to stop the server") + + // Capture the original stdin, stdout, and stderr. + invStdin := inv.Stdin + invStdout := inv.Stdout + invStderr := inv.Stderr + defer func() { + inv.Stdin = invStdin + inv.Stdout = invStdout + inv.Stderr = invStderr + }() + + options := []codermcp.Option{ + codermcp.WithInstructions(instructions), + codermcp.WithLogger(&logger), + codermcp.WithStdin(invStdin), + codermcp.WithStdout(invStdout), + } + + // Add allowed tools option if specified + if len(allowedTools) > 0 { + options = append(options, codermcp.WithAllowedTools(allowedTools)) + } + + // Add allowed exec commands option if specified + if len(allowedExecCommands) > 0 { + options = append(options, codermcp.WithAllowedExecCommands(allowedExecCommands)) + } + + closer := codermcp.New(ctx, client, options...) + + <-ctx.Done() + if err := closer.Close(); err != nil { + if !errors.Is(err, context.Canceled) { + cliui.Errorf(inv.Stderr, "Failed to stop the MCP server: %s", err) + return err + } + } + return nil +} diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go new file mode 100644 index 0000000000000..673339b8d2efe --- /dev/null +++ b/cli/exp_mcp_test.go @@ -0,0 +1,136 @@ +package cli_test + +import ( + "context" + "encoding/json" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func TestExpMcp(t *testing.T) { + t.Parallel() + + t.Run("AllowedTools", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + cancelCtx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + + // Given: a running coder deployment + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + // Given: we run the exp mcp command with allowed tools set + inv, root := clitest.New(t, "exp", "mcp", "--allowed-tools=coder_whoami,coder_list_templates") + inv = inv.WithContext(cancelCtx) + + pty := ptytest.New(t) + inv.Stdin = pty.Input() + inv.Stdout = pty.Output() + clitest.SetupConfig(t, client, root) + + cmdDone := make(chan struct{}) + go func() { + defer close(cmdDone) + err := inv.Run() + assert.NoError(t, err) + }() + + // When: we send a tools/list request + toolsPayload := `{"jsonrpc":"2.0","id":2,"method":"tools/list"}` + pty.WriteLine(toolsPayload) + _ = pty.ReadLine(ctx) // ignore echoed output + output := pty.ReadLine(ctx) + + cancel() + <-cmdDone + + // Then: we should only see the allowed tools in the response + var toolsResponse struct { + Result struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } `json:"result"` + } + err := json.Unmarshal([]byte(output), &toolsResponse) + require.NoError(t, err) + require.Len(t, toolsResponse.Result.Tools, 2, "should have exactly 2 tools") + foundTools := make([]string, 0, 2) + for _, tool := range toolsResponse.Result.Tools { + foundTools = append(foundTools, tool.Name) + } + slices.Sort(foundTools) + require.Equal(t, []string{"coder_list_templates", "coder_whoami"}, foundTools) + }) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + cancelCtx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + inv, root := clitest.New(t, "exp", "mcp") + inv = inv.WithContext(cancelCtx) + + pty := ptytest.New(t) + inv.Stdin = pty.Input() + inv.Stdout = pty.Output() + clitest.SetupConfig(t, client, root) + + cmdDone := make(chan struct{}) + go func() { + defer close(cmdDone) + err := inv.Run() + assert.NoError(t, err) + }() + + payload := `{"jsonrpc":"2.0","id":1,"method":"initialize"}` + pty.WriteLine(payload) + _ = pty.ReadLine(ctx) // ignore echoed output + output := pty.ReadLine(ctx) + cancel() + <-cmdDone + + // Ensure the initialize output is valid JSON + t.Logf("/initialize output: %s", output) + var initializeResponse map[string]interface{} + err := json.Unmarshal([]byte(output), &initializeResponse) + require.NoError(t, err) + require.Equal(t, "2.0", initializeResponse["jsonrpc"]) + require.Equal(t, 1.0, initializeResponse["id"]) + require.NotNil(t, initializeResponse["result"]) + }) + + t.Run("NoCredentials", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + cancelCtx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + + client := coderdtest.New(t, nil) + inv, root := clitest.New(t, "exp", "mcp") + inv = inv.WithContext(cancelCtx) + + pty := ptytest.New(t) + inv.Stdin = pty.Input() + inv.Stdout = pty.Output() + clitest.SetupConfig(t, client, root) + + err := inv.Run() + assert.ErrorContains(t, err, "your session has expired") + }) +} diff --git a/go.mod b/go.mod index 56c52a82b6721..fabc53eeb8b10 100644 --- a/go.mod +++ b/go.mod @@ -320,7 +320,7 @@ require ( github.com/google/nftables v0.2.0 // indirect github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/gorilla/css v1.0.1 // indirect @@ -480,3 +480,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect ) + +require github.com/mark3labs/mcp-go v0.15.0 + +require github.com/yosida95/uritemplate/v3 v3.0.2 // indirect diff --git a/go.sum b/go.sum index efa6ade52ffb6..1ec89d58ec9d2 100644 --- a/go.sum +++ b/go.sum @@ -658,6 +658,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc= github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0= github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA= +github.com/mark3labs/mcp-go v0.15.0 h1:lViiC4dk6chJHZccezaTzZLMOQVUXJDGNQPtzExr5NQ= +github.com/mark3labs/mcp-go v0.15.0/go.mod h1:xBB350hekQsJAK7gJAii8bcEoWemboLm2mRm5/+KBaU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 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 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= diff --git a/mcp/mcp.go b/mcp/mcp.go new file mode 100644 index 0000000000000..d8bd8e937e26c --- /dev/null +++ b/mcp/mcp.go @@ -0,0 +1,124 @@ +package codermcp + +import ( + "context" + "io" + "log" + "os" + + "github.com/mark3labs/mcp-go/server" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + mcptools "github.com/coder/coder/v2/mcp/tools" +) + +type mcpOptions struct { + in io.Reader + out io.Writer + instructions string + logger *slog.Logger + allowedTools []string + allowedExecCommands []string +} + +// Option is a function that configures the MCP server. +type Option func(*mcpOptions) + +// WithInstructions sets the instructions for the MCP server. +func WithInstructions(instructions string) Option { + return func(o *mcpOptions) { + o.instructions = instructions + } +} + +// WithLogger sets the logger for the MCP server. +func WithLogger(logger *slog.Logger) Option { + return func(o *mcpOptions) { + o.logger = logger + } +} + +// WithStdin sets the input reader for the MCP server. +func WithStdin(in io.Reader) Option { + return func(o *mcpOptions) { + o.in = in + } +} + +// WithStdout sets the output writer for the MCP server. +func WithStdout(out io.Writer) Option { + return func(o *mcpOptions) { + o.out = out + } +} + +// WithAllowedTools sets the allowed tools for the MCP server. +func WithAllowedTools(tools []string) Option { + return func(o *mcpOptions) { + o.allowedTools = tools + } +} + +// WithAllowedExecCommands sets the allowed commands for workspace execution. +func WithAllowedExecCommands(commands []string) Option { + return func(o *mcpOptions) { + o.allowedExecCommands = commands + } +} + +// New creates a new MCP server with the given client and options. +func New(ctx context.Context, client *codersdk.Client, opts ...Option) io.Closer { + options := &mcpOptions{ + in: os.Stdin, + instructions: ``, + logger: ptr.Ref(slog.Make(sloghuman.Sink(os.Stdout))), + out: os.Stdout, + } + for _, opt := range opts { + opt(options) + } + + mcpSrv := server.NewMCPServer( + "Coder Agent", + buildinfo.Version(), + server.WithInstructions(options.instructions), + ) + + logger := slog.Make(sloghuman.Sink(os.Stdout)) + + // Register tools based on the allowed list (if specified) + reg := mcptools.AllTools() + if len(options.allowedTools) > 0 { + reg = reg.WithOnlyAllowed(options.allowedTools...) + } + reg.Register(mcpSrv, mcptools.ToolDeps{ + Client: client, + Logger: &logger, + AllowedExecCommands: options.allowedExecCommands, + }) + + srv := server.NewStdioServer(mcpSrv) + srv.SetErrorLogger(log.New(options.out, "", log.LstdFlags)) + done := make(chan error) + go func() { + defer close(done) + srvErr := srv.Listen(ctx, options.in, options.out) + done <- srvErr + }() + + return closeFunc(func() error { + return <-done + }) +} + +type closeFunc func() error + +func (f closeFunc) Close() error { + return f() +} + +var _ io.Closer = closeFunc(nil) diff --git a/mcp/tools/command_validator.go b/mcp/tools/command_validator.go new file mode 100644 index 0000000000000..dfbde59b8be3f --- /dev/null +++ b/mcp/tools/command_validator.go @@ -0,0 +1,46 @@ +package mcptools + +import ( + "strings" + + "github.com/google/shlex" + "golang.org/x/xerrors" +) + +// IsCommandAllowed checks if a command is in the allowed list. +// It parses the command using shlex to correctly handle quoted arguments +// and only checks the executable name (first part of the command). +// +// Based on benchmarks, a simple linear search performs better than +// a map-based approach for the typical number of allowed commands, +// so we're sticking with the simple approach. +func IsCommandAllowed(command string, allowedCommands []string) (bool, error) { + if len(allowedCommands) == 0 { + // If no allowed commands are specified, all commands are allowed + return true, nil + } + + // Parse the command to extract the executable name + parts, err := shlex.Split(command) + if err != nil { + return false, xerrors.Errorf("failed to parse command: %w", err) + } + + if len(parts) == 0 { + return false, xerrors.New("empty command") + } + + // The first part is the executable name + executable := parts[0] + + // Check if the executable is in the allowed list + for _, allowed := range allowedCommands { + if allowed == executable { + return true, nil + } + } + + // Build a helpful error message + return false, xerrors.Errorf("command %q is not allowed. Allowed commands: %s", + executable, strings.Join(allowedCommands, ", ")) +} diff --git a/mcp/tools/command_validator_test.go b/mcp/tools/command_validator_test.go new file mode 100644 index 0000000000000..902aa320fce03 --- /dev/null +++ b/mcp/tools/command_validator_test.go @@ -0,0 +1,82 @@ +package mcptools_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + mcptools "github.com/coder/coder/v2/mcp/tools" +) + +func TestIsCommandAllowed(t *testing.T) { + t.Parallel() + tests := []struct { + name string + command string + allowedCommands []string + want bool + wantErr bool + errorMessage string + }{ + { + name: "empty allowed commands allows all", + command: "ls -la", + allowedCommands: []string{}, + want: true, + wantErr: false, + }, + { + name: "allowed command", + command: "ls -la", + allowedCommands: []string{"ls", "cat", "grep"}, + want: true, + wantErr: false, + }, + { + name: "disallowed command", + command: "rm -rf /", + allowedCommands: []string{"ls", "cat", "grep"}, + want: false, + wantErr: true, + errorMessage: "not allowed", + }, + { + name: "command with quotes", + command: "echo \"hello world\"", + allowedCommands: []string{"echo", "cat", "grep"}, + want: true, + wantErr: false, + }, + { + name: "command with path", + command: "/bin/ls -la", + allowedCommands: []string{"/bin/ls", "cat", "grep"}, + want: true, + wantErr: false, + }, + { + name: "empty command", + command: "", + allowedCommands: []string{"ls", "cat", "grep"}, + want: false, + wantErr: true, + errorMessage: "empty command", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := mcptools.IsCommandAllowed(tt.command, tt.allowedCommands) + if tt.wantErr { + require.Error(t, err) + if tt.errorMessage != "" { + require.Contains(t, err.Error(), tt.errorMessage) + } + } else { + require.NoError(t, err) + } + require.Equal(t, tt.want, got) + }) + } +} diff --git a/mcp/tools/tools_coder.go b/mcp/tools/tools_coder.go new file mode 100644 index 0000000000000..7a3d08e4bce47 --- /dev/null +++ b/mcp/tools/tools_coder.go @@ -0,0 +1,381 @@ +package mcptools + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + + "github.com/google/uuid" + "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_report_task", "arguments": {"summary": "I'm working on the login page.", "link": "https://github.com/coder/coder/pull/1234", "emoji": "🔍", "done": false}}} +func handleCoderReportTask(deps ToolDeps) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + + args := request.Params.Arguments + + summary, ok := args["summary"].(string) + if !ok { + return nil, xerrors.New("summary is required") + } + + link, ok := args["link"].(string) + if !ok { + return nil, xerrors.New("link is required") + } + + emoji, ok := args["emoji"].(string) + if !ok { + return nil, xerrors.New("emoji is required") + } + + done, ok := args["done"].(bool) + if !ok { + return nil, xerrors.New("done is required") + } + + // TODO: Waiting on support for tasks. + deps.Logger.Info(ctx, "report task tool called", slog.F("summary", summary), slog.F("link", link), slog.F("done", done), slog.F("emoji", emoji)) + /* + err := sdk.PostTask(ctx, agentsdk.PostTaskRequest{ + Reporter: "claude", + Summary: summary, + URL: link, + Completion: done, + Icon: emoji, + }) + if err != nil { + return nil, err + } + */ + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent("Thanks for reporting!"), + }, + }, nil + } +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_whoami", "arguments": {}}} +func handleCoderWhoami(deps ToolDeps) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + me, err := deps.Client.User(ctx, codersdk.Me) + if err != nil { + return nil, xerrors.Errorf("Failed to fetch the current user: %s", err.Error()) + } + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(me); err != nil { + return nil, xerrors.Errorf("Failed to encode the current user: %s", err.Error()) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(strings.TrimSpace(buf.String())), + }, + }, nil + } +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_list_workspaces", "arguments": {"owner": "me", "offset": 0, "limit": 10}}} +func handleCoderListWorkspaces(deps ToolDeps) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + args := request.Params.Arguments + + owner, ok := args["owner"].(string) + if !ok { + owner = codersdk.Me + } + + offset, ok := args["offset"].(int) + if !ok || offset < 0 { + offset = 0 + } + limit, ok := args["limit"].(int) + if !ok || limit <= 0 { + limit = 10 + } + + workspaces, err := deps.Client.Workspaces(ctx, codersdk.WorkspaceFilter{ + Owner: owner, + Offset: offset, + Limit: limit, + }) + if err != nil { + return nil, xerrors.Errorf("failed to fetch workspaces: %w", err) + } + + // Encode it as JSON. TODO: It might be nicer for the agent to have a tabulated response. + data, err := json.Marshal(workspaces) + if err != nil { + return nil, xerrors.Errorf("failed to encode workspaces: %s", err.Error()) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(data)), + }, + }, nil + } +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_get_workspace", "arguments": {"workspace": "dev"}}} +func handleCoderGetWorkspace(deps ToolDeps) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + args := request.Params.Arguments + + wsArg, ok := args["workspace"].(string) + if !ok { + return nil, xerrors.New("workspace is required") + } + + workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, wsArg) + if err != nil { + return nil, xerrors.Errorf("failed to fetch workspace: %w", err) + } + + workspaceJSON, err := json.Marshal(workspace) + if err != nil { + return nil, xerrors.Errorf("failed to encode workspace: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(workspaceJSON)), + }, + }, nil + } +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_workspace_exec", "arguments": {"workspace": "dev", "command": "ps -ef"}}} +func handleCoderWorkspaceExec(deps ToolDeps) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + args := request.Params.Arguments + + wsArg, ok := args["workspace"].(string) + if !ok { + return nil, xerrors.New("workspace is required") + } + + command, ok := args["command"].(string) + if !ok { + return nil, xerrors.New("command is required") + } + + // Validate the command if allowed commands are specified + allowed, err := IsCommandAllowed(command, deps.AllowedExecCommands) + if err != nil { + return nil, err + } + if !allowed { + return nil, xerrors.Errorf("command not allowed: %s", command) + } + + // Attempt to fetch the workspace. We may get a UUID or a name, so try to + // handle both. + ws, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, wsArg) + if err != nil { + return nil, xerrors.Errorf("failed to fetch workspace: %w", err) + } + + // Ensure the workspace is started. + // Select the first agent of the workspace. + var agt *codersdk.WorkspaceAgent + for _, r := range ws.LatestBuild.Resources { + for _, a := range r.Agents { + if a.Status != codersdk.WorkspaceAgentConnected { + continue + } + agt = ptr.Ref(a) + break + } + } + if agt == nil { + return nil, xerrors.Errorf("no connected agents for workspace %s", ws.ID) + } + + conn, err := workspacesdk.New(deps.Client).AgentReconnectingPTY(ctx, workspacesdk.WorkspaceAgentReconnectingPTYOpts{ + AgentID: agt.ID, + Reconnect: uuid.New(), + Width: 80, + Height: 24, + Command: command, + BackendType: "buffered", // the screen backend is annoying to use here. + }) + if err != nil { + return nil, xerrors.Errorf("failed to open reconnecting PTY: %w", err) + } + defer conn.Close() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, conn); err != nil { + // EOF is expected when the connection is closed. + // We can ignore this error. + if !errors.Is(err, io.EOF) { + return nil, xerrors.Errorf("failed to read from reconnecting PTY: %w", err) + } + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(strings.TrimSpace(buf.String())), + }, + }, nil + } +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_list_templates", "arguments": {}}} +func handleCoderListTemplates(deps ToolDeps) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + templates, err := deps.Client.Templates(ctx, codersdk.TemplateFilter{}) + if err != nil { + return nil, xerrors.Errorf("failed to fetch templates: %w", err) + } + + templateJSON, err := json.Marshal(templates) + if err != nil { + return nil, xerrors.Errorf("failed to encode templates: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(templateJSON)), + }, + }, nil + } +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_start_workspace", "arguments": {"workspace": "dev"}}} +func handleCoderStartWorkspace(deps ToolDeps) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + + args := request.Params.Arguments + + wsArg, ok := args["workspace"].(string) + if !ok { + return nil, xerrors.New("workspace is required") + } + + workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, wsArg) + if err != nil { + return nil, xerrors.Errorf("failed to fetch workspace: %w", err) + } + + switch workspace.LatestBuild.Status { + case codersdk.WorkspaceStatusPending, codersdk.WorkspaceStatusStarting, codersdk.WorkspaceStatusRunning, codersdk.WorkspaceStatusCanceling: + return nil, xerrors.Errorf("workspace is %s", workspace.LatestBuild.Status) + } + + wb, err := deps.Client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStart, + }) + if err != nil { + return nil, xerrors.Errorf("failed to start workspace: %w", err) + } + + resp := map[string]any{"status": wb.Status, "transition": wb.Transition} + respJSON, err := json.Marshal(resp) + if err != nil { + return nil, xerrors.Errorf("failed to encode workspace build: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(respJSON)), + }, + }, nil + } +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_stop_workspace", "arguments": {"workspace": "dev"}}} +func handleCoderStopWorkspace(deps ToolDeps) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + + args := request.Params.Arguments + + wsArg, ok := args["workspace"].(string) + if !ok { + return nil, xerrors.New("workspace is required") + } + + workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, wsArg) + if err != nil { + return nil, xerrors.Errorf("failed to fetch workspace: %w", err) + } + + switch workspace.LatestBuild.Status { + case codersdk.WorkspaceStatusPending, codersdk.WorkspaceStatusStopping, codersdk.WorkspaceStatusStopped, codersdk.WorkspaceStatusCanceling: + return nil, xerrors.Errorf("workspace is %s", workspace.LatestBuild.Status) + } + + wb, err := deps.Client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStop, + }) + if err != nil { + return nil, xerrors.Errorf("failed to stop workspace: %w", err) + } + + resp := map[string]any{"status": wb.Status, "transition": wb.Transition} + respJSON, err := json.Marshal(resp) + if err != nil { + return nil, xerrors.Errorf("failed to encode workspace build: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(respJSON)), + }, + }, nil + } +} + +func getWorkspaceByIDOrOwnerName(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) { + if wsid, err := uuid.Parse(identifier); err == nil { + return client.Workspace(ctx, wsid) + } + return client.WorkspaceByOwnerAndName(ctx, codersdk.Me, identifier, codersdk.WorkspaceOptions{}) +} diff --git a/mcp/tools/tools_coder_test.go b/mcp/tools/tools_coder_test.go new file mode 100644 index 0000000000000..ae2964432180c --- /dev/null +++ b/mcp/tools/tools_coder_test.go @@ -0,0 +1,380 @@ +package mcptools_test + +import ( + "context" + "encoding/json" + "io" + "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" + mcptools "github.com/coder/coder/v2/mcp/tools" + "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) { + 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) + mcptools.AllTools().Register(mcpSrv, mcptools.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) { + // 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. + expected := makeJSONRPCTextResponse(t, randString) + actual := pty.ReadLine(ctx) + testutil.RequireJSONEq(t, expected, actual) + }) + + t.Run("coder_stop_workspace", 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_stop_workspace tool is called + ctr := makeJSONRPCRequest(t, "tools/call", "coder_stop_workspace", map[string]any{ + "workspace": stopWs.Workspace.ID.String(), + }) + + 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) + }) + + // NOTE: this test runs after the list_workspaces tool is called. + t.Run("tool_and_command_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"} + allowedCommands := []string{"echo", "ls"} + restrictedMCPSrv, closeRestrictedSrv := startTestMCPServer(ctx, t, restrictedPty.Input(), restrictedPty.Output()) + t.Cleanup(func() { + _ = closeRestrictedSrv() + }) + mcptools.AllTools(). + WithOnlyAllowed(allowedTools...). + Register(restrictedMCPSrv, mcptools.ToolDeps{ + Client: memberClient, + Logger: &logger, + AllowedExecCommands: allowedCommands, + }) + + // 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") + + // When: an allowed exec command is called + randString := testutil.GetRandomName(t) + allowedCmd := makeJSONRPCRequest(t, "tools/call", "coder_workspace_exec", map[string]any{ + "workspace": r.Workspace.ID.String(), + "command": "echo " + randString, + }) + + // Then: the response is the output of the command. + restrictedPty.WriteLine(allowedCmd) + _ = restrictedPty.ReadLine(ctx) // skip the echo + actual := restrictedPty.ReadLine(ctx) + require.Contains(t, actual, randString) + + // When: a disallowed exec command is called + disallowedCmd := makeJSONRPCRequest(t, "tools/call", "coder_workspace_exec", map[string]any{ + "workspace": r.Workspace.ID.String(), + "command": "evil --hax", + }) + + // Then: the response is an error indicating the command is not allowed. + restrictedPty.WriteLine(disallowedCmd) + _ = restrictedPty.ReadLine(ctx) // skip the echo + errorResponse := restrictedPty.ReadLine(ctx) + require.Contains(t, errorResponse, `command \"evil\" is not allowed`) + }) + + t.Run("coder_start_workspace", 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_start_workspace tool is called + ctr := makeJSONRPCRequest(t, "tools/call", "coder_start_workspace", map[string]any{ + "workspace": stopWs.Workspace.ID.String(), + }) + + 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 + } +} diff --git a/mcp/tools/tools_registry.go b/mcp/tools/tools_registry.go new file mode 100644 index 0000000000000..d44aa113ea975 --- /dev/null +++ b/mcp/tools/tools_registry.go @@ -0,0 +1,139 @@ +package mcptools + +import ( + "slices" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "cdr.dev/slog" + "github.com/coder/coder/v2/codersdk" +) + +// allTools is the list of all available tools. When adding a new tool, +// make sure to update this list. +var allTools = ToolRegistry{ + { + Tool: mcp.NewTool("coder_report_task", + mcp.WithDescription(`Report progress on a task.`), + mcp.WithString("summary", mcp.Description(`A summary of your progress on a task. + +Good Summaries: +- "Taking a look at the login page..." +- "Found a bug! Fixing it now..." +- "Investigating the GitHub Issue..."`), mcp.Required()), + mcp.WithString("link", mcp.Description(`A relevant link to your work. e.g. GitHub issue link, pull request link, etc.`), mcp.Required()), + mcp.WithString("emoji", mcp.Description(`A relevant emoji to your work.`), mcp.Required()), + mcp.WithBoolean("done", mcp.Description(`Whether the task the user requested is complete.`), mcp.Required()), + ), + MakeHandler: handleCoderReportTask, + }, + { + Tool: mcp.NewTool("coder_whoami", + mcp.WithDescription(`Get information about the currently logged-in Coder user.`), + ), + MakeHandler: handleCoderWhoami, + }, + { + Tool: mcp.NewTool("coder_list_templates", + mcp.WithDescription(`List all templates on a given Coder deployment.`), + ), + MakeHandler: handleCoderListTemplates, + }, + { + Tool: mcp.NewTool("coder_list_workspaces", + mcp.WithDescription(`List workspaces on a given Coder deployment owned by the current user.`), + mcp.WithString(`owner`, mcp.Description(`The owner of the workspaces to list. Defaults to the current user.`), mcp.DefaultString(codersdk.Me)), + mcp.WithNumber(`offset`, mcp.Description(`The offset to start listing workspaces from. Defaults to 0.`), mcp.DefaultNumber(0)), + mcp.WithNumber(`limit`, mcp.Description(`The maximum number of workspaces to list. Defaults to 10.`), mcp.DefaultNumber(10)), + ), + MakeHandler: handleCoderListWorkspaces, + }, + { + Tool: mcp.NewTool("coder_get_workspace", + mcp.WithDescription(`Get information about a workspace on a given Coder deployment.`), + mcp.WithString("workspace", mcp.Description(`The workspace ID or name to get.`), mcp.Required()), + ), + MakeHandler: handleCoderGetWorkspace, + }, + { + Tool: mcp.NewTool("coder_workspace_exec", + mcp.WithDescription(`Execute a command in a remote workspace on a given Coder deployment.`), + mcp.WithString("workspace", mcp.Description(`The workspace ID or name in which to execute the command in. The workspace must be running.`), mcp.Required()), + mcp.WithString("command", mcp.Description(`The command to execute. Changing the working directory is not currently supported, so you may need to preface the command with 'cd /some/path && '.`), mcp.Required()), + ), + MakeHandler: handleCoderWorkspaceExec, + }, + { + Tool: mcp.NewTool("coder_start_workspace", + mcp.WithDescription(`Start a workspace on a given Coder deployment.`), + mcp.WithString("workspace", mcp.Description(`The workspace ID or name to start.`), mcp.Required()), + ), + MakeHandler: handleCoderStartWorkspace, + }, + { + Tool: mcp.NewTool("coder_stop_workspace", + mcp.WithDescription(`Stop a workspace on a given Coder deployment.`), + mcp.WithString("workspace", mcp.Description(`The workspace ID or name to stop.`), mcp.Required()), + ), + MakeHandler: handleCoderStopWorkspace, + }, +} + +// ToolAdder interface for adding tools to a server +type ToolAdder interface { + AddTool(tool mcp.Tool, handler server.ToolHandlerFunc) +} + +// Ensure that MCPServer implements ToolAdder +var _ ToolAdder = (*server.MCPServer)(nil) + +// ToolDeps contains all dependencies needed by tool handlers +type ToolDeps struct { + Client *codersdk.Client + Logger *slog.Logger + AllowedExecCommands []string +} + +// ToolHandler associates a tool with its handler creation function +type ToolHandler struct { + Tool mcp.Tool + MakeHandler func(ToolDeps) server.ToolHandlerFunc +} + +// ToolRegistry is a map of available tools with their handler creation +// functions +type ToolRegistry []ToolHandler + +// WithOnlyAllowed returns a new ToolRegistry containing only the tools +// specified in the allowed list. +func (r ToolRegistry) WithOnlyAllowed(allowed ...string) ToolRegistry { + if len(allowed) == 0 { + return []ToolHandler{} + } + + filtered := make(ToolRegistry, 0, len(r)) + + // The overhead of a map lookup is likely higher than a linear scan + // for a small number of tools. + for _, entry := range r { + if slices.Contains(allowed, entry.Tool.Name) { + filtered = append(filtered, entry) + } + } + return filtered +} + +// Register registers all tools in the registry with the given tool adder +// and dependencies. +func (r ToolRegistry) Register(ta ToolAdder, deps ToolDeps) { + for _, entry := range r { + ta.AddTool(entry.Tool, entry.MakeHandler(deps)) + } +} + +// AllTools returns all available tools. +func AllTools() ToolRegistry { + // return a copy of allTools to avoid mutating the original + return slices.Clone(allTools) +} diff --git a/testutil/json.go b/testutil/json.go new file mode 100644 index 0000000000000..006617d1ca030 --- /dev/null +++ b/testutil/json.go @@ -0,0 +1,27 @@ +package testutil + +import ( + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// RequireJSONEq is like assert.RequireJSONEq, but it's actually readable. +// Note that this calls t.Fatalf under the hood, so it should never +// be called in a goroutine. +func RequireJSONEq(t *testing.T, expected, actual string) { + t.Helper() + + var expectedJSON, actualJSON any + if err := json.Unmarshal([]byte(expected), &expectedJSON); err != nil { + t.Fatalf("failed to unmarshal expected JSON: %s", err) + } + if err := json.Unmarshal([]byte(actual), &actualJSON); err != nil { + t.Fatalf("failed to unmarshal actual JSON: %s", err) + } + + if diff := cmp.Diff(expectedJSON, actualJSON); diff != "" { + t.Fatalf("JSON diff (-want +got):\n%s", diff) + } +} From 7759c86dd529d02bc43effb28c169ba76ed08f98 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 28 Mar 2025 11:44:40 +0000 Subject: [PATCH 02/20] skip exp mcp test on non-linux --- cli/exp_mcp_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go index 673339b8d2efe..23d02a2388bca 100644 --- a/cli/exp_mcp_test.go +++ b/cli/exp_mcp_test.go @@ -3,6 +3,7 @@ package cli_test import ( "context" "encoding/json" + "runtime" "slices" "testing" @@ -18,6 +19,11 @@ import ( func TestExpMcp(t *testing.T) { t.Parallel() + // Reading to / writing from the PTY is flaky on non-linux systems. + if runtime.GOOS != "linux" { + t.Skip("skipping on non-linux") + } + t.Run("AllowedTools", func(t *testing.T) { t.Parallel() From 2f313221cdb857c8f9ca19871ed975d0ec7bebab Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 28 Mar 2025 11:57:55 +0000 Subject: [PATCH 03/20] improve tool descriptions --- mcp/tools/tools_registry.go | 169 +++++++++++++++++++++++++++++++----- 1 file changed, 147 insertions(+), 22 deletions(-) diff --git a/mcp/tools/tools_registry.go b/mcp/tools/tools_registry.go index d44aa113ea975..5dba8305cb7bc 100644 --- a/mcp/tools/tools_registry.go +++ b/mcp/tools/tools_registry.go @@ -15,66 +15,191 @@ import ( var allTools = ToolRegistry{ { Tool: mcp.NewTool("coder_report_task", - mcp.WithDescription(`Report progress on a task.`), - mcp.WithString("summary", mcp.Description(`A summary of your progress on a task. - + mcp.WithDescription(`Report progress on a user task in Coder. +Use this tool to keep the user informed about your progress with their request. +For long-running operations, call this periodically to provide status updates. +This is especially useful when performing multi-step operations like workspace creation or deployment.`), + mcp.WithString("summary", mcp.Description(`A concise summary of your current progress on the task. + Good Summaries: - "Taking a look at the login page..." - "Found a bug! Fixing it now..." -- "Investigating the GitHub Issue..."`), mcp.Required()), - mcp.WithString("link", mcp.Description(`A relevant link to your work. e.g. GitHub issue link, pull request link, etc.`), mcp.Required()), - mcp.WithString("emoji", mcp.Description(`A relevant emoji to your work.`), mcp.Required()), - mcp.WithBoolean("done", mcp.Description(`Whether the task the user requested is complete.`), mcp.Required()), +- "Investigating the GitHub Issue..." +- "Waiting for workspace to start (1/3 resources ready)" +- "Downloading template files from repository"`), mcp.Required()), + mcp.WithString("link", mcp.Description(`A relevant URL related to your work, such as: +- GitHub issue link +- Pull request URL +- Documentation reference +- Workspace URL +Use complete URLs (including https://) when possible.`), mcp.Required()), + mcp.WithString("emoji", mcp.Description(`A relevant emoji that visually represents the current status: +- 🔍 for investigating/searching +- 🚀 for deploying/starting +- 🐛 for debugging +- ✅ for completion +- ⏳ for waiting +Choose an emoji that helps the user understand the current phase at a glance.`), mcp.Required()), + mcp.WithBoolean("done", mcp.Description(`Whether the overall task the user requested is complete. +Set to true only when the entire requested operation is finished successfully. +For multi-step processes, use false until all steps are complete.`), mcp.Required()), ), MakeHandler: handleCoderReportTask, }, { Tool: mcp.NewTool("coder_whoami", - mcp.WithDescription(`Get information about the currently logged-in Coder user.`), + mcp.WithDescription(`Get information about the currently logged-in Coder user. +Returns JSON with the user's profile including fields: id, username, email, created_at, status, roles, etc. +Use this to identify the current user context before performing workspace operations. +This tool is useful for verifying permissions and checking the user's identity. + +Common errors: +- Authentication failure: The session may have expired +- Server unavailable: The Coder deployment may be unreachable`), ), MakeHandler: handleCoderWhoami, }, { Tool: mcp.NewTool("coder_list_templates", - mcp.WithDescription(`List all templates on a given Coder deployment.`), + mcp.WithDescription(`List all templates available on the Coder deployment. +Returns JSON with detailed information about each template, including: +- Template name, ID, and description +- Creation/modification timestamps +- Version information +- Associated organization + +Use this tool to discover available templates before creating workspaces. +Templates define the infrastructure and configuration for workspaces. + +Common errors: +- Authentication failure: Check user permissions +- No templates available: The deployment may not have any templates configured`), ), MakeHandler: handleCoderListTemplates, }, { Tool: mcp.NewTool("coder_list_workspaces", - mcp.WithDescription(`List workspaces on a given Coder deployment owned by the current user.`), - mcp.WithString(`owner`, mcp.Description(`The owner of the workspaces to list. Defaults to the current user.`), mcp.DefaultString(codersdk.Me)), - mcp.WithNumber(`offset`, mcp.Description(`The offset to start listing workspaces from. Defaults to 0.`), mcp.DefaultNumber(0)), - mcp.WithNumber(`limit`, mcp.Description(`The maximum number of workspaces to list. Defaults to 10.`), mcp.DefaultNumber(10)), + mcp.WithDescription(`List workspaces available on the Coder deployment. +Returns JSON with workspace metadata including status, resources, and configurations. +Use this before other workspace operations to find valid workspace names/IDs. +Results are paginated - use offset and limit parameters for large deployments. + +Common errors: +- Authentication failure: Check user permissions +- Invalid owner parameter: Ensure the owner exists`), + mcp.WithString(`owner`, mcp.Description(`The username of the workspace owner to filter by. +Defaults to "me" which represents the currently authenticated user. +Use this to view workspaces belonging to other users (requires appropriate permissions). +Special value: "me" - List workspaces owned by the authenticated user.`), mcp.DefaultString(codersdk.Me)), + mcp.WithNumber(`offset`, mcp.Description(`Pagination offset - the starting index for listing workspaces. +Used with the 'limit' parameter to implement pagination. +For example, to get the second page of results with 10 items per page, use offset=10. +Defaults to 0 (first page).`), mcp.DefaultNumber(0)), + mcp.WithNumber(`limit`, mcp.Description(`Maximum number of workspaces to return in a single request. +Used with the 'offset' parameter to implement pagination. +Higher values return more results but may increase response time. +Valid range: 1-100. Defaults to 10.`), mcp.DefaultNumber(10)), ), MakeHandler: handleCoderListWorkspaces, }, { Tool: mcp.NewTool("coder_get_workspace", - mcp.WithDescription(`Get information about a workspace on a given Coder deployment.`), - mcp.WithString("workspace", mcp.Description(`The workspace ID or name to get.`), mcp.Required()), + mcp.WithDescription(`Get detailed information about a specific Coder workspace. +Returns comprehensive JSON with the workspace's configuration, status, and resources. +Use this to check workspace status before performing operations like exec or start/stop. +The response includes the latest build status, agent connectivity, and resource details. + +Common errors: +- Workspace not found: Check the workspace name or ID +- Permission denied: The user may not have access to this workspace`), + mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to retrieve. +Can be specified as either: +- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" +- Workspace name: e.g., "dev", "python-project" +Use coder_list_workspaces first if you're not sure about available workspace names.`), mcp.Required()), ), MakeHandler: handleCoderGetWorkspace, }, { Tool: mcp.NewTool("coder_workspace_exec", - mcp.WithDescription(`Execute a command in a remote workspace on a given Coder deployment.`), - mcp.WithString("workspace", mcp.Description(`The workspace ID or name in which to execute the command in. The workspace must be running.`), mcp.Required()), - mcp.WithString("command", mcp.Description(`The command to execute. Changing the working directory is not currently supported, so you may need to preface the command with 'cd /some/path && '.`), mcp.Required()), + mcp.WithDescription(`Execute a shell command in a remote Coder workspace. +Runs the specified command and returns the complete output (stdout/stderr). +Use this for file operations, running build commands, or checking workspace state. +The workspace must be running with a connected agent for this to succeed. + +Before using this tool: +1. Verify the workspace is running using coder_get_workspace +2. Start the workspace if needed using coder_start_workspace + +Common errors: +- Workspace not running: Start the workspace first +- Command not allowed: Check security restrictions +- Agent not connected: The workspace may still be starting up`), + mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name where the command will execute. +Can be specified as either: +- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" +- Workspace name: e.g., "dev", "python-project" +The workspace must be running with a connected agent. +Use coder_get_workspace first to check the workspace status.`), mcp.Required()), + mcp.WithString("command", mcp.Description(`The shell command to execute in the workspace. +Commands are executed in the default shell of the workspace. + +Examples: +- "ls -la" - List files with details +- "cd /path/to/directory && command" - Execute in specific directory +- "cat ~/.bashrc" - View a file's contents +- "python -m pip list" - List installed Python packages + +Note: Commands are subject to security restrictions and validation. +Very long-running commands may time out.`), mcp.Required()), ), MakeHandler: handleCoderWorkspaceExec, }, { Tool: mcp.NewTool("coder_start_workspace", - mcp.WithDescription(`Start a workspace on a given Coder deployment.`), - mcp.WithString("workspace", mcp.Description(`The workspace ID or name to start.`), mcp.Required()), + mcp.WithDescription(`Start a stopped Coder workspace. +Initiates the workspace build process to provision and start all resources. +Only works on workspaces that are currently stopped or failed. +Starting a workspace is an asynchronous operation - it may take several minutes to complete. + +After calling this tool: +1. Use coder_report_task to inform the user that the workspace is starting +2. Use coder_get_workspace periodically to check for completion + +Common errors: +- Workspace already running/starting: No action needed +- Quota limits exceeded: User may have reached resource limits +- Template error: The underlying template may have issues`), + mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to start. +Can be specified as either: +- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" +- Workspace name: e.g., "dev", "python-project" +The workspace must be in a stopped state to be started. +Use coder_get_workspace first to check the current workspace status.`), mcp.Required()), ), MakeHandler: handleCoderStartWorkspace, }, { Tool: mcp.NewTool("coder_stop_workspace", - mcp.WithDescription(`Stop a workspace on a given Coder deployment.`), - mcp.WithString("workspace", mcp.Description(`The workspace ID or name to stop.`), mcp.Required()), + mcp.WithDescription(`Stop a running Coder workspace. +Initiates the workspace termination process to shut down all resources. +Only works on workspaces that are currently running. +Stopping a workspace is an asynchronous operation - it may take several minutes to complete. + +After calling this tool: +1. Use coder_report_task to inform the user that the workspace is stopping +2. Use coder_get_workspace periodically to check for completion + +Common errors: +- Workspace already stopped/stopping: No action needed +- Cancellation failed: There may be issues with the underlying infrastructure +- User doesn't own workspace: Permission issues`), + mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to stop. +Can be specified as either: +- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" +- Workspace name: e.g., "dev", "python-project" +The workspace must be in a running state to be stopped. +Use coder_get_workspace first to check the current workspace status.`), mcp.Required()), ), MakeHandler: handleCoderStopWorkspace, }, From 41d0b35657410a65ab2ba018bbbe9e2af6c3fef3 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 28 Mar 2025 12:19:05 +0000 Subject: [PATCH 04/20] reduce test flakeihood --- mcp/tools/tools_coder_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mcp/tools/tools_coder_test.go b/mcp/tools/tools_coder_test.go index ae2964432180c..6832bcd2c0f15 100644 --- a/mcp/tools/tools_coder_test.go +++ b/mcp/tools/tools_coder_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "runtime" "testing" "github.com/mark3labs/mcp-go/mcp" @@ -25,6 +26,9 @@ import ( // 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) @@ -146,6 +150,9 @@ func TestCoderTools(t *testing.T) { }) 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(), From de2ba8b9da6ce7539b8e4409bfe779fae1bacdd2 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 28 Mar 2025 12:57:25 +0000 Subject: [PATCH 05/20] update mcp-go -> v0.17.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fabc53eeb8b10..56fddd78ce445 100644 --- a/go.mod +++ b/go.mod @@ -481,6 +481,6 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect ) -require github.com/mark3labs/mcp-go v0.15.0 +require github.com/mark3labs/mcp-go v0.17.0 require github.com/yosida95/uritemplate/v3 v3.0.2 // indirect diff --git a/go.sum b/go.sum index 1ec89d58ec9d2..70c46ff5266da 100644 --- a/go.sum +++ b/go.sum @@ -658,8 +658,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc= github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0= github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA= -github.com/mark3labs/mcp-go v0.15.0 h1:lViiC4dk6chJHZccezaTzZLMOQVUXJDGNQPtzExr5NQ= -github.com/mark3labs/mcp-go v0.15.0/go.mod h1:xBB350hekQsJAK7gJAii8bcEoWemboLm2mRm5/+KBaU= +github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930= +github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= From 5d1e9e700c48f04be556923cf76524d5ac0709b9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 28 Mar 2025 14:59:25 +0000 Subject: [PATCH 06/20] remove exec command filtering --- cli/exp_mcp.go | 25 ++------- go.mod | 2 +- mcp/mcp.go | 23 +++----- mcp/tools/command_validator.go | 46 ---------------- mcp/tools/command_validator_test.go | 82 ----------------------------- mcp/tools/tools_coder.go | 9 ---- mcp/tools/tools_coder_test.go | 33 ++---------- mcp/tools/tools_registry.go | 8 ++- 8 files changed, 19 insertions(+), 209 deletions(-) delete mode 100644 mcp/tools/command_validator.go delete mode 100644 mcp/tools/command_validator_test.go diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index 0a1c529932a9b..d2cf2e69a4ee5 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -14,15 +14,14 @@ import ( func (r *RootCmd) mcpCommand() *serpent.Command { var ( - client = new(codersdk.Client) - instructions string - allowedTools []string - allowedExecCommands []string + client = new(codersdk.Client) + instructions string + allowedTools []string ) return &serpent.Command{ Use: "mcp", Handler: func(inv *serpent.Invocation) error { - return mcpHandler(inv, client, instructions, allowedTools, allowedExecCommands) + return mcpHandler(inv, client, instructions, allowedTools) }, Short: "Start an MCP server that can be used to interact with a Coder depoyment.", Middleware: serpent.Chain( @@ -41,17 +40,11 @@ func (r *RootCmd) mcpCommand() *serpent.Command { Flag: "allowed-tools", Value: serpent.StringArrayOf(&allowedTools), }, - { - Name: "allowed-exec-commands", - Description: "Comma-separated list of allowed commands for workspace execution. If not specified, all commands are allowed.", - Flag: "allowed-exec-commands", - Value: serpent.StringArrayOf(&allowedExecCommands), - }, }, } } -func mcpHandler(inv *serpent.Invocation, client *codersdk.Client, instructions string, allowedTools []string, allowedExecCommands []string) error { +func mcpHandler(inv *serpent.Invocation, client *codersdk.Client, instructions string, allowedTools []string) error { ctx, cancel := context.WithCancel(inv.Context()) defer cancel() @@ -71,9 +64,6 @@ func mcpHandler(inv *serpent.Invocation, client *codersdk.Client, instructions s if len(allowedTools) > 0 { cliui.Infof(inv.Stderr, "Allowed Tools : %v", allowedTools) } - if len(allowedExecCommands) > 0 { - cliui.Infof(inv.Stderr, "Allowed Exec Commands : %v", allowedExecCommands) - } cliui.Infof(inv.Stderr, "Press Ctrl+C to stop the server") // Capture the original stdin, stdout, and stderr. @@ -98,11 +88,6 @@ func mcpHandler(inv *serpent.Invocation, client *codersdk.Client, instructions s options = append(options, codermcp.WithAllowedTools(allowedTools)) } - // Add allowed exec commands option if specified - if len(allowedExecCommands) > 0 { - options = append(options, codermcp.WithAllowedExecCommands(allowedExecCommands)) - } - closer := codermcp.New(ctx, client, options...) <-ctx.Done() diff --git a/go.mod b/go.mod index 56fddd78ce445..3ecb96a3e14f6 100644 --- a/go.mod +++ b/go.mod @@ -320,7 +320,7 @@ require ( github.com/google/nftables v0.2.0 // indirect github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/gorilla/css v1.0.1 // indirect diff --git a/mcp/mcp.go b/mcp/mcp.go index d8bd8e937e26c..560a18930efae 100644 --- a/mcp/mcp.go +++ b/mcp/mcp.go @@ -17,12 +17,11 @@ import ( ) type mcpOptions struct { - in io.Reader - out io.Writer - instructions string - logger *slog.Logger - allowedTools []string - allowedExecCommands []string + in io.Reader + out io.Writer + instructions string + logger *slog.Logger + allowedTools []string } // Option is a function that configures the MCP server. @@ -63,13 +62,6 @@ func WithAllowedTools(tools []string) Option { } } -// WithAllowedExecCommands sets the allowed commands for workspace execution. -func WithAllowedExecCommands(commands []string) Option { - return func(o *mcpOptions) { - o.allowedExecCommands = commands - } -} - // New creates a new MCP server with the given client and options. func New(ctx context.Context, client *codersdk.Client, opts ...Option) io.Closer { options := &mcpOptions{ @@ -96,9 +88,8 @@ func New(ctx context.Context, client *codersdk.Client, opts ...Option) io.Closer reg = reg.WithOnlyAllowed(options.allowedTools...) } reg.Register(mcpSrv, mcptools.ToolDeps{ - Client: client, - Logger: &logger, - AllowedExecCommands: options.allowedExecCommands, + Client: client, + Logger: &logger, }) srv := server.NewStdioServer(mcpSrv) diff --git a/mcp/tools/command_validator.go b/mcp/tools/command_validator.go deleted file mode 100644 index dfbde59b8be3f..0000000000000 --- a/mcp/tools/command_validator.go +++ /dev/null @@ -1,46 +0,0 @@ -package mcptools - -import ( - "strings" - - "github.com/google/shlex" - "golang.org/x/xerrors" -) - -// IsCommandAllowed checks if a command is in the allowed list. -// It parses the command using shlex to correctly handle quoted arguments -// and only checks the executable name (first part of the command). -// -// Based on benchmarks, a simple linear search performs better than -// a map-based approach for the typical number of allowed commands, -// so we're sticking with the simple approach. -func IsCommandAllowed(command string, allowedCommands []string) (bool, error) { - if len(allowedCommands) == 0 { - // If no allowed commands are specified, all commands are allowed - return true, nil - } - - // Parse the command to extract the executable name - parts, err := shlex.Split(command) - if err != nil { - return false, xerrors.Errorf("failed to parse command: %w", err) - } - - if len(parts) == 0 { - return false, xerrors.New("empty command") - } - - // The first part is the executable name - executable := parts[0] - - // Check if the executable is in the allowed list - for _, allowed := range allowedCommands { - if allowed == executable { - return true, nil - } - } - - // Build a helpful error message - return false, xerrors.Errorf("command %q is not allowed. Allowed commands: %s", - executable, strings.Join(allowedCommands, ", ")) -} diff --git a/mcp/tools/command_validator_test.go b/mcp/tools/command_validator_test.go deleted file mode 100644 index 902aa320fce03..0000000000000 --- a/mcp/tools/command_validator_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package mcptools_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - - mcptools "github.com/coder/coder/v2/mcp/tools" -) - -func TestIsCommandAllowed(t *testing.T) { - t.Parallel() - tests := []struct { - name string - command string - allowedCommands []string - want bool - wantErr bool - errorMessage string - }{ - { - name: "empty allowed commands allows all", - command: "ls -la", - allowedCommands: []string{}, - want: true, - wantErr: false, - }, - { - name: "allowed command", - command: "ls -la", - allowedCommands: []string{"ls", "cat", "grep"}, - want: true, - wantErr: false, - }, - { - name: "disallowed command", - command: "rm -rf /", - allowedCommands: []string{"ls", "cat", "grep"}, - want: false, - wantErr: true, - errorMessage: "not allowed", - }, - { - name: "command with quotes", - command: "echo \"hello world\"", - allowedCommands: []string{"echo", "cat", "grep"}, - want: true, - wantErr: false, - }, - { - name: "command with path", - command: "/bin/ls -la", - allowedCommands: []string{"/bin/ls", "cat", "grep"}, - want: true, - wantErr: false, - }, - { - name: "empty command", - command: "", - allowedCommands: []string{"ls", "cat", "grep"}, - want: false, - wantErr: true, - errorMessage: "empty command", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got, err := mcptools.IsCommandAllowed(tt.command, tt.allowedCommands) - if tt.wantErr { - require.Error(t, err) - if tt.errorMessage != "" { - require.Contains(t, err.Error(), tt.errorMessage) - } - } else { - require.NoError(t, err) - } - require.Equal(t, tt.want, got) - }) - } -} diff --git a/mcp/tools/tools_coder.go b/mcp/tools/tools_coder.go index 7a3d08e4bce47..97cea9c60e061 100644 --- a/mcp/tools/tools_coder.go +++ b/mcp/tools/tools_coder.go @@ -194,15 +194,6 @@ func handleCoderWorkspaceExec(deps ToolDeps) mcpserver.ToolHandlerFunc { return nil, xerrors.New("command is required") } - // Validate the command if allowed commands are specified - allowed, err := IsCommandAllowed(command, deps.AllowedExecCommands) - if err != nil { - return nil, err - } - if !allowed { - return nil, xerrors.Errorf("command not allowed: %s", command) - } - // Attempt to fetch the workspace. We may get a UUID or a name, so try to // handle both. ws, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, wsArg) diff --git a/mcp/tools/tools_coder_test.go b/mcp/tools/tools_coder_test.go index 6832bcd2c0f15..ba2a90a0cbe8e 100644 --- a/mcp/tools/tools_coder_test.go +++ b/mcp/tools/tools_coder_test.go @@ -217,14 +217,13 @@ func TestCoderTools(t *testing.T) { }) // NOTE: this test runs after the list_workspaces tool is called. - t.Run("tool_and_command_restrictions", func(t *testing.T) { + 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"} - allowedCommands := []string{"echo", "ls"} restrictedMCPSrv, closeRestrictedSrv := startTestMCPServer(ctx, t, restrictedPty.Input(), restrictedPty.Output()) t.Cleanup(func() { _ = closeRestrictedSrv() @@ -232,9 +231,8 @@ func TestCoderTools(t *testing.T) { mcptools.AllTools(). WithOnlyAllowed(allowedTools...). Register(restrictedMCPSrv, mcptools.ToolDeps{ - Client: memberClient, - Logger: &logger, - AllowedExecCommands: allowedCommands, + Client: memberClient, + Logger: &logger, }) // When: the tools/list command is called @@ -256,31 +254,6 @@ func TestCoderTools(t *testing.T) { disallowedToolResponse := restrictedPty.ReadLine(ctx) require.Contains(t, disallowedToolResponse, "error") require.Contains(t, disallowedToolResponse, "not found") - - // When: an allowed exec command is called - randString := testutil.GetRandomName(t) - allowedCmd := makeJSONRPCRequest(t, "tools/call", "coder_workspace_exec", map[string]any{ - "workspace": r.Workspace.ID.String(), - "command": "echo " + randString, - }) - - // Then: the response is the output of the command. - restrictedPty.WriteLine(allowedCmd) - _ = restrictedPty.ReadLine(ctx) // skip the echo - actual := restrictedPty.ReadLine(ctx) - require.Contains(t, actual, randString) - - // When: a disallowed exec command is called - disallowedCmd := makeJSONRPCRequest(t, "tools/call", "coder_workspace_exec", map[string]any{ - "workspace": r.Workspace.ID.String(), - "command": "evil --hax", - }) - - // Then: the response is an error indicating the command is not allowed. - restrictedPty.WriteLine(disallowedCmd) - _ = restrictedPty.ReadLine(ctx) // skip the echo - errorResponse := restrictedPty.ReadLine(ctx) - require.Contains(t, errorResponse, `command \"evil\" is not allowed`) }) t.Run("coder_start_workspace", func(t *testing.T) { diff --git a/mcp/tools/tools_registry.go b/mcp/tools/tools_registry.go index 5dba8305cb7bc..cfa98df2b9e92 100644 --- a/mcp/tools/tools_registry.go +++ b/mcp/tools/tools_registry.go @@ -150,8 +150,7 @@ Examples: - "cat ~/.bashrc" - View a file's contents - "python -m pip list" - List installed Python packages -Note: Commands are subject to security restrictions and validation. -Very long-running commands may time out.`), mcp.Required()), +Note: Very long-running commands may time out.`), mcp.Required()), ), MakeHandler: handleCoderWorkspaceExec, }, @@ -215,9 +214,8 @@ var _ ToolAdder = (*server.MCPServer)(nil) // ToolDeps contains all dependencies needed by tool handlers type ToolDeps struct { - Client *codersdk.Client - Logger *slog.Logger - AllowedExecCommands []string + Client *codersdk.Client + Logger *slog.Logger } // ToolHandler associates a tool with its handler creation function From 7897e67ab51ac53692a5de4275161b5097354d2b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 28 Mar 2025 15:14:39 +0000 Subject: [PATCH 07/20] merge stop and start command into one --- mcp/tools/tools_coder.go | 64 ++++++++--------------------------- mcp/tools/tools_coder_test.go | 52 ++++++++++++++-------------- mcp/tools/tools_registry.go | 46 ++++++++----------------- 3 files changed, 55 insertions(+), 107 deletions(-) diff --git a/mcp/tools/tools_coder.go b/mcp/tools/tools_coder.go index 97cea9c60e061..146d0c6b43fa8 100644 --- a/mcp/tools/tools_coder.go +++ b/mcp/tools/tools_coder.go @@ -273,8 +273,9 @@ func handleCoderListTemplates(deps ToolDeps) mcpserver.ToolHandlerFunc { } // Example payload: -// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_start_workspace", "arguments": {"workspace": "dev"}}} -func handleCoderStartWorkspace(deps ToolDeps) mcpserver.ToolHandlerFunc { +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": +// "coder_workspace_transition", "arguments": {"workspace": "dev", "transition": "stop"}}} +func handleCoderWorkspaceTransition(deps ToolDeps) mcpserver.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { if deps.Client == nil { return nil, xerrors.New("developer error: client is required") @@ -292,59 +293,22 @@ func handleCoderStartWorkspace(deps ToolDeps) mcpserver.ToolHandlerFunc { return nil, xerrors.Errorf("failed to fetch workspace: %w", err) } - switch workspace.LatestBuild.Status { - case codersdk.WorkspaceStatusPending, codersdk.WorkspaceStatusStarting, codersdk.WorkspaceStatusRunning, codersdk.WorkspaceStatusCanceling: - return nil, xerrors.Errorf("workspace is %s", workspace.LatestBuild.Status) - } - - wb, err := deps.Client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - Transition: codersdk.WorkspaceTransitionStart, - }) - if err != nil { - return nil, xerrors.Errorf("failed to start workspace: %w", err) - } - - resp := map[string]any{"status": wb.Status, "transition": wb.Transition} - respJSON, err := json.Marshal(resp) - if err != nil { - return nil, xerrors.Errorf("failed to encode workspace build: %w", err) - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(string(respJSON)), - }, - }, nil - } -} - -// Example payload: -// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_stop_workspace", "arguments": {"workspace": "dev"}}} -func handleCoderStopWorkspace(deps ToolDeps) mcpserver.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - if deps.Client == nil { - return nil, xerrors.New("developer error: client is required") - } - - args := request.Params.Arguments - - wsArg, ok := args["workspace"].(string) + transition, ok := args["transition"].(string) if !ok { - return nil, xerrors.New("workspace is required") + return nil, xerrors.New("transition is required") } - - workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, wsArg) - if err != nil { - return nil, xerrors.Errorf("failed to fetch workspace: %w", err) - } - - switch workspace.LatestBuild.Status { - case codersdk.WorkspaceStatusPending, codersdk.WorkspaceStatusStopping, codersdk.WorkspaceStatusStopped, codersdk.WorkspaceStatusCanceling: - return nil, xerrors.Errorf("workspace is %s", workspace.LatestBuild.Status) + wsTransition := codersdk.WorkspaceTransition(transition) + switch wsTransition { + case codersdk.WorkspaceTransitionStart: + case codersdk.WorkspaceTransitionStop: + default: + return nil, xerrors.New("invalid transition") } + // We're not going to check the workspace status here as it is checked on the + // server side. wb, err := deps.Client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - Transition: codersdk.WorkspaceTransitionStop, + Transition: wsTransition, }) if err != nil { return nil, xerrors.Errorf("failed to stop workspace: %w", err) diff --git a/mcp/tools/tools_coder_test.go b/mcp/tools/tools_coder_test.go index ba2a90a0cbe8e..24adde78a243d 100644 --- a/mcp/tools/tools_coder_test.go +++ b/mcp/tools/tools_coder_test.go @@ -195,27 +195,6 @@ func TestCoderTools(t *testing.T) { testutil.RequireJSONEq(t, expected, actual) }) - t.Run("coder_stop_workspace", 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_stop_workspace tool is called - ctr := makeJSONRPCRequest(t, "tools/call", "coder_stop_workspace", map[string]any{ - "workspace": stopWs.Workspace.ID.String(), - }) - - 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) - }) - // 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 @@ -256,7 +235,29 @@ func TestCoderTools(t *testing.T) { require.Contains(t, disallowedToolResponse, "not found") }) - t.Run("coder_start_workspace", func(t *testing.T) { + 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, @@ -265,9 +266,10 @@ func TestCoderTools(t *testing.T) { Transition: database.WorkspaceTransitionStop, }).Do() - // When: the coder_start_workspace tool is called - ctr := makeJSONRPCRequest(t, "tools/call", "coder_start_workspace", map[string]any{ - "workspace": stopWs.Workspace.ID.String(), + // 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) diff --git a/mcp/tools/tools_registry.go b/mcp/tools/tools_registry.go index cfa98df2b9e92..0cec7947961c2 100644 --- a/mcp/tools/tools_registry.go +++ b/mcp/tools/tools_registry.go @@ -155,52 +155,34 @@ Note: Very long-running commands may time out.`), mcp.Required()), MakeHandler: handleCoderWorkspaceExec, }, { - Tool: mcp.NewTool("coder_start_workspace", - mcp.WithDescription(`Start a stopped Coder workspace. -Initiates the workspace build process to provision and start all resources. -Only works on workspaces that are currently stopped or failed. -Starting a workspace is an asynchronous operation - it may take several minutes to complete. + Tool: mcp.NewTool("coder_workspace_transition", + mcp.WithDescription(`Start or stop a running Coder workspace. +If stopping, initiates the workspace stop transition. +Only works on workspaces that are currently running or failed. -After calling this tool: -1. Use coder_report_task to inform the user that the workspace is starting -2. Use coder_get_workspace periodically to check for completion +If starting, initiates the workspace start transition. +Only works on workspaces that are currently stopped or failed. -Common errors: -- Workspace already running/starting: No action needed -- Quota limits exceeded: User may have reached resource limits -- Template error: The underlying template may have issues`), - mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to start. -Can be specified as either: -- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" -- Workspace name: e.g., "dev", "python-project" -The workspace must be in a stopped state to be started. -Use coder_get_workspace first to check the current workspace status.`), mcp.Required()), - ), - MakeHandler: handleCoderStartWorkspace, - }, - { - Tool: mcp.NewTool("coder_stop_workspace", - mcp.WithDescription(`Stop a running Coder workspace. -Initiates the workspace termination process to shut down all resources. -Only works on workspaces that are currently running. -Stopping a workspace is an asynchronous operation - it may take several minutes to complete. +Stopping or starting a workspace is an asynchronous operation - it may take several minutes to complete. After calling this tool: -1. Use coder_report_task to inform the user that the workspace is stopping +1. Use coder_report_task to inform the user that the workspace is stopping or starting 2. Use coder_get_workspace periodically to check for completion Common errors: -- Workspace already stopped/stopping: No action needed +- Workspace already started/starting/stopped/stopping: No action needed - Cancellation failed: There may be issues with the underlying infrastructure - User doesn't own workspace: Permission issues`), - mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to stop. + mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to start or stop. Can be specified as either: - Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" - Workspace name: e.g., "dev", "python-project" -The workspace must be in a running state to be stopped. +The workspace must be in a running state to be stopped, or in a stopped or failed state to be started. Use coder_get_workspace first to check the current workspace status.`), mcp.Required()), + mcp.WithString("transition", mcp.Description(`The transition to apply to the workspace. +Can be either "start" or "stop".`)), ), - MakeHandler: handleCoderStopWorkspace, + MakeHandler: handleCoderWorkspaceTransition, }, } From 3d810c0a4eb4d12db5195891e89f600faab83bbd Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 28 Mar 2025 15:20:09 +0000 Subject: [PATCH 08/20] return timings in coder_workspace_exec --- mcp/tools/tools_coder.go | 18 +++++++++++++++++- mcp/tools/tools_coder_test.go | 3 +-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/mcp/tools/tools_coder.go b/mcp/tools/tools_coder.go index 146d0c6b43fa8..9b875ee80dd6c 100644 --- a/mcp/tools/tools_coder.go +++ b/mcp/tools/tools_coder.go @@ -7,6 +7,7 @@ import ( "errors" "io" "strings" + "time" "github.com/google/uuid" "github.com/mark3labs/mcp-go/mcp" @@ -217,6 +218,7 @@ func handleCoderWorkspaceExec(deps ToolDeps) mcpserver.ToolHandlerFunc { return nil, xerrors.Errorf("no connected agents for workspace %s", ws.ID) } + startedAt := time.Now() conn, err := workspacesdk.New(deps.Client).AgentReconnectingPTY(ctx, workspacesdk.WorkspaceAgentReconnectingPTYOpts{ AgentID: agt.ID, Reconnect: uuid.New(), @@ -229,6 +231,7 @@ func handleCoderWorkspaceExec(deps ToolDeps) mcpserver.ToolHandlerFunc { return nil, xerrors.Errorf("failed to open reconnecting PTY: %w", err) } defer conn.Close() + connectedAt := time.Now() var buf bytes.Buffer if _, err := io.Copy(&buf, conn); err != nil { @@ -238,10 +241,23 @@ func handleCoderWorkspaceExec(deps ToolDeps) mcpserver.ToolHandlerFunc { return nil, xerrors.Errorf("failed to read from reconnecting PTY: %w", err) } } + completedAt := time.Now() + connectionTime := connectedAt.Sub(startedAt) + executionTime := completedAt.Sub(connectedAt) + + resp := map[string]string{ + "connection_time": connectionTime.String(), + "execution_time": executionTime.String(), + "output": buf.String(), + } + respJSON, err := json.Marshal(resp) + if err != nil { + return nil, xerrors.Errorf("failed to encode workspace build: %w", err) + } return &mcp.CallToolResult{ Content: []mcp.Content{ - mcp.NewTextContent(strings.TrimSpace(buf.String())), + mcp.NewTextContent(string(respJSON)), }, }, nil } diff --git a/mcp/tools/tools_coder_test.go b/mcp/tools/tools_coder_test.go index 24adde78a243d..4527d24ee66d9 100644 --- a/mcp/tools/tools_coder_test.go +++ b/mcp/tools/tools_coder_test.go @@ -190,9 +190,8 @@ func TestCoderTools(t *testing.T) { _ = pty.ReadLine(ctx) // skip the echo // Then: the response is the output of the command. - expected := makeJSONRPCTextResponse(t, randString) actual := pty.ReadLine(ctx) - testutil.RequireJSONEq(t, expected, actual) + require.Contains(t, actual, randString) }) // NOTE: this test runs after the list_workspaces tool is called. From 035ab2c3d3e8faa4692123fc3e628ed832aeef3f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 28 Mar 2025 15:33:32 +0000 Subject: [PATCH 09/20] improve argument handling by abusing json.Marshal/Unmarshal --- mcp/tools/tools_coder.go | 135 +++++++++++++++++++-------------------- 1 file changed, 67 insertions(+), 68 deletions(-) diff --git a/mcp/tools/tools_coder.go b/mcp/tools/tools_coder.go index 9b875ee80dd6c..278a7331eb83e 100644 --- a/mcp/tools/tools_coder.go +++ b/mcp/tools/tools_coder.go @@ -20,6 +20,13 @@ import ( "github.com/coder/coder/v2/codersdk/workspacesdk" ) +type handleCoderReportTaskArgs struct { + Summary string `json:"summary"` + Link string `json:"link"` + Emoji string `json:"emoji"` + Done bool `json:"done"` +} + // Example payload: // {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_report_task", "arguments": {"summary": "I'm working on the login page.", "link": "https://github.com/coder/coder/pull/1234", "emoji": "🔍", "done": false}}} func handleCoderReportTask(deps ToolDeps) mcpserver.ToolHandlerFunc { @@ -28,30 +35,15 @@ func handleCoderReportTask(deps ToolDeps) mcpserver.ToolHandlerFunc { return nil, xerrors.New("developer error: client is required") } - args := request.Params.Arguments - - summary, ok := args["summary"].(string) - if !ok { - return nil, xerrors.New("summary is required") - } - - link, ok := args["link"].(string) - if !ok { - return nil, xerrors.New("link is required") - } - - emoji, ok := args["emoji"].(string) - if !ok { - return nil, xerrors.New("emoji is required") - } - - done, ok := args["done"].(bool) - if !ok { - return nil, xerrors.New("done is required") + // Convert the request parameters to a json.RawMessage so we can unmarshal + // them into the correct struct. + args, err := unmarshalArgs[handleCoderReportTaskArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) } // TODO: Waiting on support for tasks. - deps.Logger.Info(ctx, "report task tool called", slog.F("summary", summary), slog.F("link", link), slog.F("done", done), slog.F("emoji", emoji)) + deps.Logger.Info(ctx, "report task tool called", slog.F("summary", args.Summary), slog.F("link", args.Link), slog.F("done", args.Done), slog.F("emoji", args.Emoji)) /* err := sdk.PostTask(ctx, agentsdk.PostTaskRequest{ Reporter: "claude", @@ -98,6 +90,12 @@ func handleCoderWhoami(deps ToolDeps) mcpserver.ToolHandlerFunc { } } +type handleCoderListWorkspacesArgs struct { + Owner string `json:"owner"` + Offset int `json:"offset"` + Limit int `json:"limit"` +} + // Example payload: // {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_list_workspaces", "arguments": {"owner": "me", "offset": 0, "limit": 10}}} func handleCoderListWorkspaces(deps ToolDeps) mcpserver.ToolHandlerFunc { @@ -105,26 +103,15 @@ func handleCoderListWorkspaces(deps ToolDeps) mcpserver.ToolHandlerFunc { if deps.Client == nil { return nil, xerrors.New("developer error: client is required") } - args := request.Params.Arguments - - owner, ok := args["owner"].(string) - if !ok { - owner = codersdk.Me - } - - offset, ok := args["offset"].(int) - if !ok || offset < 0 { - offset = 0 - } - limit, ok := args["limit"].(int) - if !ok || limit <= 0 { - limit = 10 + args, err := unmarshalArgs[handleCoderListWorkspacesArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) } workspaces, err := deps.Client.Workspaces(ctx, codersdk.WorkspaceFilter{ - Owner: owner, - Offset: offset, - Limit: limit, + Owner: args.Owner, + Offset: args.Offset, + Limit: args.Limit, }) if err != nil { return nil, xerrors.Errorf("failed to fetch workspaces: %w", err) @@ -144,6 +131,10 @@ func handleCoderListWorkspaces(deps ToolDeps) mcpserver.ToolHandlerFunc { } } +type handleCoderGetWorkspaceArgs struct { + Workspace string `json:"workspace"` +} + // Example payload: // {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_get_workspace", "arguments": {"workspace": "dev"}}} func handleCoderGetWorkspace(deps ToolDeps) mcpserver.ToolHandlerFunc { @@ -151,14 +142,12 @@ func handleCoderGetWorkspace(deps ToolDeps) mcpserver.ToolHandlerFunc { if deps.Client == nil { return nil, xerrors.New("developer error: client is required") } - args := request.Params.Arguments - - wsArg, ok := args["workspace"].(string) - if !ok { - return nil, xerrors.New("workspace is required") + args, err := unmarshalArgs[handleCoderGetWorkspaceArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) } - workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, wsArg) + workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace) if err != nil { return nil, xerrors.Errorf("failed to fetch workspace: %w", err) } @@ -176,6 +165,11 @@ func handleCoderGetWorkspace(deps ToolDeps) mcpserver.ToolHandlerFunc { } } +type handleCoderWorkspaceExecArgs struct { + Workspace string `json:"workspace"` + Command string `json:"command"` +} + // Example payload: // {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_workspace_exec", "arguments": {"workspace": "dev", "command": "ps -ef"}}} func handleCoderWorkspaceExec(deps ToolDeps) mcpserver.ToolHandlerFunc { @@ -183,21 +177,14 @@ func handleCoderWorkspaceExec(deps ToolDeps) mcpserver.ToolHandlerFunc { if deps.Client == nil { return nil, xerrors.New("developer error: client is required") } - args := request.Params.Arguments - - wsArg, ok := args["workspace"].(string) - if !ok { - return nil, xerrors.New("workspace is required") - } - - command, ok := args["command"].(string) - if !ok { - return nil, xerrors.New("command is required") + args, err := unmarshalArgs[handleCoderWorkspaceExecArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) } // Attempt to fetch the workspace. We may get a UUID or a name, so try to // handle both. - ws, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, wsArg) + ws, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace) if err != nil { return nil, xerrors.Errorf("failed to fetch workspace: %w", err) } @@ -224,7 +211,7 @@ func handleCoderWorkspaceExec(deps ToolDeps) mcpserver.ToolHandlerFunc { Reconnect: uuid.New(), Width: 80, Height: 24, - Command: command, + Command: args.Command, BackendType: "buffered", // the screen backend is annoying to use here. }) if err != nil { @@ -288,6 +275,11 @@ func handleCoderListTemplates(deps ToolDeps) mcpserver.ToolHandlerFunc { } } +type handleCoderWorkspaceTransitionArgs struct { + Workspace string `json:"workspace"` + Transition string `json:"transition"` +} + // Example payload: // {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": // "coder_workspace_transition", "arguments": {"workspace": "dev", "transition": "stop"}}} @@ -296,24 +288,17 @@ func handleCoderWorkspaceTransition(deps ToolDeps) mcpserver.ToolHandlerFunc { if deps.Client == nil { return nil, xerrors.New("developer error: client is required") } - - args := request.Params.Arguments - - wsArg, ok := args["workspace"].(string) - if !ok { - return nil, xerrors.New("workspace is required") + args, err := unmarshalArgs[handleCoderWorkspaceTransitionArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) } - workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, wsArg) + workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace) if err != nil { return nil, xerrors.Errorf("failed to fetch workspace: %w", err) } - transition, ok := args["transition"].(string) - if !ok { - return nil, xerrors.New("transition is required") - } - wsTransition := codersdk.WorkspaceTransition(transition) + wsTransition := codersdk.WorkspaceTransition(args.Transition) switch wsTransition { case codersdk.WorkspaceTransitionStart: case codersdk.WorkspaceTransitionStop: @@ -350,3 +335,17 @@ func getWorkspaceByIDOrOwnerName(ctx context.Context, client *codersdk.Client, i } return client.WorkspaceByOwnerAndName(ctx, codersdk.Me, identifier, codersdk.WorkspaceOptions{}) } + +// unmarshalArgs is a helper function to convert the map[string]any we get from +// the MCP server into a typed struct. It does this by marshaling and unmarshalling +// the arguments. +func unmarshalArgs[T any](args map[string]interface{}) (t T, err error) { + argsJSON, err := json.Marshal(args) + if err != nil { + return t, xerrors.Errorf("failed to marshal arguments: %w", err) + } + if err := json.Unmarshal(argsJSON, &t); err != nil { + return t, xerrors.Errorf("failed to unmarshal arguments: %w", err) + } + return t, nil +} From 908655576b2fda7ebb42843d41b706b4375eb360 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 28 Mar 2025 15:34:35 +0000 Subject: [PATCH 10/20] typo --- cli/exp_mcp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index d2cf2e69a4ee5..2c6b9c841fe55 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -23,7 +23,7 @@ func (r *RootCmd) mcpCommand() *serpent.Command { Handler: func(inv *serpent.Invocation) error { return mcpHandler(inv, client, instructions, allowedTools) }, - Short: "Start an MCP server that can be used to interact with a Coder depoyment.", + Short: "Start an MCP server that can be used to interact with a Coder deployment.", Middleware: serpent.Chain( r.InitClient(client), ), From 86fdd92141f8179daac1ebe71647e79fee591443 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 31 Mar 2025 16:40:10 +0100 Subject: [PATCH 11/20] feat(cli): add in exp mcp configure commands from kyle/tasks --- cli/exp_mcp.go | 178 +++++++++++++++++- cli/exp_mcp_test.go | 6 +- .../TestProvisioners_Golden/list.golden | 2 +- cli/testdata/coder_provisioner_list.golden | 2 +- 4 files changed, 179 insertions(+), 9 deletions(-) diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index 2c6b9c841fe55..06a465824e4ef 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -2,7 +2,10 @@ package cli import ( "context" + "encoding/json" "errors" + "os" + "path/filepath" "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" @@ -13,17 +16,184 @@ import ( ) func (r *RootCmd) mcpCommand() *serpent.Command { + cmd := &serpent.Command{ + Use: "mcp", + Short: "Run the Coder MCP server and configure it to work with AI tools.", + Long: "The Coder MCP server allows you to automatically create workspaces with parameters.", + Children: []*serpent.Command{ + r.mcpConfigure(), + r.mcpServer(), + }, + } + return cmd +} + +func (r *RootCmd) mcpConfigure() *serpent.Command { + cmd := &serpent.Command{ + Use: "configure", + Short: "Automatically configure the MCP server.", + Children: []*serpent.Command{ + r.mcpConfigureClaudeDesktop(), + r.mcpConfigureClaudeCode(), + r.mcpConfigureCursor(), + }, + } + return cmd +} + +func (r *RootCmd) mcpConfigureClaudeDesktop() *serpent.Command { + cmd := &serpent.Command{ + Use: "claude-desktop", + Short: "Configure the Claude Desktop server.", + Handler: func(_ *serpent.Invocation) error { + configPath, err := os.UserConfigDir() + if err != nil { + return err + } + configPath = filepath.Join(configPath, "Claude") + err = os.MkdirAll(configPath, 0755) + if err != nil { + return err + } + configPath = filepath.Join(configPath, "claude_desktop_config.json") + _, err = os.Stat(configPath) + if err != nil { + if !os.IsNotExist(err) { + return err + } + } + contents := map[string]any{} + data, err := os.ReadFile(configPath) + if err != nil { + if !os.IsNotExist(err) { + return err + } + } else { + err = json.Unmarshal(data, &contents) + if err != nil { + return err + } + } + binPath, err := os.Executable() + if err != nil { + return err + } + contents["mcpServers"] = map[string]any{ + "coder": map[string]any{"command": binPath, "args": []string{"mcp", "server"}}, + } + data, err = json.MarshalIndent(contents, "", " ") + if err != nil { + return err + } + err = os.WriteFile(configPath, data, 0600) + if err != nil { + return err + } + return nil + }, + } + return cmd +} + +func (_ *RootCmd) mcpConfigureClaudeCode() *serpent.Command { + cmd := &serpent.Command{ + Use: "claude-code", + Short: "Configure the Claude Code server.", + Handler: func(_ *serpent.Invocation) error { + return nil + }, + } + return cmd +} + +func (_ *RootCmd) mcpConfigureCursor() *serpent.Command { + var project bool + cmd := &serpent.Command{ + Use: "cursor", + Short: "Configure Cursor to use Coder MCP.", + Options: serpent.OptionSet{ + serpent.Option{ + Flag: "project", + Env: "CODER_MCP_CURSOR_PROJECT", + Description: "Use to configure a local project to use the Cursor MCP.", + Value: serpent.BoolOf(&project), + }, + }, + Handler: func(_ *serpent.Invocation) error { + dir, err := os.Getwd() + if err != nil { + return err + } + if !project { + dir, err = os.UserHomeDir() + if err != nil { + return err + } + } + cursorDir := filepath.Join(dir, ".cursor") + err = os.MkdirAll(cursorDir, 0755) + if err != nil { + return err + } + mcpConfig := filepath.Join(cursorDir, "mcp.json") + _, err = os.Stat(mcpConfig) + contents := map[string]any{} + if err != nil { + if !os.IsNotExist(err) { + return err + } + } else { + data, err := os.ReadFile(mcpConfig) + if err != nil { + return err + } + // The config can be empty, so we don't want to return an error if it is. + if len(data) > 0 { + err = json.Unmarshal(data, &contents) + if err != nil { + return err + } + } + } + mcpServers, ok := contents["mcpServers"].(map[string]any) + if !ok { + mcpServers = map[string]any{} + } + binPath, err := os.Executable() + if err != nil { + return err + } + mcpServers["coder"] = map[string]any{ + "command": binPath, + "args": []string{"mcp", "server"}, + } + contents["mcpServers"] = mcpServers + data, err := json.MarshalIndent(contents, "", " ") + if err != nil { + return err + } + err = os.WriteFile(mcpConfig, data, 0600) + if err != nil { + return err + } + return nil + }, + } + return cmd +} + +func (r *RootCmd) mcpServer() *serpent.Command { var ( client = new(codersdk.Client) instructions string allowedTools []string ) return &serpent.Command{ - Use: "mcp", + Use: "server", Handler: func(inv *serpent.Invocation) error { - return mcpHandler(inv, client, instructions, allowedTools) + return mcpServerHandler(inv, client, instructions, allowedTools) }, - Short: "Start an MCP server that can be used to interact with a Coder deployment.", + Short: "Start the Coder MCP server.", Middleware: serpent.Chain( r.InitClient(client), ), @@ -44,7 +214,7 @@ func (r *RootCmd) mcpCommand() *serpent.Command { } } -func mcpHandler(inv *serpent.Invocation, client *codersdk.Client, instructions string, allowedTools []string) error { +func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instructions string, allowedTools []string) error { ctx, cancel := context.WithCancel(inv.Context()) defer cancel() diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go index 23d02a2388bca..06d7693c86f7d 100644 --- a/cli/exp_mcp_test.go +++ b/cli/exp_mcp_test.go @@ -36,7 +36,7 @@ func TestExpMcp(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) // Given: we run the exp mcp command with allowed tools set - inv, root := clitest.New(t, "exp", "mcp", "--allowed-tools=coder_whoami,coder_list_templates") + inv, root := clitest.New(t, "exp", "mcp", "server", "--allowed-tools=coder_whoami,coder_list_templates") inv = inv.WithContext(cancelCtx) pty := ptytest.New(t) @@ -88,7 +88,7 @@ func TestExpMcp(t *testing.T) { client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) - inv, root := clitest.New(t, "exp", "mcp") + inv, root := clitest.New(t, "exp", "mcp", "server") inv = inv.WithContext(cancelCtx) pty := ptytest.New(t) @@ -128,7 +128,7 @@ func TestExpMcp(t *testing.T) { t.Cleanup(cancel) client := coderdtest.New(t, nil) - inv, root := clitest.New(t, "exp", "mcp") + inv, root := clitest.New(t, "exp", "mcp", "server") inv = inv.WithContext(cancelCtx) pty := ptytest.New(t) diff --git a/cli/testdata/TestProvisioners_Golden/list.golden b/cli/testdata/TestProvisioners_Golden/list.golden index 3f50f90746744..35844d8b9c50e 100644 --- a/cli/testdata/TestProvisioners_Golden/list.golden +++ b/cli/testdata/TestProvisioners_Golden/list.golden @@ -1,4 +1,4 @@ -ID CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS CURRENT JOB ID CURRENT JOB STATUS PREVIOUS JOB ID PREVIOUS JOB STATUS ORGANIZATION +ID CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS CURRENT JOB ID CURRENT JOB STATUS PREVIOUS JOB ID PREVIOUS JOB STATUS ORGANIZATION 00000000-0000-0000-aaaa-000000000000 ====[timestamp]===== ====[timestamp]===== default-provisioner v0.0.0-devel map[owner: scope:organization] built-in idle 00000000-0000-0000-bbbb-000000000001 succeeded Coder 00000000-0000-0000-aaaa-000000000001 ====[timestamp]===== ====[timestamp]===== provisioner-1 v0.0.0 map[foo:bar owner: scope:organization] built-in busy 00000000-0000-0000-bbbb-000000000002 running Coder 00000000-0000-0000-aaaa-000000000002 ====[timestamp]===== ====[timestamp]===== provisioner-2 v0.0.0 map[owner: scope:organization] built-in offline 00000000-0000-0000-bbbb-000000000003 succeeded Coder diff --git a/cli/testdata/coder_provisioner_list.golden b/cli/testdata/coder_provisioner_list.golden index 64941eebf5b89..e34db5605fd81 100644 --- a/cli/testdata/coder_provisioner_list.golden +++ b/cli/testdata/coder_provisioner_list.golden @@ -1,2 +1,2 @@ -CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS +CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS ====[timestamp]===== ====[timestamp]===== built-in test v0.0.0-devel idle map[owner: scope:organization] From e4e7eccfd7c78f550f055ec5af47b0fc8b8c8e12 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 31 Mar 2025 16:52:49 +0100 Subject: [PATCH 12/20] chore(mcp): return StdioServer directly instead of return an io.Closer --- cli/exp_mcp.go | 33 ++++++++++++++++++++------------- mcp/mcp.go | 37 ++++--------------------------------- 2 files changed, 24 insertions(+), 46 deletions(-) diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index 06a465824e4ef..893b122284802 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "log" "os" "path/filepath" @@ -41,7 +42,7 @@ func (r *RootCmd) mcpConfigure() *serpent.Command { return cmd } -func (r *RootCmd) mcpConfigureClaudeDesktop() *serpent.Command { +func (*RootCmd) mcpConfigureClaudeDesktop() *serpent.Command { cmd := &serpent.Command{ Use: "claude-desktop", Short: "Configure the Claude Desktop server.", @@ -51,7 +52,7 @@ func (r *RootCmd) mcpConfigureClaudeDesktop() *serpent.Command { return err } configPath = filepath.Join(configPath, "Claude") - err = os.MkdirAll(configPath, 0755) + err = os.MkdirAll(configPath, 0o755) if err != nil { return err } @@ -85,7 +86,7 @@ func (r *RootCmd) mcpConfigureClaudeDesktop() *serpent.Command { if err != nil { return err } - err = os.WriteFile(configPath, data, 0600) + err = os.WriteFile(configPath, data, 0o600) if err != nil { return err } @@ -95,7 +96,7 @@ func (r *RootCmd) mcpConfigureClaudeDesktop() *serpent.Command { return cmd } -func (_ *RootCmd) mcpConfigureClaudeCode() *serpent.Command { +func (*RootCmd) mcpConfigureClaudeCode() *serpent.Command { cmd := &serpent.Command{ Use: "claude-code", Short: "Configure the Claude Code server.", @@ -106,7 +107,7 @@ func (_ *RootCmd) mcpConfigureClaudeCode() *serpent.Command { return cmd } -func (_ *RootCmd) mcpConfigureCursor() *serpent.Command { +func (*RootCmd) mcpConfigureCursor() *serpent.Command { var project bool cmd := &serpent.Command{ Use: "cursor", @@ -131,7 +132,7 @@ func (_ *RootCmd) mcpConfigureCursor() *serpent.Command { } } cursorDir := filepath.Join(dir, ".cursor") - err = os.MkdirAll(cursorDir, 0755) + err = os.MkdirAll(cursorDir, 0o755) if err != nil { return err } @@ -172,7 +173,7 @@ func (_ *RootCmd) mcpConfigureCursor() *serpent.Command { if err != nil { return err } - err = os.WriteFile(mcpConfig, data, 0600) + err = os.WriteFile(mcpConfig, data, 0o600) if err != nil { return err } @@ -249,8 +250,6 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct options := []codermcp.Option{ codermcp.WithInstructions(instructions), codermcp.WithLogger(&logger), - codermcp.WithStdin(invStdin), - codermcp.WithStdout(invStdout), } // Add allowed tools option if specified @@ -258,14 +257,22 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct options = append(options, codermcp.WithAllowedTools(allowedTools)) } - closer := codermcp.New(ctx, client, options...) + srv := codermcp.NewStdio(client, options...) + srv.SetErrorLogger(log.New(invStderr, "", log.LstdFlags)) - <-ctx.Done() - if err := closer.Close(); err != nil { + done := make(chan error) + go func() { + defer close(done) + srvErr := srv.Listen(ctx, invStdin, invStdout) + done <- srvErr + }() + + if err := <-done; err != nil { if !errors.Is(err, context.Canceled) { - cliui.Errorf(inv.Stderr, "Failed to stop the MCP server: %s", err) + cliui.Errorf(inv.Stderr, "Failed to start the MCP server: %s", err) return err } } + return nil } diff --git a/mcp/mcp.go b/mcp/mcp.go index 560a18930efae..c9d46e37546ca 100644 --- a/mcp/mcp.go +++ b/mcp/mcp.go @@ -1,9 +1,7 @@ package codermcp import ( - "context" "io" - "log" "os" "github.com/mark3labs/mcp-go/server" @@ -17,8 +15,6 @@ import ( ) type mcpOptions struct { - in io.Reader - out io.Writer instructions string logger *slog.Logger allowedTools []string @@ -41,20 +37,6 @@ func WithLogger(logger *slog.Logger) Option { } } -// WithStdin sets the input reader for the MCP server. -func WithStdin(in io.Reader) Option { - return func(o *mcpOptions) { - o.in = in - } -} - -// WithStdout sets the output writer for the MCP server. -func WithStdout(out io.Writer) Option { - return func(o *mcpOptions) { - o.out = out - } -} - // WithAllowedTools sets the allowed tools for the MCP server. func WithAllowedTools(tools []string) Option { return func(o *mcpOptions) { @@ -62,13 +44,12 @@ func WithAllowedTools(tools []string) Option { } } -// New creates a new MCP server with the given client and options. -func New(ctx context.Context, client *codersdk.Client, opts ...Option) io.Closer { +// NewStdio creates a new MCP stdio server with the given client and options. +// It is the responsibility of the caller to start and stop the server. +func NewStdio(client *codersdk.Client, opts ...Option) *server.StdioServer { options := &mcpOptions{ - in: os.Stdin, instructions: ``, logger: ptr.Ref(slog.Make(sloghuman.Sink(os.Stdout))), - out: os.Stdout, } for _, opt := range opts { opt(options) @@ -93,17 +74,7 @@ func New(ctx context.Context, client *codersdk.Client, opts ...Option) io.Closer }) srv := server.NewStdioServer(mcpSrv) - srv.SetErrorLogger(log.New(options.out, "", log.LstdFlags)) - done := make(chan error) - go func() { - defer close(done) - srvErr := srv.Listen(ctx, options.in, options.out) - done <- srvErr - }() - - return closeFunc(func() error { - return <-done - }) + return srv } type closeFunc func() error From f805f3a4f5a787575f5ef2fc4d319fb46a30bb8f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 31 Mar 2025 16:54:01 +0100 Subject: [PATCH 13/20] chore(kyleosophy): sometimes the right abstraction is no abstraction --- mcp/tools/tools_registry.go | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/mcp/tools/tools_registry.go b/mcp/tools/tools_registry.go index 0cec7947961c2..6c502cfbd76b6 100644 --- a/mcp/tools/tools_registry.go +++ b/mcp/tools/tools_registry.go @@ -186,14 +186,6 @@ Can be either "start" or "stop".`)), }, } -// ToolAdder interface for adding tools to a server -type ToolAdder interface { - AddTool(tool mcp.Tool, handler server.ToolHandlerFunc) -} - -// Ensure that MCPServer implements ToolAdder -var _ ToolAdder = (*server.MCPServer)(nil) - // ToolDeps contains all dependencies needed by tool handlers type ToolDeps struct { Client *codersdk.Client @@ -231,9 +223,9 @@ func (r ToolRegistry) WithOnlyAllowed(allowed ...string) ToolRegistry { // Register registers all tools in the registry with the given tool adder // and dependencies. -func (r ToolRegistry) Register(ta ToolAdder, deps ToolDeps) { +func (r ToolRegistry) Register(srv *server.MCPServer, deps ToolDeps) { for _, entry := range r { - ta.AddTool(entry.Tool, entry.MakeHandler(deps)) + srv.AddTool(entry.Tool, entry.MakeHandler(deps)) } } From efedab0a0b31b253d6499d78f039c09de9efd7f4 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 31 Mar 2025 16:59:07 +0100 Subject: [PATCH 14/20] chore(cli/clitest): nicer diff --- cli/clitest/golden.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cli/clitest/golden.go b/cli/clitest/golden.go index e79006ebb58e3..ca454be798fe4 100644 --- a/cli/clitest/golden.go +++ b/cli/clitest/golden.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -117,11 +118,9 @@ func TestGoldenFile(t *testing.T, fileName string, actual []byte, replacements m require.NoError(t, err, "read golden file, run \"make gen/golden-files\" and commit the changes") expected = normalizeGoldenFile(t, expected) - require.Equal( - t, string(expected), string(actual), - "golden file mismatch: %s, run \"make gen/golden-files\", verify and commit the changes", - goldenPath, - ) + if diff := cmp.Diff(string(expected), string(actual)); diff != "" { + t.Fatalf("golden file mismatch: %s, run \"make gen/golden-files\", verify and commit the changes", goldenPath) + } } // normalizeGoldenFile replaces any strings that are system or timing dependent From 40422c12444e172141dd86e88c981166d5c1e21a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 31 Mar 2025 17:02:21 +0100 Subject: [PATCH 15/20] add missing handlers --- cli/exp_mcp.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index 893b122284802..371aa2b0a0c28 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -21,6 +21,9 @@ func (r *RootCmd) mcpCommand() *serpent.Command { Use: "mcp", Short: "Run the Coder MCP server and configure it to work with AI tools.", Long: "The Coder MCP server allows you to automatically create workspaces with parameters.", + Handler: func(i *serpent.Invocation) error { + return i.Command.HelpHandler(i) + }, Children: []*serpent.Command{ r.mcpConfigure(), r.mcpServer(), @@ -33,6 +36,9 @@ func (r *RootCmd) mcpConfigure() *serpent.Command { cmd := &serpent.Command{ Use: "configure", Short: "Automatically configure the MCP server.", + Handler: func(i *serpent.Invocation) error { + return i.Command.HelpHandler(i) + }, Children: []*serpent.Command{ r.mcpConfigureClaudeDesktop(), r.mcpConfigureClaudeCode(), From f83b0eddb24ba78ba532df5b39a1cd624de3298c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 31 Mar 2025 17:06:50 +0100 Subject: [PATCH 16/20] fix mcp server invocation cmd --- cli/exp_mcp.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index 371aa2b0a0c28..a5af41d9103a6 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -86,7 +86,7 @@ func (*RootCmd) mcpConfigureClaudeDesktop() *serpent.Command { return err } contents["mcpServers"] = map[string]any{ - "coder": map[string]any{"command": binPath, "args": []string{"mcp", "server"}}, + "coder": map[string]any{"command": binPath, "args": []string{"exp", "mcp", "server"}}, } data, err = json.MarshalIndent(contents, "", " ") if err != nil { @@ -172,7 +172,7 @@ func (*RootCmd) mcpConfigureCursor() *serpent.Command { } mcpServers["coder"] = map[string]any{ "command": binPath, - "args": []string{"mcp", "server"}, + "args": []string{"exp", "mcp", "server"}, } contents["mcpServers"] = mcpServers data, err := json.MarshalIndent(contents, "", " ") From 55130f79bc2fff3c26070c097f70d48dad253c54 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 31 Mar 2025 17:19:51 +0100 Subject: [PATCH 17/20] keep mcp stuff together --- mcp/mcp.go | 571 +++++++++++++++++- .../tools_coder_test.go => mcp_test.go} | 10 +- mcp/tools/tools_coder.go | 351 ----------- mcp/tools/tools_registry.go | 236 -------- 4 files changed, 569 insertions(+), 599 deletions(-) rename mcp/{tools/tools_coder_test.go => mcp_test.go} (98%) delete mode 100644 mcp/tools/tools_coder.go delete mode 100644 mcp/tools/tools_registry.go diff --git a/mcp/mcp.go b/mcp/mcp.go index c9d46e37546ca..80e0f341e16e6 100644 --- a/mcp/mcp.go +++ b/mcp/mcp.go @@ -1,17 +1,27 @@ package codermcp import ( + "bytes" + "context" + "encoding/json" + "errors" "io" "os" + "slices" + "strings" + "time" + "github.com/google/uuid" + "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + "golang.org/x/xerrors" "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" - mcptools "github.com/coder/coder/v2/mcp/tools" + "github.com/coder/coder/v2/codersdk/workspacesdk" ) type mcpOptions struct { @@ -64,11 +74,11 @@ func NewStdio(client *codersdk.Client, opts ...Option) *server.StdioServer { logger := slog.Make(sloghuman.Sink(os.Stdout)) // Register tools based on the allowed list (if specified) - reg := mcptools.AllTools() + reg := AllTools() if len(options.allowedTools) > 0 { reg = reg.WithOnlyAllowed(options.allowedTools...) } - reg.Register(mcpSrv, mcptools.ToolDeps{ + reg.Register(mcpSrv, ToolDeps{ Client: client, Logger: &logger, }) @@ -77,10 +87,557 @@ func NewStdio(client *codersdk.Client, opts ...Option) *server.StdioServer { return srv } -type closeFunc func() error +// allTools is the list of all available tools. When adding a new tool, +// make sure to update this list. +var allTools = ToolRegistry{ + { + Tool: mcp.NewTool("coder_report_task", + mcp.WithDescription(`Report progress on a user task in Coder. +Use this tool to keep the user informed about your progress with their request. +For long-running operations, call this periodically to provide status updates. +This is especially useful when performing multi-step operations like workspace creation or deployment.`), + mcp.WithString("summary", mcp.Description(`A concise summary of your current progress on the task. + +Good Summaries: +- "Taking a look at the login page..." +- "Found a bug! Fixing it now..." +- "Investigating the GitHub Issue..." +- "Waiting for workspace to start (1/3 resources ready)" +- "Downloading template files from repository"`), mcp.Required()), + mcp.WithString("link", mcp.Description(`A relevant URL related to your work, such as: +- GitHub issue link +- Pull request URL +- Documentation reference +- Workspace URL +Use complete URLs (including https://) when possible.`), mcp.Required()), + mcp.WithString("emoji", mcp.Description(`A relevant emoji that visually represents the current status: +- 🔍 for investigating/searching +- 🚀 for deploying/starting +- 🐛 for debugging +- ✅ for completion +- ⏳ for waiting +Choose an emoji that helps the user understand the current phase at a glance.`), mcp.Required()), + mcp.WithBoolean("done", mcp.Description(`Whether the overall task the user requested is complete. +Set to true only when the entire requested operation is finished successfully. +For multi-step processes, use false until all steps are complete.`), mcp.Required()), + ), + MakeHandler: handleCoderReportTask, + }, + { + Tool: mcp.NewTool("coder_whoami", + mcp.WithDescription(`Get information about the currently logged-in Coder user. +Returns JSON with the user's profile including fields: id, username, email, created_at, status, roles, etc. +Use this to identify the current user context before performing workspace operations. +This tool is useful for verifying permissions and checking the user's identity. -func (f closeFunc) Close() error { - return f() +Common errors: +- Authentication failure: The session may have expired +- Server unavailable: The Coder deployment may be unreachable`), + ), + MakeHandler: handleCoderWhoami, + }, + { + Tool: mcp.NewTool("coder_list_templates", + mcp.WithDescription(`List all templates available on the Coder deployment. +Returns JSON with detailed information about each template, including: +- Template name, ID, and description +- Creation/modification timestamps +- Version information +- Associated organization + +Use this tool to discover available templates before creating workspaces. +Templates define the infrastructure and configuration for workspaces. + +Common errors: +- Authentication failure: Check user permissions +- No templates available: The deployment may not have any templates configured`), + ), + MakeHandler: handleCoderListTemplates, + }, + { + Tool: mcp.NewTool("coder_list_workspaces", + mcp.WithDescription(`List workspaces available on the Coder deployment. +Returns JSON with workspace metadata including status, resources, and configurations. +Use this before other workspace operations to find valid workspace names/IDs. +Results are paginated - use offset and limit parameters for large deployments. + +Common errors: +- Authentication failure: Check user permissions +- Invalid owner parameter: Ensure the owner exists`), + mcp.WithString(`owner`, mcp.Description(`The username of the workspace owner to filter by. +Defaults to "me" which represents the currently authenticated user. +Use this to view workspaces belonging to other users (requires appropriate permissions). +Special value: "me" - List workspaces owned by the authenticated user.`), mcp.DefaultString(codersdk.Me)), + mcp.WithNumber(`offset`, mcp.Description(`Pagination offset - the starting index for listing workspaces. +Used with the 'limit' parameter to implement pagination. +For example, to get the second page of results with 10 items per page, use offset=10. +Defaults to 0 (first page).`), mcp.DefaultNumber(0)), + mcp.WithNumber(`limit`, mcp.Description(`Maximum number of workspaces to return in a single request. +Used with the 'offset' parameter to implement pagination. +Higher values return more results but may increase response time. +Valid range: 1-100. Defaults to 10.`), mcp.DefaultNumber(10)), + ), + MakeHandler: handleCoderListWorkspaces, + }, + { + Tool: mcp.NewTool("coder_get_workspace", + mcp.WithDescription(`Get detailed information about a specific Coder workspace. +Returns comprehensive JSON with the workspace's configuration, status, and resources. +Use this to check workspace status before performing operations like exec or start/stop. +The response includes the latest build status, agent connectivity, and resource details. + +Common errors: +- Workspace not found: Check the workspace name or ID +- Permission denied: The user may not have access to this workspace`), + mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to retrieve. +Can be specified as either: +- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" +- Workspace name: e.g., "dev", "python-project" +Use coder_list_workspaces first if you're not sure about available workspace names.`), mcp.Required()), + ), + MakeHandler: handleCoderGetWorkspace, + }, + { + Tool: mcp.NewTool("coder_workspace_exec", + mcp.WithDescription(`Execute a shell command in a remote Coder workspace. +Runs the specified command and returns the complete output (stdout/stderr). +Use this for file operations, running build commands, or checking workspace state. +The workspace must be running with a connected agent for this to succeed. + +Before using this tool: +1. Verify the workspace is running using coder_get_workspace +2. Start the workspace if needed using coder_start_workspace + +Common errors: +- Workspace not running: Start the workspace first +- Command not allowed: Check security restrictions +- Agent not connected: The workspace may still be starting up`), + mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name where the command will execute. +Can be specified as either: +- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" +- Workspace name: e.g., "dev", "python-project" +The workspace must be running with a connected agent. +Use coder_get_workspace first to check the workspace status.`), mcp.Required()), + mcp.WithString("command", mcp.Description(`The shell command to execute in the workspace. +Commands are executed in the default shell of the workspace. + +Examples: +- "ls -la" - List files with details +- "cd /path/to/directory && command" - Execute in specific directory +- "cat ~/.bashrc" - View a file's contents +- "python -m pip list" - List installed Python packages + +Note: Very long-running commands may time out.`), mcp.Required()), + ), + MakeHandler: handleCoderWorkspaceExec, + }, + { + Tool: mcp.NewTool("coder_workspace_transition", + mcp.WithDescription(`Start or stop a running Coder workspace. +If stopping, initiates the workspace stop transition. +Only works on workspaces that are currently running or failed. + +If starting, initiates the workspace start transition. +Only works on workspaces that are currently stopped or failed. + +Stopping or starting a workspace is an asynchronous operation - it may take several minutes to complete. + +After calling this tool: +1. Use coder_report_task to inform the user that the workspace is stopping or starting +2. Use coder_get_workspace periodically to check for completion + +Common errors: +- Workspace already started/starting/stopped/stopping: No action needed +- Cancellation failed: There may be issues with the underlying infrastructure +- User doesn't own workspace: Permission issues`), + mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to start or stop. +Can be specified as either: +- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" +- Workspace name: e.g., "dev", "python-project" +The workspace must be in a running state to be stopped, or in a stopped or failed state to be started. +Use coder_get_workspace first to check the current workspace status.`), mcp.Required()), + mcp.WithString("transition", mcp.Description(`The transition to apply to the workspace. +Can be either "start" or "stop".`)), + ), + MakeHandler: handleCoderWorkspaceTransition, + }, +} + +// ToolDeps contains all dependencies needed by tool handlers +type ToolDeps struct { + Client *codersdk.Client + Logger *slog.Logger +} + +// ToolHandler associates a tool with its handler creation function +type ToolHandler struct { + Tool mcp.Tool + MakeHandler func(ToolDeps) server.ToolHandlerFunc +} + +// ToolRegistry is a map of available tools with their handler creation +// functions +type ToolRegistry []ToolHandler + +// WithOnlyAllowed returns a new ToolRegistry containing only the tools +// specified in the allowed list. +func (r ToolRegistry) WithOnlyAllowed(allowed ...string) ToolRegistry { + if len(allowed) == 0 { + return []ToolHandler{} + } + + filtered := make(ToolRegistry, 0, len(r)) + + // The overhead of a map lookup is likely higher than a linear scan + // for a small number of tools. + for _, entry := range r { + if slices.Contains(allowed, entry.Tool.Name) { + filtered = append(filtered, entry) + } + } + return filtered } -var _ io.Closer = closeFunc(nil) +// Register registers all tools in the registry with the given tool adder +// and dependencies. +func (r ToolRegistry) Register(srv *server.MCPServer, deps ToolDeps) { + for _, entry := range r { + srv.AddTool(entry.Tool, entry.MakeHandler(deps)) + } +} + +// AllTools returns all available tools. +func AllTools() ToolRegistry { + // return a copy of allTools to avoid mutating the original + return slices.Clone(allTools) +} + +type handleCoderReportTaskArgs struct { + Summary string `json:"summary"` + Link string `json:"link"` + Emoji string `json:"emoji"` + Done bool `json:"done"` +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_report_task", "arguments": {"summary": "I'm working on the login page.", "link": "https://github.com/coder/coder/pull/1234", "emoji": "🔍", "done": false}}} +func handleCoderReportTask(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + + // Convert the request parameters to a json.RawMessage so we can unmarshal + // them into the correct struct. + args, err := unmarshalArgs[handleCoderReportTaskArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) + } + + // TODO: Waiting on support for tasks. + deps.Logger.Info(ctx, "report task tool called", slog.F("summary", args.Summary), slog.F("link", args.Link), slog.F("done", args.Done), slog.F("emoji", args.Emoji)) + /* + err := sdk.PostTask(ctx, agentsdk.PostTaskRequest{ + Reporter: "claude", + Summary: summary, + URL: link, + Completion: done, + Icon: emoji, + }) + if err != nil { + return nil, err + } + */ + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent("Thanks for reporting!"), + }, + }, nil + } +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_whoami", "arguments": {}}} +func handleCoderWhoami(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + me, err := deps.Client.User(ctx, codersdk.Me) + if err != nil { + return nil, xerrors.Errorf("Failed to fetch the current user: %s", err.Error()) + } + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(me); err != nil { + return nil, xerrors.Errorf("Failed to encode the current user: %s", err.Error()) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(strings.TrimSpace(buf.String())), + }, + }, nil + } +} + +type handleCoderListWorkspacesArgs struct { + Owner string `json:"owner"` + Offset int `json:"offset"` + Limit int `json:"limit"` +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_list_workspaces", "arguments": {"owner": "me", "offset": 0, "limit": 10}}} +func handleCoderListWorkspaces(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + args, err := unmarshalArgs[handleCoderListWorkspacesArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) + } + + workspaces, err := deps.Client.Workspaces(ctx, codersdk.WorkspaceFilter{ + Owner: args.Owner, + Offset: args.Offset, + Limit: args.Limit, + }) + if err != nil { + return nil, xerrors.Errorf("failed to fetch workspaces: %w", err) + } + + // Encode it as JSON. TODO: It might be nicer for the agent to have a tabulated response. + data, err := json.Marshal(workspaces) + if err != nil { + return nil, xerrors.Errorf("failed to encode workspaces: %s", err.Error()) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(data)), + }, + }, nil + } +} + +type handleCoderGetWorkspaceArgs struct { + Workspace string `json:"workspace"` +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_get_workspace", "arguments": {"workspace": "dev"}}} +func handleCoderGetWorkspace(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + args, err := unmarshalArgs[handleCoderGetWorkspaceArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) + } + + workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace) + if err != nil { + return nil, xerrors.Errorf("failed to fetch workspace: %w", err) + } + + workspaceJSON, err := json.Marshal(workspace) + if err != nil { + return nil, xerrors.Errorf("failed to encode workspace: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(workspaceJSON)), + }, + }, nil + } +} + +type handleCoderWorkspaceExecArgs struct { + Workspace string `json:"workspace"` + Command string `json:"command"` +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_workspace_exec", "arguments": {"workspace": "dev", "command": "ps -ef"}}} +func handleCoderWorkspaceExec(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + args, err := unmarshalArgs[handleCoderWorkspaceExecArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) + } + + // Attempt to fetch the workspace. We may get a UUID or a name, so try to + // handle both. + ws, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace) + if err != nil { + return nil, xerrors.Errorf("failed to fetch workspace: %w", err) + } + + // Ensure the workspace is started. + // Select the first agent of the workspace. + var agt *codersdk.WorkspaceAgent + for _, r := range ws.LatestBuild.Resources { + for _, a := range r.Agents { + if a.Status != codersdk.WorkspaceAgentConnected { + continue + } + agt = ptr.Ref(a) + break + } + } + if agt == nil { + return nil, xerrors.Errorf("no connected agents for workspace %s", ws.ID) + } + + startedAt := time.Now() + conn, err := workspacesdk.New(deps.Client).AgentReconnectingPTY(ctx, workspacesdk.WorkspaceAgentReconnectingPTYOpts{ + AgentID: agt.ID, + Reconnect: uuid.New(), + Width: 80, + Height: 24, + Command: args.Command, + BackendType: "buffered", // the screen backend is annoying to use here. + }) + if err != nil { + return nil, xerrors.Errorf("failed to open reconnecting PTY: %w", err) + } + defer conn.Close() + connectedAt := time.Now() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, conn); err != nil { + // EOF is expected when the connection is closed. + // We can ignore this error. + if !errors.Is(err, io.EOF) { + return nil, xerrors.Errorf("failed to read from reconnecting PTY: %w", err) + } + } + completedAt := time.Now() + connectionTime := connectedAt.Sub(startedAt) + executionTime := completedAt.Sub(connectedAt) + + resp := map[string]string{ + "connection_time": connectionTime.String(), + "execution_time": executionTime.String(), + "output": buf.String(), + } + respJSON, err := json.Marshal(resp) + if err != nil { + return nil, xerrors.Errorf("failed to encode workspace build: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(respJSON)), + }, + }, nil + } +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_list_templates", "arguments": {}}} +func handleCoderListTemplates(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + templates, err := deps.Client.Templates(ctx, codersdk.TemplateFilter{}) + if err != nil { + return nil, xerrors.Errorf("failed to fetch templates: %w", err) + } + + templateJSON, err := json.Marshal(templates) + if err != nil { + return nil, xerrors.Errorf("failed to encode templates: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(templateJSON)), + }, + }, nil + } +} + +type handleCoderWorkspaceTransitionArgs struct { + Workspace string `json:"workspace"` + Transition string `json:"transition"` +} + +// Example payload: +// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": +// "coder_workspace_transition", "arguments": {"workspace": "dev", "transition": "stop"}}} +func handleCoderWorkspaceTransition(deps ToolDeps) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if deps.Client == nil { + return nil, xerrors.New("developer error: client is required") + } + args, err := unmarshalArgs[handleCoderWorkspaceTransitionArgs](request.Params.Arguments) + if err != nil { + return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) + } + + workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace) + if err != nil { + return nil, xerrors.Errorf("failed to fetch workspace: %w", err) + } + + wsTransition := codersdk.WorkspaceTransition(args.Transition) + switch wsTransition { + case codersdk.WorkspaceTransitionStart: + case codersdk.WorkspaceTransitionStop: + default: + return nil, xerrors.New("invalid transition") + } + + // We're not going to check the workspace status here as it is checked on the + // server side. + wb, err := deps.Client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: wsTransition, + }) + if err != nil { + return nil, xerrors.Errorf("failed to stop workspace: %w", err) + } + + resp := map[string]any{"status": wb.Status, "transition": wb.Transition} + respJSON, err := json.Marshal(resp) + if err != nil { + return nil, xerrors.Errorf("failed to encode workspace build: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(string(respJSON)), + }, + }, nil + } +} + +func getWorkspaceByIDOrOwnerName(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) { + if wsid, err := uuid.Parse(identifier); err == nil { + return client.Workspace(ctx, wsid) + } + return client.WorkspaceByOwnerAndName(ctx, codersdk.Me, identifier, codersdk.WorkspaceOptions{}) +} + +// unmarshalArgs is a helper function to convert the map[string]any we get from +// the MCP server into a typed struct. It does this by marshaling and unmarshalling +// the arguments. +func unmarshalArgs[T any](args map[string]interface{}) (t T, err error) { + argsJSON, err := json.Marshal(args) + if err != nil { + return t, xerrors.Errorf("failed to marshal arguments: %w", err) + } + if err := json.Unmarshal(argsJSON, &t); err != nil { + return t, xerrors.Errorf("failed to unmarshal arguments: %w", err) + } + return t, nil +} diff --git a/mcp/tools/tools_coder_test.go b/mcp/mcp_test.go similarity index 98% rename from mcp/tools/tools_coder_test.go rename to mcp/mcp_test.go index 4527d24ee66d9..f2573f44a1be6 100644 --- a/mcp/tools/tools_coder_test.go +++ b/mcp/mcp_test.go @@ -1,4 +1,4 @@ -package mcptools_test +package codermcp_test import ( "context" @@ -17,7 +17,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/codersdk" - mcptools "github.com/coder/coder/v2/mcp/tools" + codermcp "github.com/coder/coder/v2/mcp" "github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/testutil" ) @@ -65,7 +65,7 @@ func TestCoderTools(t *testing.T) { // Register tools using our registry logger := slogtest.Make(t, nil) - mcptools.AllTools().Register(mcpSrv, mcptools.ToolDeps{ + codermcp.AllTools().Register(mcpSrv, codermcp.ToolDeps{ Client: memberClient, Logger: &logger, }) @@ -206,9 +206,9 @@ func TestCoderTools(t *testing.T) { t.Cleanup(func() { _ = closeRestrictedSrv() }) - mcptools.AllTools(). + codermcp.AllTools(). WithOnlyAllowed(allowedTools...). - Register(restrictedMCPSrv, mcptools.ToolDeps{ + Register(restrictedMCPSrv, codermcp.ToolDeps{ Client: memberClient, Logger: &logger, }) diff --git a/mcp/tools/tools_coder.go b/mcp/tools/tools_coder.go deleted file mode 100644 index 278a7331eb83e..0000000000000 --- a/mcp/tools/tools_coder.go +++ /dev/null @@ -1,351 +0,0 @@ -package mcptools - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "strings" - "time" - - "github.com/google/uuid" - "github.com/mark3labs/mcp-go/mcp" - mcpserver "github.com/mark3labs/mcp-go/server" - "golang.org/x/xerrors" - - "cdr.dev/slog" - "github.com/coder/coder/v2/coderd/util/ptr" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/workspacesdk" -) - -type handleCoderReportTaskArgs struct { - Summary string `json:"summary"` - Link string `json:"link"` - Emoji string `json:"emoji"` - Done bool `json:"done"` -} - -// Example payload: -// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_report_task", "arguments": {"summary": "I'm working on the login page.", "link": "https://github.com/coder/coder/pull/1234", "emoji": "🔍", "done": false}}} -func handleCoderReportTask(deps ToolDeps) mcpserver.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - if deps.Client == nil { - return nil, xerrors.New("developer error: client is required") - } - - // Convert the request parameters to a json.RawMessage so we can unmarshal - // them into the correct struct. - args, err := unmarshalArgs[handleCoderReportTaskArgs](request.Params.Arguments) - if err != nil { - return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) - } - - // TODO: Waiting on support for tasks. - deps.Logger.Info(ctx, "report task tool called", slog.F("summary", args.Summary), slog.F("link", args.Link), slog.F("done", args.Done), slog.F("emoji", args.Emoji)) - /* - err := sdk.PostTask(ctx, agentsdk.PostTaskRequest{ - Reporter: "claude", - Summary: summary, - URL: link, - Completion: done, - Icon: emoji, - }) - if err != nil { - return nil, err - } - */ - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent("Thanks for reporting!"), - }, - }, nil - } -} - -// Example payload: -// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_whoami", "arguments": {}}} -func handleCoderWhoami(deps ToolDeps) mcpserver.ToolHandlerFunc { - return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - if deps.Client == nil { - return nil, xerrors.New("developer error: client is required") - } - me, err := deps.Client.User(ctx, codersdk.Me) - if err != nil { - return nil, xerrors.Errorf("Failed to fetch the current user: %s", err.Error()) - } - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(me); err != nil { - return nil, xerrors.Errorf("Failed to encode the current user: %s", err.Error()) - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(strings.TrimSpace(buf.String())), - }, - }, nil - } -} - -type handleCoderListWorkspacesArgs struct { - Owner string `json:"owner"` - Offset int `json:"offset"` - Limit int `json:"limit"` -} - -// Example payload: -// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_list_workspaces", "arguments": {"owner": "me", "offset": 0, "limit": 10}}} -func handleCoderListWorkspaces(deps ToolDeps) mcpserver.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - if deps.Client == nil { - return nil, xerrors.New("developer error: client is required") - } - args, err := unmarshalArgs[handleCoderListWorkspacesArgs](request.Params.Arguments) - if err != nil { - return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) - } - - workspaces, err := deps.Client.Workspaces(ctx, codersdk.WorkspaceFilter{ - Owner: args.Owner, - Offset: args.Offset, - Limit: args.Limit, - }) - if err != nil { - return nil, xerrors.Errorf("failed to fetch workspaces: %w", err) - } - - // Encode it as JSON. TODO: It might be nicer for the agent to have a tabulated response. - data, err := json.Marshal(workspaces) - if err != nil { - return nil, xerrors.Errorf("failed to encode workspaces: %s", err.Error()) - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(string(data)), - }, - }, nil - } -} - -type handleCoderGetWorkspaceArgs struct { - Workspace string `json:"workspace"` -} - -// Example payload: -// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_get_workspace", "arguments": {"workspace": "dev"}}} -func handleCoderGetWorkspace(deps ToolDeps) mcpserver.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - if deps.Client == nil { - return nil, xerrors.New("developer error: client is required") - } - args, err := unmarshalArgs[handleCoderGetWorkspaceArgs](request.Params.Arguments) - if err != nil { - return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) - } - - workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace) - if err != nil { - return nil, xerrors.Errorf("failed to fetch workspace: %w", err) - } - - workspaceJSON, err := json.Marshal(workspace) - if err != nil { - return nil, xerrors.Errorf("failed to encode workspace: %w", err) - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(string(workspaceJSON)), - }, - }, nil - } -} - -type handleCoderWorkspaceExecArgs struct { - Workspace string `json:"workspace"` - Command string `json:"command"` -} - -// Example payload: -// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_workspace_exec", "arguments": {"workspace": "dev", "command": "ps -ef"}}} -func handleCoderWorkspaceExec(deps ToolDeps) mcpserver.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - if deps.Client == nil { - return nil, xerrors.New("developer error: client is required") - } - args, err := unmarshalArgs[handleCoderWorkspaceExecArgs](request.Params.Arguments) - if err != nil { - return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) - } - - // Attempt to fetch the workspace. We may get a UUID or a name, so try to - // handle both. - ws, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace) - if err != nil { - return nil, xerrors.Errorf("failed to fetch workspace: %w", err) - } - - // Ensure the workspace is started. - // Select the first agent of the workspace. - var agt *codersdk.WorkspaceAgent - for _, r := range ws.LatestBuild.Resources { - for _, a := range r.Agents { - if a.Status != codersdk.WorkspaceAgentConnected { - continue - } - agt = ptr.Ref(a) - break - } - } - if agt == nil { - return nil, xerrors.Errorf("no connected agents for workspace %s", ws.ID) - } - - startedAt := time.Now() - conn, err := workspacesdk.New(deps.Client).AgentReconnectingPTY(ctx, workspacesdk.WorkspaceAgentReconnectingPTYOpts{ - AgentID: agt.ID, - Reconnect: uuid.New(), - Width: 80, - Height: 24, - Command: args.Command, - BackendType: "buffered", // the screen backend is annoying to use here. - }) - if err != nil { - return nil, xerrors.Errorf("failed to open reconnecting PTY: %w", err) - } - defer conn.Close() - connectedAt := time.Now() - - var buf bytes.Buffer - if _, err := io.Copy(&buf, conn); err != nil { - // EOF is expected when the connection is closed. - // We can ignore this error. - if !errors.Is(err, io.EOF) { - return nil, xerrors.Errorf("failed to read from reconnecting PTY: %w", err) - } - } - completedAt := time.Now() - connectionTime := connectedAt.Sub(startedAt) - executionTime := completedAt.Sub(connectedAt) - - resp := map[string]string{ - "connection_time": connectionTime.String(), - "execution_time": executionTime.String(), - "output": buf.String(), - } - respJSON, err := json.Marshal(resp) - if err != nil { - return nil, xerrors.Errorf("failed to encode workspace build: %w", err) - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(string(respJSON)), - }, - }, nil - } -} - -// Example payload: -// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_list_templates", "arguments": {}}} -func handleCoderListTemplates(deps ToolDeps) mcpserver.ToolHandlerFunc { - return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - if deps.Client == nil { - return nil, xerrors.New("developer error: client is required") - } - templates, err := deps.Client.Templates(ctx, codersdk.TemplateFilter{}) - if err != nil { - return nil, xerrors.Errorf("failed to fetch templates: %w", err) - } - - templateJSON, err := json.Marshal(templates) - if err != nil { - return nil, xerrors.Errorf("failed to encode templates: %w", err) - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(string(templateJSON)), - }, - }, nil - } -} - -type handleCoderWorkspaceTransitionArgs struct { - Workspace string `json:"workspace"` - Transition string `json:"transition"` -} - -// Example payload: -// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": -// "coder_workspace_transition", "arguments": {"workspace": "dev", "transition": "stop"}}} -func handleCoderWorkspaceTransition(deps ToolDeps) mcpserver.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - if deps.Client == nil { - return nil, xerrors.New("developer error: client is required") - } - args, err := unmarshalArgs[handleCoderWorkspaceTransitionArgs](request.Params.Arguments) - if err != nil { - return nil, xerrors.Errorf("failed to unmarshal arguments: %w", err) - } - - workspace, err := getWorkspaceByIDOrOwnerName(ctx, deps.Client, args.Workspace) - if err != nil { - return nil, xerrors.Errorf("failed to fetch workspace: %w", err) - } - - wsTransition := codersdk.WorkspaceTransition(args.Transition) - switch wsTransition { - case codersdk.WorkspaceTransitionStart: - case codersdk.WorkspaceTransitionStop: - default: - return nil, xerrors.New("invalid transition") - } - - // We're not going to check the workspace status here as it is checked on the - // server side. - wb, err := deps.Client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - Transition: wsTransition, - }) - if err != nil { - return nil, xerrors.Errorf("failed to stop workspace: %w", err) - } - - resp := map[string]any{"status": wb.Status, "transition": wb.Transition} - respJSON, err := json.Marshal(resp) - if err != nil { - return nil, xerrors.Errorf("failed to encode workspace build: %w", err) - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(string(respJSON)), - }, - }, nil - } -} - -func getWorkspaceByIDOrOwnerName(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) { - if wsid, err := uuid.Parse(identifier); err == nil { - return client.Workspace(ctx, wsid) - } - return client.WorkspaceByOwnerAndName(ctx, codersdk.Me, identifier, codersdk.WorkspaceOptions{}) -} - -// unmarshalArgs is a helper function to convert the map[string]any we get from -// the MCP server into a typed struct. It does this by marshaling and unmarshalling -// the arguments. -func unmarshalArgs[T any](args map[string]interface{}) (t T, err error) { - argsJSON, err := json.Marshal(args) - if err != nil { - return t, xerrors.Errorf("failed to marshal arguments: %w", err) - } - if err := json.Unmarshal(argsJSON, &t); err != nil { - return t, xerrors.Errorf("failed to unmarshal arguments: %w", err) - } - return t, nil -} diff --git a/mcp/tools/tools_registry.go b/mcp/tools/tools_registry.go deleted file mode 100644 index 6c502cfbd76b6..0000000000000 --- a/mcp/tools/tools_registry.go +++ /dev/null @@ -1,236 +0,0 @@ -package mcptools - -import ( - "slices" - - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - - "cdr.dev/slog" - "github.com/coder/coder/v2/codersdk" -) - -// allTools is the list of all available tools. When adding a new tool, -// make sure to update this list. -var allTools = ToolRegistry{ - { - Tool: mcp.NewTool("coder_report_task", - mcp.WithDescription(`Report progress on a user task in Coder. -Use this tool to keep the user informed about your progress with their request. -For long-running operations, call this periodically to provide status updates. -This is especially useful when performing multi-step operations like workspace creation or deployment.`), - mcp.WithString("summary", mcp.Description(`A concise summary of your current progress on the task. - -Good Summaries: -- "Taking a look at the login page..." -- "Found a bug! Fixing it now..." -- "Investigating the GitHub Issue..." -- "Waiting for workspace to start (1/3 resources ready)" -- "Downloading template files from repository"`), mcp.Required()), - mcp.WithString("link", mcp.Description(`A relevant URL related to your work, such as: -- GitHub issue link -- Pull request URL -- Documentation reference -- Workspace URL -Use complete URLs (including https://) when possible.`), mcp.Required()), - mcp.WithString("emoji", mcp.Description(`A relevant emoji that visually represents the current status: -- 🔍 for investigating/searching -- 🚀 for deploying/starting -- 🐛 for debugging -- ✅ for completion -- ⏳ for waiting -Choose an emoji that helps the user understand the current phase at a glance.`), mcp.Required()), - mcp.WithBoolean("done", mcp.Description(`Whether the overall task the user requested is complete. -Set to true only when the entire requested operation is finished successfully. -For multi-step processes, use false until all steps are complete.`), mcp.Required()), - ), - MakeHandler: handleCoderReportTask, - }, - { - Tool: mcp.NewTool("coder_whoami", - mcp.WithDescription(`Get information about the currently logged-in Coder user. -Returns JSON with the user's profile including fields: id, username, email, created_at, status, roles, etc. -Use this to identify the current user context before performing workspace operations. -This tool is useful for verifying permissions and checking the user's identity. - -Common errors: -- Authentication failure: The session may have expired -- Server unavailable: The Coder deployment may be unreachable`), - ), - MakeHandler: handleCoderWhoami, - }, - { - Tool: mcp.NewTool("coder_list_templates", - mcp.WithDescription(`List all templates available on the Coder deployment. -Returns JSON with detailed information about each template, including: -- Template name, ID, and description -- Creation/modification timestamps -- Version information -- Associated organization - -Use this tool to discover available templates before creating workspaces. -Templates define the infrastructure and configuration for workspaces. - -Common errors: -- Authentication failure: Check user permissions -- No templates available: The deployment may not have any templates configured`), - ), - MakeHandler: handleCoderListTemplates, - }, - { - Tool: mcp.NewTool("coder_list_workspaces", - mcp.WithDescription(`List workspaces available on the Coder deployment. -Returns JSON with workspace metadata including status, resources, and configurations. -Use this before other workspace operations to find valid workspace names/IDs. -Results are paginated - use offset and limit parameters for large deployments. - -Common errors: -- Authentication failure: Check user permissions -- Invalid owner parameter: Ensure the owner exists`), - mcp.WithString(`owner`, mcp.Description(`The username of the workspace owner to filter by. -Defaults to "me" which represents the currently authenticated user. -Use this to view workspaces belonging to other users (requires appropriate permissions). -Special value: "me" - List workspaces owned by the authenticated user.`), mcp.DefaultString(codersdk.Me)), - mcp.WithNumber(`offset`, mcp.Description(`Pagination offset - the starting index for listing workspaces. -Used with the 'limit' parameter to implement pagination. -For example, to get the second page of results with 10 items per page, use offset=10. -Defaults to 0 (first page).`), mcp.DefaultNumber(0)), - mcp.WithNumber(`limit`, mcp.Description(`Maximum number of workspaces to return in a single request. -Used with the 'offset' parameter to implement pagination. -Higher values return more results but may increase response time. -Valid range: 1-100. Defaults to 10.`), mcp.DefaultNumber(10)), - ), - MakeHandler: handleCoderListWorkspaces, - }, - { - Tool: mcp.NewTool("coder_get_workspace", - mcp.WithDescription(`Get detailed information about a specific Coder workspace. -Returns comprehensive JSON with the workspace's configuration, status, and resources. -Use this to check workspace status before performing operations like exec or start/stop. -The response includes the latest build status, agent connectivity, and resource details. - -Common errors: -- Workspace not found: Check the workspace name or ID -- Permission denied: The user may not have access to this workspace`), - mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to retrieve. -Can be specified as either: -- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" -- Workspace name: e.g., "dev", "python-project" -Use coder_list_workspaces first if you're not sure about available workspace names.`), mcp.Required()), - ), - MakeHandler: handleCoderGetWorkspace, - }, - { - Tool: mcp.NewTool("coder_workspace_exec", - mcp.WithDescription(`Execute a shell command in a remote Coder workspace. -Runs the specified command and returns the complete output (stdout/stderr). -Use this for file operations, running build commands, or checking workspace state. -The workspace must be running with a connected agent for this to succeed. - -Before using this tool: -1. Verify the workspace is running using coder_get_workspace -2. Start the workspace if needed using coder_start_workspace - -Common errors: -- Workspace not running: Start the workspace first -- Command not allowed: Check security restrictions -- Agent not connected: The workspace may still be starting up`), - mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name where the command will execute. -Can be specified as either: -- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" -- Workspace name: e.g., "dev", "python-project" -The workspace must be running with a connected agent. -Use coder_get_workspace first to check the workspace status.`), mcp.Required()), - mcp.WithString("command", mcp.Description(`The shell command to execute in the workspace. -Commands are executed in the default shell of the workspace. - -Examples: -- "ls -la" - List files with details -- "cd /path/to/directory && command" - Execute in specific directory -- "cat ~/.bashrc" - View a file's contents -- "python -m pip list" - List installed Python packages - -Note: Very long-running commands may time out.`), mcp.Required()), - ), - MakeHandler: handleCoderWorkspaceExec, - }, - { - Tool: mcp.NewTool("coder_workspace_transition", - mcp.WithDescription(`Start or stop a running Coder workspace. -If stopping, initiates the workspace stop transition. -Only works on workspaces that are currently running or failed. - -If starting, initiates the workspace start transition. -Only works on workspaces that are currently stopped or failed. - -Stopping or starting a workspace is an asynchronous operation - it may take several minutes to complete. - -After calling this tool: -1. Use coder_report_task to inform the user that the workspace is stopping or starting -2. Use coder_get_workspace periodically to check for completion - -Common errors: -- Workspace already started/starting/stopped/stopping: No action needed -- Cancellation failed: There may be issues with the underlying infrastructure -- User doesn't own workspace: Permission issues`), - mcp.WithString("workspace", mcp.Description(`The workspace ID (UUID) or name to start or stop. -Can be specified as either: -- Full UUID: e.g., "8a0b9c7d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" -- Workspace name: e.g., "dev", "python-project" -The workspace must be in a running state to be stopped, or in a stopped or failed state to be started. -Use coder_get_workspace first to check the current workspace status.`), mcp.Required()), - mcp.WithString("transition", mcp.Description(`The transition to apply to the workspace. -Can be either "start" or "stop".`)), - ), - MakeHandler: handleCoderWorkspaceTransition, - }, -} - -// ToolDeps contains all dependencies needed by tool handlers -type ToolDeps struct { - Client *codersdk.Client - Logger *slog.Logger -} - -// ToolHandler associates a tool with its handler creation function -type ToolHandler struct { - Tool mcp.Tool - MakeHandler func(ToolDeps) server.ToolHandlerFunc -} - -// ToolRegistry is a map of available tools with their handler creation -// functions -type ToolRegistry []ToolHandler - -// WithOnlyAllowed returns a new ToolRegistry containing only the tools -// specified in the allowed list. -func (r ToolRegistry) WithOnlyAllowed(allowed ...string) ToolRegistry { - if len(allowed) == 0 { - return []ToolHandler{} - } - - filtered := make(ToolRegistry, 0, len(r)) - - // The overhead of a map lookup is likely higher than a linear scan - // for a small number of tools. - for _, entry := range r { - if slices.Contains(allowed, entry.Tool.Name) { - filtered = append(filtered, entry) - } - } - return filtered -} - -// Register registers all tools in the registry with the given tool adder -// and dependencies. -func (r ToolRegistry) Register(srv *server.MCPServer, deps ToolDeps) { - for _, entry := range r { - srv.AddTool(entry.Tool, entry.MakeHandler(deps)) - } -} - -// AllTools returns all available tools. -func AllTools() ToolRegistry { - // return a copy of allTools to avoid mutating the original - return slices.Clone(allTools) -} From a8e908a462a8bb06a9f3d1d94104c77a5640959b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 31 Mar 2025 17:22:45 +0100 Subject: [PATCH 18/20] actually print the diff --- cli/clitest/golden.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cli/clitest/golden.go b/cli/clitest/golden.go index ca454be798fe4..d4401d6c5d5f9 100644 --- a/cli/clitest/golden.go +++ b/cli/clitest/golden.go @@ -13,6 +13,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/config" @@ -118,9 +119,7 @@ func TestGoldenFile(t *testing.T, fileName string, actual []byte, replacements m require.NoError(t, err, "read golden file, run \"make gen/golden-files\" and commit the changes") expected = normalizeGoldenFile(t, expected) - if diff := cmp.Diff(string(expected), string(actual)); diff != "" { - t.Fatalf("golden file mismatch: %s, run \"make gen/golden-files\", verify and commit the changes", goldenPath) - } + 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) } // normalizeGoldenFile replaces any strings that are system or timing dependent From 364ee2fc699948a81024588311af5d24d142fc3e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 31 Mar 2025 17:29:25 +0100 Subject: [PATCH 19/20] fix golden file diff --- cli/testdata/TestProvisioners_Golden/list.golden | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/testdata/TestProvisioners_Golden/list.golden b/cli/testdata/TestProvisioners_Golden/list.golden index 35844d8b9c50e..3f50f90746744 100644 --- a/cli/testdata/TestProvisioners_Golden/list.golden +++ b/cli/testdata/TestProvisioners_Golden/list.golden @@ -1,4 +1,4 @@ -ID CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS CURRENT JOB ID CURRENT JOB STATUS PREVIOUS JOB ID PREVIOUS JOB STATUS ORGANIZATION +ID CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS CURRENT JOB ID CURRENT JOB STATUS PREVIOUS JOB ID PREVIOUS JOB STATUS ORGANIZATION 00000000-0000-0000-aaaa-000000000000 ====[timestamp]===== ====[timestamp]===== default-provisioner v0.0.0-devel map[owner: scope:organization] built-in idle 00000000-0000-0000-bbbb-000000000001 succeeded Coder 00000000-0000-0000-aaaa-000000000001 ====[timestamp]===== ====[timestamp]===== provisioner-1 v0.0.0 map[foo:bar owner: scope:organization] built-in busy 00000000-0000-0000-bbbb-000000000002 running Coder 00000000-0000-0000-aaaa-000000000002 ====[timestamp]===== ====[timestamp]===== provisioner-2 v0.0.0 map[owner: scope:organization] built-in offline 00000000-0000-0000-bbbb-000000000003 succeeded Coder From 883042da153fd6327b0374cd721b5d9e1f8b3a33 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 31 Mar 2025 17:38:14 +0100 Subject: [PATCH 20/20] please be fixed --- cli/testdata/coder_provisioner_list.golden | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/testdata/coder_provisioner_list.golden b/cli/testdata/coder_provisioner_list.golden index e34db5605fd81..64941eebf5b89 100644 --- a/cli/testdata/coder_provisioner_list.golden +++ b/cli/testdata/coder_provisioner_list.golden @@ -1,2 +1,2 @@ -CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS +CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS ====[timestamp]===== ====[timestamp]===== built-in test v0.0.0-devel idle map[owner: scope:organization]