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] pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy