From dd595779367711fde34283c3b3418824213df779 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 10 Apr 2025 19:08:25 +0100 Subject: [PATCH 1/6] feat(codersdk): add toolsdk and replace existing mcp server tool impl --- cli/exp_mcp.go | 87 ++- cli/exp_mcp_test.go | 7 +- coderd/database/dbfake/dbfake.go | 51 +- codersdk/toolsdk/toolsdk.go | 1241 ++++++++++++++++++++++++++++++ codersdk/toolsdk/toolsdk_test.go | 357 +++++++++ mcp/mcp.go | 600 --------------- mcp/mcp_test.go | 397 ---------- 7 files changed, 1694 insertions(+), 1046 deletions(-) create mode 100644 codersdk/toolsdk/toolsdk.go create mode 100644 codersdk/toolsdk/toolsdk_test.go delete mode 100644 mcp/mcp.go delete mode 100644 mcp/mcp_test.go diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index 2726f2a3d53cc..2ff681e2510d8 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -6,19 +6,19 @@ import ( "errors" "os" "path/filepath" + "slices" "strings" + "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/spf13/afero" "golang.org/x/xerrors" - "cdr.dev/slog" - "cdr.dev/slog/sloggers/sloghuman" "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" - codermcp "github.com/coder/coder/v2/mcp" + "github.com/coder/coder/v2/codersdk/toolsdk" "github.com/coder/serpent" ) @@ -365,6 +365,8 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct ctx, cancel := context.WithCancel(inv.Context()) defer cancel() + fs := afero.NewOsFs() + me, err := client.User(ctx, codersdk.Me) if err != nil { cliui.Errorf(inv.Stderr, "Failed to log in to the Coder deployment.") @@ -397,40 +399,36 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct server.WithInstructions(instructions), ) - // Create a separate logger for the tools. - toolLogger := slog.Make(sloghuman.Sink(invStderr)) - - toolDeps := codermcp.ToolDeps{ - Client: client, - Logger: &toolLogger, - AppStatusSlug: appStatusSlug, - AgentClient: agentsdk.New(client.URL), - } - + // Create a new context for the tools with all relevant information. + clientCtx := toolsdk.WithClient(ctx, client) // Get the workspace agent token from the environment. - agentToken, ok := os.LookupEnv("CODER_AGENT_TOKEN") - if ok && agentToken != "" { - toolDeps.AgentClient.SetSessionToken(agentToken) + if agentToken, err := getAgentToken(fs); err == nil && agentToken != "" { + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(agentToken) + clientCtx = toolsdk.WithAgentClient(clientCtx, agentClient) } else { cliui.Warnf(inv.Stderr, "CODER_AGENT_TOKEN is not set, task reporting will not be available") } - if appStatusSlug == "" { + if appStatusSlug != "" { cliui.Warnf(inv.Stderr, "CODER_MCP_APP_STATUS_SLUG is not set, task reporting will not be available.") + } else { + clientCtx = toolsdk.WithWorkspaceAppStatusSlug(clientCtx, appStatusSlug) } // Register tools based on the allowlist (if specified) - reg := codermcp.AllTools() - if len(allowedTools) > 0 { - reg = reg.WithOnlyAllowed(allowedTools...) + for _, tool := range toolsdk.All { + if len(allowedTools) == 0 || slices.ContainsFunc(allowedTools, func(t string) bool { + return t == tool.Tool.Name + }) { + mcpSrv.AddTools(mcpFromSDK(tool)) + } } - reg.Register(mcpSrv, toolDeps) - srv := server.NewStdioServer(mcpSrv) done := make(chan error) go func() { defer close(done) - srvErr := srv.Listen(ctx, invStdin, invStdout) + srvErr := srv.Listen(clientCtx, invStdin, invStdout) done <- srvErr }() @@ -674,7 +672,7 @@ func indexOf(s, substr string) int { func getAgentToken(fs afero.Fs) (string, error) { token, ok := os.LookupEnv("CODER_AGENT_TOKEN") - if ok { + if ok && token != "" { return token, nil } tokenFile, ok := os.LookupEnv("CODER_AGENT_TOKEN_FILE") @@ -687,3 +685,44 @@ func getAgentToken(fs afero.Fs) (string, error) { } return string(bs), nil } + +// mcpFromSDK adapts a toolsdk.Tool to go-mcp's server.ServerTool. +// It assumes that the tool responds with a valid JSON object. +func mcpFromSDK(sdkTool toolsdk.Tool[any]) server.ServerTool { + return server.ServerTool{ + Tool: mcp.Tool{ + Name: sdkTool.Tool.Name, + Description: sdkTool.Description, + InputSchema: mcp.ToolInputSchema{ + Type: "object", // Default of mcp.NewTool() + Properties: sdkTool.Schema.Properties, + Required: sdkTool.Schema.Required, + }, + }, + Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + result, err := sdkTool.Handler(ctx, request.Params.Arguments) + if err != nil { + return nil, err + } + var sb strings.Builder + if err := json.NewEncoder(&sb).Encode(result); err == nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(sb.String()), + }, + }, nil + } + // If the result is not JSON, return it as a string. + // This is a fallback for tools that return non-JSON data. + resultStr, ok := result.(string) + if !ok { + return nil, xerrors.Errorf("tool call result is neither valid JSON or a string, got: %T", result) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(resultStr), + }, + }, nil + }, + } +} diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go index 20ced5761f42c..4a8ae34a603b8 100644 --- a/cli/exp_mcp_test.go +++ b/cli/exp_mcp_test.go @@ -39,12 +39,13 @@ func TestExpMcpServer(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", "server", "--allowed-tools=coder_whoami,coder_list_templates") + inv, root := clitest.New(t, "exp", "mcp", "server", "--allowed-tools=coder_whoami") inv = inv.WithContext(cancelCtx) pty := ptytest.New(t) inv.Stdin = pty.Input() inv.Stdout = pty.Output() + // nolint: gocritic // not the focus of this test clitest.SetupConfig(t, client, root) cmdDone := make(chan struct{}) @@ -73,13 +74,13 @@ func TestExpMcpServer(t *testing.T) { } err := json.Unmarshal([]byte(output), &toolsResponse) require.NoError(t, err) - require.Len(t, toolsResponse.Result.Tools, 2, "should have exactly 2 tools") + require.Len(t, toolsResponse.Result.Tools, 1, "should have exactly 1 tool") 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) + require.Equal(t, []string{"coder_whoami"}, foundTools) }) t.Run("OK", func(t *testing.T) { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 197502ebac42c..abadd78f07b36 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -287,23 +287,25 @@ type TemplateVersionResponse struct { } type TemplateVersionBuilder struct { - t testing.TB - db database.Store - seed database.TemplateVersion - fileID uuid.UUID - ps pubsub.Pubsub - resources []*sdkproto.Resource - params []database.TemplateVersionParameter - promote bool + t testing.TB + db database.Store + seed database.TemplateVersion + fileID uuid.UUID + ps pubsub.Pubsub + resources []*sdkproto.Resource + params []database.TemplateVersionParameter + promote bool + autoCreateTemplate bool } // TemplateVersion generates a template version and optionally a parent // template if no template ID is set on the seed. func TemplateVersion(t testing.TB, db database.Store) TemplateVersionBuilder { return TemplateVersionBuilder{ - t: t, - db: db, - promote: true, + t: t, + db: db, + promote: true, + autoCreateTemplate: true, } } @@ -337,6 +339,13 @@ func (t TemplateVersionBuilder) Params(ps ...database.TemplateVersionParameter) return t } +func (t TemplateVersionBuilder) SkipCreateTemplate() TemplateVersionBuilder { + // nolint: revive // returns modified struct + t.autoCreateTemplate = false + t.promote = false + return t +} + func (t TemplateVersionBuilder) Do() TemplateVersionResponse { t.t.Helper() @@ -347,7 +356,7 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse { t.fileID = takeFirst(t.fileID, uuid.New()) var resp TemplateVersionResponse - if t.seed.TemplateID.UUID == uuid.Nil { + if t.seed.TemplateID.UUID == uuid.Nil && t.autoCreateTemplate { resp.Template = dbgen.Template(t.t, t.db, database.Template{ ActiveVersionID: t.seed.ID, OrganizationID: t.seed.OrganizationID, @@ -360,16 +369,14 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse { } version := dbgen.TemplateVersion(t.t, t.db, t.seed) - - // Always make this version the active version. We can easily - // add a conditional to the builder to opt out of this when - // necessary. - err := t.db.UpdateTemplateActiveVersionByID(ownerCtx, database.UpdateTemplateActiveVersionByIDParams{ - ID: t.seed.TemplateID.UUID, - ActiveVersionID: t.seed.ID, - UpdatedAt: dbtime.Now(), - }) - require.NoError(t.t, err) + if t.promote { + err := t.db.UpdateTemplateActiveVersionByID(ownerCtx, database.UpdateTemplateActiveVersionByIDParams{ + ID: t.seed.TemplateID.UUID, + ActiveVersionID: t.seed.ID, + UpdatedAt: dbtime.Now(), + }) + require.NoError(t.t, err) + } payload, err := json.Marshal(provisionerdserver.TemplateVersionImportJob{ TemplateVersionID: t.seed.ID, diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go new file mode 100644 index 0000000000000..c28efc83dfd36 --- /dev/null +++ b/codersdk/toolsdk/toolsdk.go @@ -0,0 +1,1241 @@ +package toolsdk + +import ( + "archive/tar" + "context" + "io" + + "github.com/google/uuid" + "github.com/kylecarbs/aisdk-go" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" +) + +// HandlerFunc is a function that handles a tool call. +type HandlerFunc[T any] func(ctx context.Context, args map[string]any) (T, error) + +type Tool[T any] struct { + aisdk.Tool + Handler HandlerFunc[T] +} + +// Generic returns a Tool[any] that can be used to call the tool. +func (t Tool[T]) Generic() Tool[any] { + return Tool[any]{ + Tool: t.Tool, + Handler: func(ctx context.Context, args map[string]any) (any, error) { + return t.Handler(ctx, args) + }, + } +} + +var ( + All = []Tool[any]{ + ReportTask.Generic(), + CreateWorkspace.Generic(), + ListWorkspaces.Generic(), + ListTemplates.Generic(), + ListTemplateVersionParameters.Generic(), + Whoami.Generic(), + CreateWorkspaceBuild.Generic(), + UploadTarFile.Generic(), + CreateTemplateVersion.Generic(), + CreateTemplate.Generic(), + GetTemplateVersionLogs.Generic(), + UpdateTemplateActiveVersion.Generic(), + DeleteTemplate.Generic(), + GetWorkspaceAgentLogs.Generic(), + GetWorkspaceBuildLogs.Generic(), + } + + ReportTask = Tool[string]{ + Tool: aisdk.Tool{ + Name: "coder_report_task", + Description: "Report progress on a user task in Coder.", + Schema: aisdk.Schema{ + Properties: map[string]any{ + "summary": map[string]any{ + "type": "string", + "description": "A concise summary of your current progress on the task. This must be less than 160 characters in length.", + }, + "link": map[string]any{ + "type": "string", + "description": "A link to a relevant resource, such as a PR or issue.", + }, + "emoji": map[string]any{ + "type": "string", + "description": "An emoji that visually represents your current progress. Choose an emoji that helps the user understand your current status at a glance.", + }, + "state": map[string]any{ + "type": "string", + "description": "The state of your task. This can be one of the following: working, complete, or failure. Select the state that best represents your current progress.", + "enum": []string{ + string(codersdk.WorkspaceAppStatusStateWorking), + string(codersdk.WorkspaceAppStatusStateComplete), + string(codersdk.WorkspaceAppStatusStateFailure), + }, + }, + }, + Required: []string{"summary", "link", "emoji", "state"}, + }, + }, + Handler: func(ctx context.Context, args map[string]any) (string, error) { + agentClient, err := agentClientFromContext(ctx) + if err != nil { + return "", xerrors.New("tool unavailable as CODER_AGENT_TOKEN or CODER_AGENT_TOKEN_FILE not set") + } + appSlug, ok := workspaceAppStatusSlugFromContext(ctx) + if !ok { + return "", xerrors.New("workspace app status slug not found in context") + } + summary, ok := args["summary"].(string) + if !ok { + return "", xerrors.New("summary must be a string") + } + if len(summary) > 160 { + return "", xerrors.New("summary must be less than 160 characters") + } + link, ok := args["link"].(string) + if !ok { + return "", xerrors.New("link must be a string") + } + emoji, ok := args["emoji"].(string) + if !ok { + return "", xerrors.New("emoji must be a string") + } + state, ok := args["state"].(string) + if !ok { + return "", xerrors.New("state must be a string") + } + + if err := agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ + AppSlug: appSlug, + Message: summary, + URI: link, + Icon: emoji, + NeedsUserAttention: false, // deprecated, to be removed later + State: codersdk.WorkspaceAppStatusState(state), + }); err != nil { + return "", err + } + return "Thanks for reporting!", nil + }, + } + + GetWorkspace = Tool[codersdk.Workspace]{ + Tool: aisdk.Tool{ + Name: "coder_get_workspace", + Description: `Get a workspace by ID. + +This returns more data than list_workspaces to reduce token usage.`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "workspace_id": map[string]any{ + "type": "string", + }, + }, + Required: []string{"workspace_id"}, + }, + }, + Handler: func(ctx context.Context, args map[string]any) (codersdk.Workspace, error) { + client, err := clientFromContext(ctx) + if err != nil { + return codersdk.Workspace{}, err + } + workspaceID, err := uuidFromArgs(args, "workspace_id") + if err != nil { + return codersdk.Workspace{}, err + } + return client.Workspace(ctx, workspaceID) + }, + } + + CreateWorkspace = Tool[codersdk.Workspace]{ + Tool: aisdk.Tool{ + Name: "coder_create_workspace", + Description: `Create a new workspace in Coder. + +If a user is asking to "test a template", they are typically referring +to creating a workspace from a template to ensure the infrastructure +is provisioned correctly and the agent can connect to the control plane. +`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "user": map[string]any{ + "type": "string", + "description": "Username or ID of the user to create the workspace for. Use the `me` keyword to create a workspace for the authenticated user.", + }, + "template_version_id": map[string]any{ + "type": "string", + "description": "ID of the template version to create the workspace from.", + }, + "name": map[string]any{ + "type": "string", + "description": "Name of the workspace to create.", + }, + "rich_parameters": map[string]any{ + "type": "object", + "description": "Key/value pairs of rich parameters to pass to the template version to create the workspace.", + }, + }, + Required: []string{"user", "template_version_id", "name", "rich_parameters"}, + }, + }, + Handler: func(ctx context.Context, args map[string]any) (codersdk.Workspace, error) { + client, err := clientFromContext(ctx) + if err != nil { + return codersdk.Workspace{}, err + } + templateVersionID, err := uuidFromArgs(args, "template_version_id") + if err != nil { + return codersdk.Workspace{}, err + } + name, ok := args["name"].(string) + if !ok { + return codersdk.Workspace{}, xerrors.New("workspace name must be a string") + } + workspace, err := client.CreateUserWorkspace(ctx, "me", codersdk.CreateWorkspaceRequest{ + TemplateVersionID: templateVersionID, + Name: name, + }) + if err != nil { + return codersdk.Workspace{}, err + } + return workspace, nil + }, + } + + ListWorkspaces = Tool[[]MinimalWorkspace]{ + Tool: aisdk.Tool{ + Name: "coder_list_workspaces", + Description: "Lists workspaces for the authenticated user.", + Schema: aisdk.Schema{ + Properties: map[string]any{ + "owner": map[string]any{ + "type": "string", + "description": "The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.", + }, + }, + }, + }, + Handler: func(ctx context.Context, args map[string]any) ([]MinimalWorkspace, error) { + client, err := clientFromContext(ctx) + if err != nil { + return nil, err + } + owner, ok := args["owner"].(string) + if !ok { + owner = codersdk.Me + } + workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + Owner: owner, + }) + if err != nil { + return nil, err + } + minimalWorkspaces := make([]MinimalWorkspace, len(workspaces.Workspaces)) + for i, workspace := range workspaces.Workspaces { + minimalWorkspaces[i] = MinimalWorkspace{ + ID: workspace.ID.String(), + Name: workspace.Name, + TemplateID: workspace.TemplateID.String(), + TemplateName: workspace.TemplateName, + TemplateDisplayName: workspace.TemplateDisplayName, + TemplateIcon: workspace.TemplateIcon, + TemplateActiveVersionID: workspace.TemplateActiveVersionID, + Outdated: workspace.Outdated, + } + } + return minimalWorkspaces, nil + }, + } + + ListTemplates = Tool[[]MinimalTemplate]{ + Tool: aisdk.Tool{ + Name: "coder_list_templates", + Description: "Lists templates for the authenticated user.", + }, + Handler: func(ctx context.Context, _ map[string]any) ([]MinimalTemplate, error) { + client, err := clientFromContext(ctx) + if err != nil { + return nil, err + } + templates, err := client.Templates(ctx, codersdk.TemplateFilter{}) + if err != nil { + return nil, err + } + minimalTemplates := make([]MinimalTemplate, len(templates)) + for i, template := range templates { + minimalTemplates[i] = MinimalTemplate{ + DisplayName: template.DisplayName, + ID: template.ID.String(), + Name: template.Name, + Description: template.Description, + ActiveVersionID: template.ActiveVersionID, + ActiveUserCount: template.ActiveUserCount, + } + } + return minimalTemplates, nil + }, + } + + ListTemplateVersionParameters = Tool[[]codersdk.TemplateVersionParameter]{ + Tool: aisdk.Tool{ + Name: "coder_template_version_parameters", + Description: "Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.", + Schema: aisdk.Schema{ + Properties: map[string]any{ + "template_version_id": map[string]any{ + "type": "string", + }, + }, + Required: []string{"template_version_id"}, + }, + }, + Handler: func(ctx context.Context, args map[string]any) ([]codersdk.TemplateVersionParameter, error) { + client, err := clientFromContext(ctx) + if err != nil { + return nil, err + } + templateVersionID, err := uuidFromArgs(args, "template_version_id") + if err != nil { + return nil, err + } + parameters, err := client.TemplateVersionRichParameters(ctx, templateVersionID) + if err != nil { + return nil, err + } + return parameters, nil + }, + } + + Whoami = Tool[codersdk.User]{ + Tool: aisdk.Tool{ + Name: "coder_whoami", + Description: "Get the currently authenticated user, similar to the `whoami` command.", + }, + Handler: func(ctx context.Context, _ map[string]any) (codersdk.User, error) { + client, err := clientFromContext(ctx) + if err != nil { + return codersdk.User{}, err + } + return client.User(ctx, "me") + }, + } + + CreateWorkspaceBuild = Tool[codersdk.WorkspaceBuild]{ + Tool: aisdk.Tool{ + Name: "coder_create_workspace_build", + Description: "Create a new workspace build for an existing workspace. Use this to start, stop, or delete.", + Schema: aisdk.Schema{ + Properties: map[string]any{ + "workspace_id": map[string]any{ + "type": "string", + }, + "transition": map[string]any{ + "type": "string", + "description": "The transition to perform. Must be one of: start, stop, delete", + }, + }, + Required: []string{"workspace_id", "transition"}, + }, + }, + Handler: func(ctx context.Context, args map[string]any) (codersdk.WorkspaceBuild, error) { + client, err := clientFromContext(ctx) + if err != nil { + return codersdk.WorkspaceBuild{}, err + } + workspaceID, err := uuidFromArgs(args, "workspace_id") + if err != nil { + return codersdk.WorkspaceBuild{}, err + } + rawTransition, ok := args["transition"].(string) + if !ok { + return codersdk.WorkspaceBuild{}, xerrors.New("transition must be a string") + } + return client.CreateWorkspaceBuild(ctx, workspaceID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransition(rawTransition), + }) + }, + } + + CreateTemplateVersion = Tool[codersdk.TemplateVersion]{ + Tool: aisdk.Tool{ + Name: "coder_create_template_version", + Description: `Create a new template version. This is a precursor to creating a template, or you can update an existing template. + +Templates are Terraform defining a development environment. The provisioned infrastructure must run +an Agent that connects to the Coder Control Plane to provide a rich experience. + +Here are some strict rules for creating a template version: +- YOU MUST NOT use "variable" or "output" blocks in the Terraform code. +- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully. + +When a template version is created, a Terraform Plan occurs that ensures the infrastructure +_could_ be provisioned, but actual provisioning occurs when a workspace is created. + + +The Coder Terraform Provider can be imported like: + +` + "```" + `hcl +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} +` + "```" + ` + +A destroy does not occur when a user stops a workspace, but rather the transition changes: + +` + "```" + `hcl +data "coder_workspace" "me" {} +` + "```" + ` + +This data source provides the following fields: +- id: The UUID of the workspace. +- name: The name of the workspace. +- transition: Either "start" or "stop". +- start_count: A computed count based on the transition field. If "start", this will be 1. + +Access workspace owner information with: + +` + "```" + `hcl +data "coder_workspace_owner" "me" {} +` + "```" + ` + +This data source provides the following fields: +- id: The UUID of the workspace owner. +- name: The name of the workspace owner. +- full_name: The full name of the workspace owner. +- email: The email of the workspace owner. +- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started. +- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string. + +Parameters are defined in the template version. They are rendered in the UI on the workspace creation page: + +` + "```" + `hcl +resource "coder_parameter" "region" { + name = "region" + type = "string" + default = "us-east-1" +} +` + "```" + ` + +This resource accepts the following properties: +- name: The name of the parameter. +- default: The default value of the parameter. +- type: The type of the parameter. Must be one of: "string", "number", "bool", or "list(string)". +- display_name: The displayed name of the parameter as it will appear in the UI. +- description: The description of the parameter as it will appear in the UI. +- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds. +- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error]. +- icon: A URL to an icon to display in the UI. +- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution! +- option: Each option block defines a value for a user to select from. (see below for nested schema) + Required: + - name: The name of the option. + - value: The value of the option. + Optional: + - description: The description of the option as it will appear in the UI. + - icon: A URL to an icon to display in the UI. + +A Workspace Agent runs on provisioned infrastructure to provide access to the workspace: + +` + "```" + `hcl +resource "coder_agent" "dev" { + arch = "amd64" + os = "linux" +} +` + "```" + ` + +This resource accepts the following properties: +- arch: The architecture of the agent. Must be one of: "amd64", "arm64", or "armv7". +- os: The operating system of the agent. Must be one of: "linux", "windows", or "darwin". +- auth: The authentication method for the agent. Must be one of: "token", "google-instance-identity", "aws-instance-identity", or "azure-instance-identity". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start. +- dir: The starting directory when a user creates a shell session. Defaults to "$HOME". +- env: A map of environment variables to set for the agent. +- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use "&" or "screen" to run processes in the background. + +This resource provides the following fields: +- id: The UUID of the agent. +- init_script: The script to run on provisioned infrastructure to fetch and start the agent. +- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent. + +The agent MUST be installed and started using the init_script. + +Expose terminal or HTTP applications running in a workspace with: + +` + "```" + `hcl +resource "coder_app" "dev" { + agent_id = coder_agent.dev.id + slug = "my-app-name" + display_name = "My App" + icon = "https://my-app.com/icon.svg" + url = "http://127.0.0.1:3000" +} +` + "```" + ` + +This resource accepts the following properties: +- agent_id: The ID of the agent to attach the app to. +- slug: The slug of the app. +- display_name: The displayed name of the app as it will appear in the UI. +- icon: A URL to an icon to display in the UI. +- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both. +- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both. +- external: Whether this app is an external app. If true, the url will be opened in a new tab. + + +The Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario, +the user will need to provide credentials to the Coder Server before the workspace can be provisioned. + +Here are examples of provisioning the Coder Agent on specific infrastructure providers: + + +// The agent is configured with "aws-instance-identity" auth. +terraform { + required_providers { + cloudinit = { + source = "hashicorp/cloudinit" + } + aws = { + source = "hashicorp/aws" + } + } +} + +data "cloudinit_config" "user_data" { + gzip = false + base64_encode = false + boundary = "//" + part { + filename = "cloud-config.yaml" + content_type = "text/cloud-config" + + // Here is the content of the cloud-config.yaml.tftpl file: + // #cloud-config + // cloud_final_modules: + // - [scripts-user, always] + // hostname: ${hostname} + // users: + // - name: ${linux_user} + // sudo: ALL=(ALL) NOPASSWD:ALL + // shell: /bin/bash + content = templatefile("${path.module}/cloud-init/cloud-config.yaml.tftpl", { + hostname = local.hostname + linux_user = local.linux_user + }) + } + + part { + filename = "userdata.sh" + content_type = "text/x-shellscript" + + // Here is the content of the userdata.sh.tftpl file: + // #!/bin/bash + // sudo -u '${linux_user}' sh -c '${init_script}' + content = templatefile("${path.module}/cloud-init/userdata.sh.tftpl", { + linux_user = local.linux_user + + init_script = try(coder_agent.dev[0].init_script, "") + }) + } +} + +resource "aws_instance" "dev" { + ami = data.aws_ami.ubuntu.id + availability_zone = "${data.coder_parameter.region.value}a" + instance_type = data.coder_parameter.instance_type.value + + user_data = data.cloudinit_config.user_data.rendered + tags = { + Name = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}" + } + lifecycle { + ignore_changes = [ami] + } +} + + + +// The agent is configured with "google-instance-identity" auth. +terraform { + required_providers { + google = { + source = "hashicorp/google" + } + } +} + +resource "google_compute_instance" "dev" { + zone = module.gcp_region.value + count = data.coder_workspace.me.start_count + name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root" + machine_type = "e2-medium" + network_interface { + network = "default" + access_config { + // Ephemeral public IP + } + } + boot_disk { + auto_delete = false + source = google_compute_disk.root.name + } + service_account { + email = data.google_compute_default_service_account.default.email + scopes = ["cloud-platform"] + } + # The startup script runs as root with no $HOME environment set up, so instead of directly + # running the agent init script, create a user (with a homedir, default shell and sudo + # permissions) and execute the init script as that user. + metadata_startup_script = </dev/null 2>&1; then + useradd -m -s /bin/bash "${local.linux_user}" + echo "${local.linux_user} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/coder-user +fi + +exec sudo -u "${local.linux_user}" sh -c '${coder_agent.main.init_script}' +EOMETA +} + + + +// The agent is configured with "azure-instance-identity" auth. +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + } + cloudinit = { + source = "hashicorp/cloudinit" + } + } +} + +data "cloudinit_config" "user_data" { + gzip = false + base64_encode = true + + boundary = "//" + + part { + filename = "cloud-config.yaml" + content_type = "text/cloud-config" + + // Here is the content of the cloud-config.yaml.tftpl file: + // #cloud-config + // cloud_final_modules: + // - [scripts-user, always] + // bootcmd: + // # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117 + // - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done + // device_aliases: + // homedir: /dev/disk/azure/scsi1/lun10 + // disk_setup: + // homedir: + // table_type: gpt + // layout: true + // fs_setup: + // - label: coder_home + // filesystem: ext4 + // device: homedir.1 + // mounts: + // - ["LABEL=coder_home", "/home/${username}"] + // hostname: ${hostname} + // users: + // - name: ${username} + // sudo: ["ALL=(ALL) NOPASSWD:ALL"] + // groups: sudo + // shell: /bin/bash + // packages: + // - git + // write_files: + // - path: /opt/coder/init + // permissions: "0755" + // encoding: b64 + // content: ${init_script} + // - path: /etc/systemd/system/coder-agent.service + // permissions: "0644" + // content: | + // [Unit] + // Description=Coder Agent + // After=network-online.target + // Wants=network-online.target + + // [Service] + // User=${username} + // ExecStart=/opt/coder/init + // Restart=always + // RestartSec=10 + // TimeoutStopSec=90 + // KillMode=process + + // OOMScoreAdjust=-900 + // SyslogIdentifier=coder-agent + + // [Install] + // WantedBy=multi-user.target + // runcmd: + // - chown ${username}:${username} /home/${username} + // - systemctl enable coder-agent + // - systemctl start coder-agent + content = templatefile("${path.module}/cloud-init/cloud-config.yaml.tftpl", { + username = "coder" # Ensure this user/group does not exist in your VM image + init_script = base64encode(coder_agent.main.init_script) + hostname = lower(data.coder_workspace.me.name) + }) + } +} + +resource "azurerm_linux_virtual_machine" "main" { + count = data.coder_workspace.me.start_count + name = "vm" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + size = data.coder_parameter.instance_type.value + // cloud-init overwrites this, so the value here doesn't matter + admin_username = "adminuser" + admin_ssh_key { + public_key = tls_private_key.dummy.public_key_openssh + username = "adminuser" + } + + network_interface_ids = [ + azurerm_network_interface.main.id, + ] + computer_name = lower(data.coder_workspace.me.name) + os_disk { + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + } + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-focal" + sku = "20_04-lts-gen2" + version = "latest" + } + user_data = data.cloudinit_config.user_data.rendered +} + + + +terraform { + required_providers { + coder = { + source = "kreuzwerker/docker" + } + } +} + +// The agent is configured with "token" auth. + +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = "codercom/enterprise-base:ubuntu" + # Uses lower() to avoid Docker restriction on container names. + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + # Hostname makes the shell more user friendly: coder@my-workspace:~$ + hostname = data.coder_workspace.me.name + # Use the docker gateway if the access URL is 127.0.0.1. + entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")] + env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"] + host { + host = "host.docker.internal" + ip = "host-gateway" + } + volumes { + container_path = "/home/coder" + volume_name = docker_volume.home_volume.name + read_only = false + } +} + + + +// The agent is configured with "token" auth. + +resource "kubernetes_deployment" "main" { + count = data.coder_workspace.me.start_count + depends_on = [ + kubernetes_persistent_volume_claim.home + ] + wait_for_rollout = false + metadata { + name = "coder-${data.coder_workspace.me.id}" + } + + spec { + replicas = 1 + strategy { + type = "Recreate" + } + + template { + spec { + security_context { + run_as_user = 1000 + fs_group = 1000 + run_as_non_root = true + } + + container { + name = "dev" + image = "codercom/enterprise-base:ubuntu" + image_pull_policy = "Always" + command = ["sh", "-c", coder_agent.main.init_script] + security_context { + run_as_user = "1000" + } + env { + name = "CODER_AGENT_TOKEN" + value = coder_agent.main.token + } + } + } + } + } +} + + +The file_id provided is a reference to a tar file you have uploaded containing the Terraform. +`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "template_id": map[string]any{ + "type": "string", + }, + "file_id": map[string]any{ + "type": "string", + }, + }, + Required: []string{"file_id"}, + }, + }, + Handler: func(ctx context.Context, args map[string]any) (codersdk.TemplateVersion, error) { + client, err := clientFromContext(ctx) + if err != nil { + return codersdk.TemplateVersion{}, err + } + me, err := client.User(ctx, "me") + if err != nil { + return codersdk.TemplateVersion{}, err + } + fileID, err := uuidFromArgs(args, "file_id") + if err != nil { + return codersdk.TemplateVersion{}, err + } + var templateID uuid.UUID + if args["template_id"] != nil { + templateID, err = uuidFromArgs(args, "template_id") + if err != nil { + return codersdk.TemplateVersion{}, err + } + } + templateVersion, err := client.CreateTemplateVersion(ctx, me.OrganizationIDs[0], codersdk.CreateTemplateVersionRequest{ + Message: "Created by AI", + StorageMethod: codersdk.ProvisionerStorageMethodFile, + FileID: fileID, + Provisioner: codersdk.ProvisionerTypeTerraform, + TemplateID: templateID, + }) + if err != nil { + return codersdk.TemplateVersion{}, err + } + return templateVersion, nil + }, + } + + GetWorkspaceAgentLogs = Tool[[]string]{ + Tool: aisdk.Tool{ + Name: "coder_get_workspace_agent_logs", + Description: `Get the logs of a workspace agent. + +More logs may appear after this call. It does not wait for the agent to finish.`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "workspace_agent_id": map[string]any{ + "type": "string", + }, + }, + Required: []string{"workspace_agent_id"}, + }, + }, + Handler: func(ctx context.Context, args map[string]any) ([]string, error) { + client, err := clientFromContext(ctx) + if err != nil { + return nil, err + } + workspaceAgentID, err := uuidFromArgs(args, "workspace_agent_id") + if err != nil { + return nil, err + } + logs, closer, err := client.WorkspaceAgentLogsAfter(ctx, workspaceAgentID, 0, false) + if err != nil { + return nil, err + } + defer closer.Close() + var acc []string + for logChunk := range logs { + for _, log := range logChunk { + acc = append(acc, log.Output) + } + } + return acc, nil + }, + } + + GetWorkspaceBuildLogs = Tool[[]string]{ + Tool: aisdk.Tool{ + Name: "coder_get_workspace_build_logs", + Description: `Get the logs of a workspace build. + +Useful for checking whether a workspace builds successfully or not.`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "workspace_build_id": map[string]any{ + "type": "string", + }, + }, + Required: []string{"workspace_build_id"}, + }, + }, + Handler: func(ctx context.Context, args map[string]any) ([]string, error) { + client, err := clientFromContext(ctx) + if err != nil { + return nil, err + } + workspaceBuildID, err := uuidFromArgs(args, "workspace_build_id") + if err != nil { + return nil, err + } + logs, closer, err := client.WorkspaceBuildLogsAfter(ctx, workspaceBuildID, 0) + if err != nil { + return nil, err + } + defer closer.Close() + var acc []string + for log := range logs { + acc = append(acc, log.Output) + } + return acc, nil + }, + } + + GetTemplateVersionLogs = Tool[[]string]{ + Tool: aisdk.Tool{ + Name: "coder_get_template_version_logs", + Description: "Get the logs of a template version. This is useful to check whether a template version successfully imports or not.", + Schema: aisdk.Schema{ + Properties: map[string]any{ + "template_version_id": map[string]any{ + "type": "string", + }, + }, + Required: []string{"template_version_id"}, + }, + }, + Handler: func(ctx context.Context, args map[string]any) ([]string, error) { + client, err := clientFromContext(ctx) + if err != nil { + return nil, err + } + templateVersionID, err := uuidFromArgs(args, "template_version_id") + if err != nil { + return nil, err + } + + logs, closer, err := client.TemplateVersionLogsAfter(ctx, templateVersionID, 0) + if err != nil { + return nil, err + } + defer closer.Close() + var acc []string + for log := range logs { + acc = append(acc, log.Output) + } + return acc, nil + }, + } + + UpdateTemplateActiveVersion = Tool[string]{ + Tool: aisdk.Tool{ + Name: "coder_update_template_active_version", + Description: "Update the active version of a template. This is helpful when iterating on templates.", + Schema: aisdk.Schema{ + Properties: map[string]any{ + "template_id": map[string]any{ + "type": "string", + }, + "template_version_id": map[string]any{ + "type": "string", + }, + }, + Required: []string{"template_id", "template_version_id"}, + }, + }, + Handler: func(ctx context.Context, args map[string]any) (string, error) { + client, err := clientFromContext(ctx) + if err != nil { + return "", err + } + templateID, err := uuidFromArgs(args, "template_id") + if err != nil { + return "", err + } + templateVersionID, err := uuidFromArgs(args, "template_version_id") + if err != nil { + return "", err + } + err = client.UpdateActiveTemplateVersion(ctx, templateID, codersdk.UpdateActiveTemplateVersion{ + ID: templateVersionID, + }) + if err != nil { + return "", err + } + return "Successfully updated active version!", nil + }, + } + + UploadTarFile = Tool[codersdk.UploadResponse]{ + Tool: aisdk.Tool{ + Name: "coder_upload_tar_file", + Description: `Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of "create_template_version" to understand template requirements.`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "mime_type": map[string]any{ + "type": "string", + }, + "files": map[string]any{ + "type": "object", + "description": "A map of file names to file contents.", + }, + }, + Required: []string{"mime_type", "files"}, + }, + }, + Handler: func(ctx context.Context, args map[string]any) (codersdk.UploadResponse, error) { + client, err := clientFromContext(ctx) + if err != nil { + return codersdk.UploadResponse{}, err + } + + files, ok := args["files"].(map[string]any) + if !ok { + return codersdk.UploadResponse{}, xerrors.New("files must be a map") + } + + pipeReader, pipeWriter := io.Pipe() + go func() { + defer pipeWriter.Close() + tarWriter := tar.NewWriter(pipeWriter) + for name, content := range files { + contentStr, ok := content.(string) + if !ok { + _ = pipeWriter.CloseWithError(xerrors.New("file content must be a string")) + return + } + header := &tar.Header{ + Name: name, + Size: int64(len(contentStr)), + Mode: 0o644, + } + if err := tarWriter.WriteHeader(header); err != nil { + _ = pipeWriter.CloseWithError(err) + return + } + if _, err := tarWriter.Write([]byte(contentStr)); err != nil { + _ = pipeWriter.CloseWithError(err) + return + } + } + if err := tarWriter.Close(); err != nil { + _ = pipeWriter.CloseWithError(err) + } + }() + + resp, err := client.Upload(ctx, codersdk.ContentTypeTar, pipeReader) + if err != nil { + return codersdk.UploadResponse{}, err + } + return resp, nil + }, + } + + CreateTemplate = Tool[codersdk.Template]{ + Tool: aisdk.Tool{ + Name: "coder_create_template", + Description: "Create a new template in Coder. First, you must create a template version.", + Schema: aisdk.Schema{ + Properties: map[string]any{ + "name": map[string]any{ + "type": "string", + }, + "display_name": map[string]any{ + "type": "string", + }, + "description": map[string]any{ + "type": "string", + }, + "icon": map[string]any{ + "type": "string", + "description": "A URL to an icon to use.", + }, + "version_id": map[string]any{ + "type": "string", + "description": "The ID of the version to use.", + }, + }, + Required: []string{"name", "display_name", "description", "version_id"}, + }, + }, + Handler: func(ctx context.Context, args map[string]any) (codersdk.Template, error) { + client, err := clientFromContext(ctx) + if err != nil { + return codersdk.Template{}, err + } + me, err := client.User(ctx, "me") + if err != nil { + return codersdk.Template{}, err + } + versionID, err := uuidFromArgs(args, "version_id") + if err != nil { + return codersdk.Template{}, err + } + name, ok := args["name"].(string) + if !ok { + return codersdk.Template{}, xerrors.New("name must be a string") + } + displayName, ok := args["display_name"].(string) + if !ok { + return codersdk.Template{}, xerrors.New("display_name must be a string") + } + description, ok := args["description"].(string) + if !ok { + return codersdk.Template{}, xerrors.New("description must be a string") + } + + template, err := client.CreateTemplate(ctx, me.OrganizationIDs[0], codersdk.CreateTemplateRequest{ + Name: name, + DisplayName: displayName, + Description: description, + VersionID: versionID, + }) + if err != nil { + return codersdk.Template{}, err + } + return template, nil + }, + } + + DeleteTemplate = Tool[string]{ + Tool: aisdk.Tool{ + Name: "coder_delete_template", + Description: "Delete a template. This is irreversible.", + Schema: aisdk.Schema{ + Properties: map[string]any{ + "template_id": map[string]any{ + "type": "string", + }, + }, + }, + }, + Handler: func(ctx context.Context, args map[string]any) (string, error) { + client, err := clientFromContext(ctx) + if err != nil { + return "", err + } + + templateID, err := uuidFromArgs(args, "template_id") + if err != nil { + return "", err + } + err = client.DeleteTemplate(ctx, templateID) + if err != nil { + return "", err + } + return "Successfully deleted template!", nil + }, + } +) + +type MinimalWorkspace struct { + ID string `json:"id"` + Name string `json:"name"` + TemplateID string `json:"template_id"` + TemplateName string `json:"template_name"` + TemplateDisplayName string `json:"template_display_name"` + TemplateIcon string `json:"template_icon"` + TemplateActiveVersionID uuid.UUID `json:"template_active_version_id"` + Outdated bool `json:"outdated"` +} + +type MinimalTemplate struct { + DisplayName string `json:"display_name"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ActiveVersionID uuid.UUID `json:"active_version_id"` + ActiveUserCount int `json:"active_user_count"` +} + +func clientFromContext(ctx context.Context) (*codersdk.Client, error) { + client, ok := ctx.Value(clientContextKey{}).(*codersdk.Client) + if !ok { + return nil, xerrors.New("client required in context") + } + return client, nil +} + +type clientContextKey struct{} + +func WithClient(ctx context.Context, client *codersdk.Client) context.Context { + return context.WithValue(ctx, clientContextKey{}, client) +} + +type agentClientContextKey struct{} + +func WithAgentClient(ctx context.Context, client *agentsdk.Client) context.Context { + return context.WithValue(ctx, agentClientContextKey{}, client) +} + +func agentClientFromContext(ctx context.Context) (*agentsdk.Client, error) { + client, ok := ctx.Value(agentClientContextKey{}).(*agentsdk.Client) + if !ok { + return nil, xerrors.New("agent client required in context") + } + return client, nil +} + +type workspaceAppStatusSlugContextKey struct{} + +func WithWorkspaceAppStatusSlug(ctx context.Context, slug string) context.Context { + return context.WithValue(ctx, workspaceAppStatusSlugContextKey{}, slug) +} + +func workspaceAppStatusSlugFromContext(ctx context.Context) (string, bool) { + slug, ok := ctx.Value(workspaceAppStatusSlugContextKey{}).(string) + if !ok || slug == "" { + return "", false + } + return slug, true +} + +func uuidFromArgs(args map[string]any, key string) (uuid.UUID, error) { + raw, ok := args[key].(string) + if !ok { + return uuid.Nil, xerrors.Errorf("%s must be a string", key) + } + id, err := uuid.Parse(raw) + if err != nil { + return uuid.Nil, xerrors.Errorf("failed to parse %s: %w", key, err) + } + return id, nil +} diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go new file mode 100644 index 0000000000000..6ec48ed713744 --- /dev/null +++ b/codersdk/toolsdk/toolsdk_test.go @@ -0,0 +1,357 @@ +package toolsdk_test + +import ( + "context" + "os" + "sort" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "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/coderd/database/dbgen" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/codersdk/toolsdk" + "github.com/coder/coder/v2/provisionersdk/proto" + "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 TestTools(t *testing.T) { + // Given: a running coderd instance + setupCtx := testutil.Context(t, testutil.WaitShort) + 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. + // nolint:gocritic // This is in a test package and does not end up in the build + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + agents[0].Apps = []*proto.App{ + { + Slug: "some-agent-app", + }, + } + return agents + }).Do() + + // Given: a client configured with the agent token. + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(r.AgentToken) + // Get the agent ID from the API. Overriding it in dbfake doesn't work. + ws, err := client.Workspace(setupCtx, r.Workspace.ID) + require.NoError(t, err) + require.NotEmpty(t, ws.LatestBuild.Resources) + require.NotEmpty(t, ws.LatestBuild.Resources[0].Agents) + agentID := ws.LatestBuild.Resources[0].Agents[0].ID + + // Given: the workspace agent has written logs. + agentClient.PatchLogs(setupCtx, agentsdk.PatchLogs{ + Logs: []agentsdk.Log{ + { + CreatedAt: time.Now(), + Level: codersdk.LogLevelInfo, + Output: "test log message", + }, + }, + }) + + t.Run("ReportTask", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithAgentClient(ctx, agentClient) + ctx = toolsdk.WithWorkspaceAppStatusSlug(ctx, "some-agent-app") + _, err := testTool(ctx, t, toolsdk.ReportTask, map[string]any{ + "summary": "test summary", + "state": "complete", + "link": "https://example.com", + "emoji": "✅", + }) + require.NoError(t, err) + }) + + t.Run("ListTemplates", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, memberClient) + + // Get the templates directly for comparison + expected, err := memberClient.Templates(context.Background(), codersdk.TemplateFilter{}) + require.NoError(t, err) + + result, err := testTool(ctx, t, toolsdk.ListTemplates, map[string]any{}) + + require.NoError(t, err) + require.Len(t, result, len(expected)) + + // Sort the results by name to ensure the order is consistent + sort.Slice(expected, func(a, b int) bool { + return expected[a].Name < expected[b].Name + }) + sort.Slice(result, func(a, b int) bool { + return result[a].Name < result[b].Name + }) + for i, template := range result { + require.Equal(t, expected[i].ID.String(), template.ID) + } + }) + + t.Run("Whoami", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, memberClient) + + result, err := testTool(ctx, t, toolsdk.Whoami, map[string]any{}) + + require.NoError(t, err) + require.Equal(t, member.ID, result.ID) + require.Equal(t, member.Username, result.Username) + }) + + t.Run("ListWorkspaces", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, memberClient) + + result, err := testTool(ctx, t, toolsdk.ListWorkspaces, map[string]any{ + "owner": "me", + }) + + require.NoError(t, err) + require.Len(t, result, 1, "expected 1 workspace") + workspace := result[0] + require.Equal(t, r.Workspace.ID.String(), workspace.ID, "expected the workspace to match the one we created") + }) + + t.Run("GetWorkspace", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, memberClient) + + result, err := testTool(ctx, t, toolsdk.GetWorkspace, map[string]any{ + "workspace_id": r.Workspace.ID.String(), + }) + + require.NoError(t, err) + require.Equal(t, r.Workspace.ID, result.ID, "expected the workspace ID to match") + }) + + t.Run("CreateWorkspaceBuild", func(t *testing.T) { + t.Run("Stop", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, memberClient) + + result, err := testTool(ctx, t, toolsdk.CreateWorkspaceBuild, map[string]any{ + "workspace_id": r.Workspace.ID.String(), + "transition": "stop", + }) + + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceTransitionStop, result.Transition) + require.Equal(t, r.Workspace.ID, result.WorkspaceID) + + // Important: cancel the build. We don't run any provisioners, so this + // will remain in the 'pending' state indefinitely. + require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID)) + }) + + t.Run("Start", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, memberClient) + + result, err := testTool(ctx, t, toolsdk.CreateWorkspaceBuild, map[string]any{ + "workspace_id": r.Workspace.ID.String(), + "transition": "start", + }) + + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceTransitionStart, result.Transition) + require.Equal(t, r.Workspace.ID, result.WorkspaceID) + + // Important: cancel the build. We don't run any provisioners, so this + // will remain in the 'pending' state indefinitely. + require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID)) + }) + }) + + t.Run("ListTemplateVersionParameters", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, memberClient) + + params, err := testTool(ctx, t, toolsdk.ListTemplateVersionParameters, map[string]any{ + "template_version_id": r.TemplateVersion.ID.String(), + }) + + require.NoError(t, err) + require.Empty(t, params) + }) + + t.Run("GetWorkspaceAgentLogs", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, client) + + logs, err := testTool(ctx, t, toolsdk.GetWorkspaceAgentLogs, map[string]any{ + "workspace_agent_id": agentID.String(), + }) + + require.NoError(t, err) + require.NotEmpty(t, logs) + }) + + t.Run("GetWorkspaceBuildLogs", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, memberClient) + + logs, err := testTool(ctx, t, toolsdk.GetWorkspaceBuildLogs, map[string]any{ + "workspace_build_id": r.Build.ID.String(), + }) + + require.NoError(t, err) + _ = logs // The build may not have any logs yet, so we just check that the function returns successfully + }) + + t.Run("GetTemplateVersionLogs", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, memberClient) + + logs, err := testTool(ctx, t, toolsdk.GetTemplateVersionLogs, map[string]any{ + "template_version_id": r.TemplateVersion.ID.String(), + }) + + require.NoError(t, err) + _ = logs // Just ensuring the call succeeds + }) + + t.Run("UpdateTemplateActiveVersion", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, client) // Use owner client for permission + + result, err := testTool(ctx, t, toolsdk.UpdateTemplateActiveVersion, map[string]any{ + "template_id": r.Template.ID.String(), + "template_version_id": r.TemplateVersion.ID.String(), + }) + + require.NoError(t, err) + require.Contains(t, result, "Successfully updated") + }) + + t.Run("DeleteTemplate", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, client) + + _, err := testTool(ctx, t, toolsdk.DeleteTemplate, map[string]any{ + "template_id": r.Template.ID.String(), + }) + + // This will fail with because there already exists a workspace. + require.ErrorContains(t, err, "All workspaces must be deleted before a template can be removed") + }) + + t.Run("UploadTarFile", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, client) + + files := map[string]any{ + "main.tf": "resource \"null_resource\" \"example\" {}", + } + + result, err := testTool(ctx, t, toolsdk.UploadTarFile, map[string]any{ + "mime_type": string(codersdk.ContentTypeTar), + "files": files, + }) + + require.NoError(t, err) + require.NotEmpty(t, result.ID) + }) + + t.Run("CreateTemplateVersion", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, client) + + // nolint:gocritic // This is in a test package and does not end up in the build + file := dbgen.File(t, store, database.File{}) + + tv, err := testTool(ctx, t, toolsdk.CreateTemplateVersion, map[string]any{ + "file_id": file.ID.String(), + }) + require.NoError(t, err) + require.NotEmpty(t, tv) + }) + + t.Run("CreateTemplate", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, client) + + // Create a new template version for use here. + tv := dbfake.TemplateVersion(t, store).SkipCreateTemplate().Do() + + // We're going to re-use the pre-existing template version + _, err := testTool(ctx, t, toolsdk.CreateTemplate, map[string]any{ + "name": testutil.GetRandomNameHyphenated(t), + "display_name": "Test Template", + "description": "This is a test template", + "version_id": tv.TemplateVersion.ID.String(), + }) + + require.NoError(t, err) + }) + + t.Run("CreateWorkspace", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + ctx = toolsdk.WithClient(ctx, memberClient) + + // We need a template version ID to create a workspace + res, err := testTool(ctx, t, toolsdk.CreateWorkspace, map[string]any{ + "user": "me", + "template_version_id": r.TemplateVersion.ID.String(), + "name": testutil.GetRandomNameHyphenated(t), + "rich_parameters": map[string]any{}, + }) + + // The creation might fail for various reasons, but the important thing is + // to mark it as tested + require.NoError(t, err) + require.NotEmpty(t, res.ID, "expected a workspace ID") + }) +} + +// TestedTools keeps track of which tools have been tested. +var testedTools sync.Map + +// testTool is a helper function to test a tool and mark it as tested. +func testTool[T any](ctx context.Context, t *testing.T, tool toolsdk.Tool[T], args map[string]any) (T, error) { + t.Helper() + testedTools.Store(tool.Tool.Name, struct{}{}) + result, err := tool.Handler(ctx, args) + return result, err +} + +// TestMain runs after all tests to ensure that all tools in this package have +// been tested once. +func TestMain(m *testing.M) { + code := m.Run() + var untested []string + for _, tool := range toolsdk.All { + if _, ok := testedTools.Load(tool.Tool.Name); !ok { + untested = append(untested, tool.Tool.Name) + } + } + + if len(untested) > 0 { + println("The following tools were not tested:") + for _, tool := range untested { + println(" - " + tool) + } + println("Please ensure that all tools are tested using testTool().") + println("If you just added a new tool, please add a test for it.") + println("NOTE: if you just ran an individual test, this is expected.") + os.Exit(1) + } + + os.Exit(code) +} diff --git a/mcp/mcp.go b/mcp/mcp.go deleted file mode 100644 index 0dd01ccdc5fdd..0000000000000 --- a/mcp/mcp.go +++ /dev/null @@ -1,600 +0,0 @@ -package codermcp - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "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" - "github.com/coder/coder/v2/coderd/util/ptr" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agentsdk" - "github.com/coder/coder/v2/codersdk/workspacesdk" -) - -// 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()), - mcp.WithBoolean("need_user_attention", mcp.Description(`Whether the user needs to take action on the task. -Set to true if the task is in a failed state or if the user needs to take action to continue.`), 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 - AgentClient *agentsdk.Client - Logger *slog.Logger - AppStatusSlug 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(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"` - NeedUserAttention bool `json:"need_user_attention"` -} - -// Example payload: -// {"jsonrpc":"2.0","id":1,"method":"tools/call", "params": {"name": "coder_report_task", "arguments": {"summary": "I need help with the login page.", "link": "https://github.com/coder/coder/pull/1234", "emoji": "🔍", "done": false, "need_user_attention": true}}} -func handleCoderReportTask(deps ToolDeps) server.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - if deps.AgentClient == nil { - return nil, xerrors.New("developer error: agent client is required") - } - - if deps.AppStatusSlug == "" { - return nil, xerrors.New("No app status slug provided, set CODER_MCP_APP_STATUS_SLUG when running the MCP server to report tasks.") - } - - // 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) - } - - deps.Logger.Info(ctx, "report task tool called", - slog.F("summary", args.Summary), - slog.F("link", args.Link), - slog.F("emoji", args.Emoji), - slog.F("done", args.Done), - slog.F("need_user_attention", args.NeedUserAttention), - ) - - newStatus := agentsdk.PatchAppStatus{ - AppSlug: deps.AppStatusSlug, - Message: args.Summary, - URI: args.Link, - Icon: args.Emoji, - NeedsUserAttention: args.NeedUserAttention, - State: codersdk.WorkspaceAppStatusStateWorking, - } - - if args.Done { - newStatus.State = codersdk.WorkspaceAppStatusStateComplete - } - if args.NeedUserAttention { - newStatus.State = codersdk.WorkspaceAppStatusStateFailure - } - - if err := deps.AgentClient.PatchAppStatus(ctx, newStatus); err != nil { - return nil, xerrors.Errorf("failed to patch app status: %w", 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/mcp_test.go b/mcp/mcp_test.go deleted file mode 100644 index f40dc03bae908..0000000000000 --- a/mcp/mcp_test.go +++ /dev/null @@ -1,397 +0,0 @@ -package codermcp_test - -import ( - "context" - "encoding/json" - "io" - "runtime" - "testing" - - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - "github.com/stretchr/testify/require" - - "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/v2/agent/agenttest" - "github.com/coder/coder/v2/coderd/coderdtest" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbfake" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agentsdk" - codermcp "github.com/coder/coder/v2/mcp" - "github.com/coder/coder/v2/provisionersdk/proto" - "github.com/coder/coder/v2/pty/ptytest" - "github.com/coder/coder/v2/testutil" -) - -// These tests are dependent on the state of the coder server. -// Running them in parallel is prone to racy behavior. -// nolint:tparallel,paralleltest -func TestCoderTools(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skip("skipping on non-linux due to pty issues") - } - ctx := testutil.Context(t, testutil.WaitLong) - // Given: a coder server, workspace, and agent. - client, store := coderdtest.NewWithDatabase(t, nil) - owner := coderdtest.CreateFirstUser(t, client) - // Given: a member user with which to test the tools. - memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - // Given: a workspace with an agent. - r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ - OrganizationID: owner.OrganizationID, - OwnerID: member.ID, - }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { - agents[0].Apps = []*proto.App{ - { - Slug: "some-agent-app", - }, - } - return agents - }).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) - agentClient := agentsdk.New(memberClient.URL) - codermcp.AllTools().Register(mcpSrv, codermcp.ToolDeps{ - Client: memberClient, - Logger: &logger, - AppStatusSlug: "some-agent-app", - AgentClient: agentClient, - }) - - 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 - - // Then: the response is a list of expected visible to the user. - expected, err := memberClient.Templates(ctx, codersdk.TemplateFilter{}) - require.NoError(t, err) - actual := unmarshalFromCallToolResult[[]codersdk.Template](t, pty.ReadLine(ctx)) - require.Len(t, actual, 1) - require.Equal(t, expected[0].ID, actual[0].ID) - }) - - t.Run("coder_report_task", func(t *testing.T) { - // Given: the MCP server has an agent token. - oldAgentToken := agentClient.SDK.SessionToken() - agentClient.SetSessionToken(r.AgentToken) - t.Cleanup(func() { - agentClient.SDK.SetSessionToken(oldAgentToken) - }) - // 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, - "need_user_attention": true, - }) - - pty.WriteLine(ctr) - _ = pty.ReadLine(ctx) // skip the echo - - // Then: positive feedback is given to the reporting agent. - actual := pty.ReadLine(ctx) - require.Contains(t, actual, "Thanks for reporting!") - - // Then: the response is a success message. - ws, err := memberClient.Workspace(ctx, r.Workspace.ID) - require.NoError(t, err, "failed to get workspace") - agt, err := memberClient.WorkspaceAgent(ctx, ws.LatestBuild.Resources[0].Agents[0].ID) - require.NoError(t, err, "failed to get workspace agent") - require.NotEmpty(t, agt.Apps, "workspace agent should have an app") - require.NotEmpty(t, agt.Apps[0].Statuses, "workspace agent app should have a status") - st := agt.Apps[0].Statuses[0] - // require.Equal(t, ws.ID, st.WorkspaceID, "workspace app status should have the correct workspace id") - require.Equal(t, agt.ID, st.AgentID, "workspace app status should have the correct agent id") - require.Equal(t, agt.Apps[0].ID, st.AppID, "workspace app status should have the correct app id") - require.Equal(t, codersdk.WorkspaceAppStatusStateFailure, st.State, "workspace app status should be in the failure state") - require.Equal(t, "Test summary", st.Message, "workspace app status should have the correct message") - require.Equal(t, "https://example.com", st.URI, "workspace app status should have the correct uri") - require.Equal(t, "🔍", st.Icon, "workspace app status should have the correct icon") - require.True(t, st.NeedsUserAttention, "workspace app status should need user attention") - }) - - t.Run("coder_whoami", func(t *testing.T) { - // When: the coder_whoami tool is called - 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, err := memberClient.User(ctx, codersdk.Me) - require.NoError(t, err) - actual := unmarshalFromCallToolResult[codersdk.User](t, pty.ReadLine(ctx)) - require.Equal(t, expected.ID, actual.ID) - }) - - 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 - - // Then: the response is a valid JSON respresentation of the calling user's workspaces. - actual := unmarshalFromCallToolResult[codersdk.WorkspacesResponse](t, pty.ReadLine(ctx)) - require.Len(t, actual.Workspaces, 1, "expected 1 workspace") - require.Equal(t, r.Workspace.ID, actual.Workspaces[0].ID, "expected the workspace to be the one we created in setup") - }) - - t.Run("coder_get_workspace", func(t *testing.T) { - // Given: the workspace agent is connected. - // The act of starting the agent will modify the workspace state. - <-agentStarted - // When: the coder_get_workspace tool is called - ctr := makeJSONRPCRequest(t, "tools/call", "coder_get_workspace", map[string]any{ - "workspace": r.Workspace.ID.String(), - }) - - pty.WriteLine(ctr) - _ = pty.ReadLine(ctx) // skip the echo - - expected, err := memberClient.Workspace(ctx, r.Workspace.ID) - require.NoError(t, err) - - // Then: the response is a valid JSON respresentation of the workspace. - actual := unmarshalFromCallToolResult[codersdk.Workspace](t, pty.ReadLine(ctx)) - require.Equal(t, expected.ID, actual.ID) - }) - - // NOTE: this test runs after the list_workspaces tool is called. - t.Run("coder_workspace_exec", func(t *testing.T) { - // Given: the workspace agent is connected - <-agentStarted - - // When: the coder_workspace_exec tools is called with a command - randString := testutil.GetRandomName(t) - ctr := makeJSONRPCRequest(t, "tools/call", "coder_workspace_exec", map[string]any{ - "workspace": r.Workspace.ID.String(), - "command": "echo " + randString, - "coder_url": client.URL.String(), - "coder_session_token": client.SessionToken(), - }) - - pty.WriteLine(ctr) - _ = pty.ReadLine(ctx) // skip the echo - - // Then: the response is the output of the command. - actual := pty.ReadLine(ctx) - require.Contains(t, actual, randString) - }) - - // NOTE: this test runs after the list_workspaces tool is called. - t.Run("tool_restrictions", func(t *testing.T) { - // Given: the workspace agent is connected - <-agentStarted - - // Given: a restricted MCP server with only allowed tools and commands - restrictedPty := ptytest.New(t) - allowedTools := []string{"coder_workspace_exec"} - restrictedMCPSrv, closeRestrictedSrv := startTestMCPServer(ctx, t, restrictedPty.Input(), restrictedPty.Output()) - t.Cleanup(func() { - _ = closeRestrictedSrv() - }) - codermcp.AllTools(). - WithOnlyAllowed(allowedTools...). - Register(restrictedMCPSrv, codermcp.ToolDeps{ - Client: memberClient, - Logger: &logger, - }) - - // When: the tools/list command is called - toolsListCmd := makeJSONRPCRequest(t, "tools/list", "", nil) - restrictedPty.WriteLine(toolsListCmd) - _ = restrictedPty.ReadLine(ctx) // skip the echo - - // Then: the response is a list of only the allowed tools. - toolsListResponse := restrictedPty.ReadLine(ctx) - require.Contains(t, toolsListResponse, "coder_workspace_exec") - require.NotContains(t, toolsListResponse, "coder_whoami") - - // When: a disallowed tool is called - disallowedToolCmd := makeJSONRPCRequest(t, "tools/call", "coder_whoami", map[string]any{}) - restrictedPty.WriteLine(disallowedToolCmd) - _ = restrictedPty.ReadLine(ctx) // skip the echo - - // Then: the response is an error indicating the tool is not available. - disallowedToolResponse := restrictedPty.ReadLine(ctx) - require.Contains(t, disallowedToolResponse, "error") - require.Contains(t, disallowedToolResponse, "not found") - }) - - t.Run("coder_workspace_transition_stop", func(t *testing.T) { - // Given: a separate workspace in the running state - stopWs := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ - OrganizationID: owner.OrganizationID, - OwnerID: member.ID, - }).WithAgent().Do() - - // When: the coder_workspace_transition tool is called with a stop transition - ctr := makeJSONRPCRequest(t, "tools/call", "coder_workspace_transition", map[string]any{ - "workspace": stopWs.Workspace.ID.String(), - "transition": "stop", - }) - - pty.WriteLine(ctr) - _ = pty.ReadLine(ctx) // skip the echo - - // Then: the response is as expected. - expected := makeJSONRPCTextResponse(t, `{"status":"pending","transition":"stop"}`) // no provisionerd yet - actual := pty.ReadLine(ctx) - testutil.RequireJSONEq(t, expected, actual) - }) - - t.Run("coder_workspace_transition_start", func(t *testing.T) { - // Given: a separate workspace in the stopped state - stopWs := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ - OrganizationID: owner.OrganizationID, - OwnerID: member.ID, - }).Seed(database.WorkspaceBuild{ - Transition: database.WorkspaceTransitionStop, - }).Do() - - // When: the coder_workspace_transition tool is called with a start transition - ctr := makeJSONRPCRequest(t, "tools/call", "coder_workspace_transition", map[string]any{ - "workspace": stopWs.Workspace.ID.String(), - "transition": "start", - }) - - pty.WriteLine(ctr) - _ = pty.ReadLine(ctx) // skip the echo - - // Then: the response is as expected - expected := makeJSONRPCTextResponse(t, `{"status":"pending","transition":"start"}`) // no provisionerd yet - actual := pty.ReadLine(ctx) - testutil.RequireJSONEq(t, expected, actual) - }) -} - -// makeJSONRPCRequest is a helper function that makes a JSON RPC request. -func makeJSONRPCRequest(t *testing.T, method, name string, args map[string]any) string { - t.Helper() - req := mcp.JSONRPCRequest{ - ID: "1", - JSONRPC: "2.0", - Request: mcp.Request{Method: method}, - Params: struct { // Unfortunately, there is no type for this yet. - Name string `json:"name"` - Arguments map[string]any `json:"arguments,omitempty"` - Meta *struct { - ProgressToken mcp.ProgressToken `json:"progressToken,omitempty"` - } `json:"_meta,omitempty"` - }{ - Name: name, - Arguments: args, - }, - } - bs, err := json.Marshal(req) - require.NoError(t, err, "failed to marshal JSON RPC request") - return string(bs) -} - -// makeJSONRPCTextResponse is a helper function that makes a JSON RPC text response -func makeJSONRPCTextResponse(t *testing.T, text string) string { - t.Helper() - - resp := mcp.JSONRPCResponse{ - ID: "1", - JSONRPC: "2.0", - Result: mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(text), - }, - }, - } - bs, err := json.Marshal(resp) - require.NoError(t, err, "failed to marshal JSON RPC response") - return string(bs) -} - -func unmarshalFromCallToolResult[T any](t *testing.T, raw string) T { - t.Helper() - - var resp map[string]any - require.NoError(t, json.Unmarshal([]byte(raw), &resp), "failed to unmarshal JSON RPC response") - res, ok := resp["result"].(map[string]any) - require.True(t, ok, "expected a result field in the response") - ct, ok := res["content"].([]any) - require.True(t, ok, "expected a content field in the result") - require.Len(t, ct, 1, "expected a single content item in the result") - ct0, ok := ct[0].(map[string]any) - require.True(t, ok, "expected a content item in the result") - txt, ok := ct0["text"].(string) - require.True(t, ok, "expected a text field in the content item") - var actual T - require.NoError(t, json.Unmarshal([]byte(txt), &actual), "failed to unmarshal content") - return actual -} - -// 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 - } -} From 87b161e24f3a1da138f98f0e10d9839db401c716 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 10 Apr 2025 19:29:49 +0100 Subject: [PATCH 2/6] make fmt lint --- cli/exp_mcp.go | 4 +-- go.mod | 35 ++++++++++++---------- go.sum | 78 +++++++++++++++++++++++++++++--------------------- 3 files changed, 67 insertions(+), 50 deletions(-) diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index 2ff681e2510d8..8b8c96ab41863 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -525,8 +525,8 @@ func configureClaude(fs afero.Fs, cfg ClaudeConfig) error { if !ok { mcpServers = make(map[string]any) } - for name, mcp := range cfg.MCPServers { - mcpServers[name] = mcp + for name, cfgmcp := range cfg.MCPServers { + mcpServers[name] = cfgmcp } project["mcpServers"] = mcpServers // Prevents Claude from asking the user to complete the project onboarding. diff --git a/go.mod b/go.mod index 8fe432f0418bf..dc4d94ec02408 100644 --- a/go.mod +++ b/go.mod @@ -222,8 +222,8 @@ require ( require ( cloud.google.com/go/auth v0.15.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/logging v1.12.0 // indirect - cloud.google.com/go/longrunning v0.6.2 // indirect + cloud.google.com/go/logging v1.13.0 // indirect + cloud.google.com/go/longrunning v0.6.4 // indirect dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect @@ -465,9 +465,9 @@ require ( golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect + google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect howett.net/plist v1.0.0 // indirect @@ -489,38 +489,43 @@ require ( require ( github.com/coder/preview v0.0.0-20250409162646-62939c63c71a - github.com/mark3labs/mcp-go v0.19.0 + github.com/kylecarbs/aisdk-go v0.0.5 + github.com/mark3labs/mcp-go v0.17.0 ) require ( - cel.dev/expr v0.19.1 // indirect - cloud.google.com/go v0.116.0 // indirect - cloud.google.com/go/iam v1.2.2 // indirect - cloud.google.com/go/monitoring v1.21.2 // indirect - cloud.google.com/go/storage v1.49.0 // indirect + cel.dev/expr v0.19.2 // indirect + cloud.google.com/go v0.120.0 // indirect + cloud.google.com/go/iam v1.4.0 // indirect + cloud.google.com/go/monitoring v1.24.0 // indirect + cloud.google.com/go/storage v1.50.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 // indirect + github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect github.com/aquasecurity/trivy v0.58.2 // indirect github.com/aws/aws-sdk-go v1.55.6 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect - github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 // indirect + github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/go-getter v1.7.8 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/liamg/memoryfs v1.6.0 // indirect github.com/moby/sys/user v0.3.0 // indirect + github.com/openai/openai-go v0.1.0-beta.6 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/samber/lo v1.49.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/ulikunitz/xz v0.5.12 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect + google.golang.org/genai v0.7.0 // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect ) diff --git a/go.sum b/go.sum index 15a22a21a2a19..65c8a706e52e3 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ cdr.dev/slog v1.6.2-0.20241112041820-0ec81e6e67bb h1:4MKA8lBQLnCqj2myJCb5Lzoa65y0tABO4gHrxuMdsCQ= cdr.dev/slog v1.6.2-0.20241112041820-0ec81e6e67bb/go.mod h1:NaoTA7KwopCrnaSb0JXTC0PTp/O/Y83Lndnq0OEV3ZQ= -cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= -cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4= +cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -38,8 +38,8 @@ cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRY cloud.google.com/go v0.105.0/go.mod h1:PrLgOJNe5nfE9UMxKxgXj4mD3voiP+YQ6gdt6KMFOKM= cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I= cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY= -cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= -cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA= +cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q= cloud.google.com/go/accessapproval v1.4.0/go.mod h1:zybIuC3KpDOvotz59lFe5qxRZx6C75OtwbisN56xYB4= cloud.google.com/go/accessapproval v1.5.0/go.mod h1:HFy3tuiGvMdcd/u+Cu5b9NkO1pEICJ46IR82PoUdplw= cloud.google.com/go/accessapproval v1.6.0/go.mod h1:R0EiYnwV5fsRFiKZkPHr6mwyk2wxUJ30nL4j2pcFY2E= @@ -319,8 +319,8 @@ cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGE cloud.google.com/go/iam v0.11.0/go.mod h1:9PiLDanza5D+oWFZiH1uG+RnRCfEGKoyl6yo4cgWZGY= cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY= cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0= -cloud.google.com/go/iam v1.2.2 h1:ozUSofHUGf/F4tCNy/mu9tHLTaxZFLOUiKzjcgWHGIA= -cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY= +cloud.google.com/go/iam v1.4.0 h1:ZNfy/TYfn2uh/ukvhp783WhnbVluqf/tzOaqVUPlIPA= +cloud.google.com/go/iam v1.4.0/go.mod h1:gMBgqPaERlriaOV0CUl//XUzDhSfXevn4OEUbg6VRs4= cloud.google.com/go/iap v1.4.0/go.mod h1:RGFwRJdihTINIe4wZ2iCP0zF/qu18ZwyKxrhMhygBEc= cloud.google.com/go/iap v1.5.0/go.mod h1:UH/CGgKd4KyohZL5Pt0jSKE4m3FR51qg6FKQ/z/Ix9A= cloud.google.com/go/iap v1.6.0/go.mod h1:NSuvI9C/j7UdjGjIde7t7HBz+QTwBcapPE07+sSRcLk= @@ -350,13 +350,13 @@ cloud.google.com/go/lifesciences v0.6.0/go.mod h1:ddj6tSX/7BOnhxCSd3ZcETvtNr8NZ6 cloud.google.com/go/lifesciences v0.8.0/go.mod h1:lFxiEOMqII6XggGbOnKiyZ7IBwoIqA84ClvoezaA/bo= cloud.google.com/go/logging v1.6.1/go.mod h1:5ZO0mHHbvm8gEmeEUHrmDlTDSu5imF6MUP9OfilNXBw= cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M= -cloud.google.com/go/logging v1.12.0 h1:ex1igYcGFd4S/RZWOCU51StlIEuey5bjqwH9ZYjHibk= -cloud.google.com/go/logging v1.12.0/go.mod h1:wwYBt5HlYP1InnrtYI0wtwttpVU1rifnMT7RejksUAM= +cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= +cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= cloud.google.com/go/longrunning v0.1.1/go.mod h1:UUFxuDWkv22EuY93jjmDMFT5GPQKeFVJBIF6QlTqdsE= cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc= cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo= -cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0MK+hc= -cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI= +cloud.google.com/go/longrunning v0.6.4 h1:3tyw9rO3E2XVXzSApn1gyEEnH2K9SynNQjMlBi3uHLg= +cloud.google.com/go/longrunning v0.6.4/go.mod h1:ttZpLCe6e7EXvn9OxpBRx7kZEB0efv8yBO6YnVMfhJs= cloud.google.com/go/managedidentities v1.3.0/go.mod h1:UzlW3cBOiPrzucO5qWkNkh0w33KFtBJU281hacNvsdE= cloud.google.com/go/managedidentities v1.4.0/go.mod h1:NWSBYbEMgqmbZsLIyKvxrYbtqOsxY1ZrGM+9RgDqInM= cloud.google.com/go/managedidentities v1.5.0/go.mod h1:+dWcZ0JlUmpuxpIDfyP5pP5y0bLdRwOS4Lp7gMni/LA= @@ -380,8 +380,8 @@ cloud.google.com/go/monitoring v1.7.0/go.mod h1:HpYse6kkGo//7p6sT0wsIC6IBDET0RhI cloud.google.com/go/monitoring v1.8.0/go.mod h1:E7PtoMJ1kQXWxPjB6mv2fhC5/15jInuulFdYYtlcvT4= cloud.google.com/go/monitoring v1.12.0/go.mod h1:yx8Jj2fZNEkL/GYZyTLS4ZtZEZN8WtDEiEqG4kLK50w= cloud.google.com/go/monitoring v1.13.0/go.mod h1:k2yMBAB1H9JT/QETjNkgdCGD9bPF712XiLTVr+cBrpw= -cloud.google.com/go/monitoring v1.21.2 h1:FChwVtClH19E7pJ+e0xUhJPGksctZNVOk2UhMmblmdU= -cloud.google.com/go/monitoring v1.21.2/go.mod h1:hS3pXvaG8KgWTSz+dAdyzPrGUYmi2Q+WFX8g2hqVEZU= +cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM= +cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= cloud.google.com/go/networkconnectivity v1.4.0/go.mod h1:nOl7YL8odKyAOtzNX73/M5/mGZgqqMeryi6UPZTk/rA= cloud.google.com/go/networkconnectivity v1.5.0/go.mod h1:3GzqJx7uhtlM3kln0+x5wyFvuVH1pIBJjhCpjzSt75o= cloud.google.com/go/networkconnectivity v1.6.0/go.mod h1:OJOoEXW+0LAxHh89nXd64uGG+FbQoeH8DtxCHVOMlaM= @@ -544,8 +544,8 @@ cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeL cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s= cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y= cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4= -cloud.google.com/go/storage v1.49.0 h1:zenOPBOWHCnojRd9aJZAyQXBYqkJkdQS42dxL55CIMw= -cloud.google.com/go/storage v1.49.0/go.mod h1:k1eHhhpLvrPjVGfo0mOUPEJ4Y2+a/Hv5PiwehZI9qGU= +cloud.google.com/go/storage v1.50.0 h1:3TbVkzTooBvnZsk7WaAQfOsNrdoM8QHusXA1cpk6QJs= +cloud.google.com/go/storage v1.50.0/go.mod h1:l7XeiD//vx5lfqE3RavfmU9yvk5Pp0Zhcv482poyafY= cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w= cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I= cloud.google.com/go/storagetransfer v1.7.0/go.mod h1:8Giuj1QNb1kfLAiWM1bN6dHzfdlDAVC9rv9abHot2W4= @@ -565,8 +565,8 @@ cloud.google.com/go/trace v1.3.0/go.mod h1:FFUE83d9Ca57C+K8rDl/Ih8LwOzWIV1krKgxg cloud.google.com/go/trace v1.4.0/go.mod h1:UG0v8UBqzusp+z63o7FK74SdFE+AXpCLdFb1rshXG+Y= cloud.google.com/go/trace v1.8.0/go.mod h1:zH7vcsbAhklH8hWFig58HvxcxyQbaIqMarMg9hn5ECA= cloud.google.com/go/trace v1.9.0/go.mod h1:lOQqpE5IaWY0Ixg7/r2SjixMuc6lfTFeO4QGM4dQWOk= -cloud.google.com/go/trace v1.11.2 h1:4ZmaBdL8Ng/ajrgKqY5jfvzqMXbrDcBsUGXOT9aqTtI= -cloud.google.com/go/trace v1.11.2/go.mod h1:bn7OwXd4pd5rFuAnTrzBuoZ4ax2XQeG3qNgYmfCy0Io= +cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= +cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= cloud.google.com/go/translate v1.3.0/go.mod h1:gzMUwRjvOqj5i69y/LYLd8RrNQk+hOmIXTi9+nb3Djs= cloud.google.com/go/translate v1.4.0/go.mod h1:06Dn/ppvLD6WvA5Rhdp029IX2Mi3Mn7fpMRLPvXT5Wg= cloud.google.com/go/translate v1.5.0/go.mod h1:29YDSYveqqpA1CQFD7NQuP49xymq17RXNaUDdc0mNu0= @@ -662,12 +662,12 @@ github.com/DataDog/sketches-go v1.4.5 h1:ki7VfeNz7IcNafq7yI/j5U/YCkO3LJiMDtXz9OM github.com/DataDog/sketches-go v1.4.5/go.mod h1:7Y8GN8Jf66DLyDhc94zuWA3uHEt/7ttt8jHOBWWrSOg= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 h1:UQ0AhxogsIRZDkElkblfnwjc3IaltCm2HUMvezQaL7s= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1/go.mod h1:jyqM3eLpJ3IbIFDTKVz2rF9T/xWGW0rIriGwnz8l9Tk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.48.1 h1:oTX4vsorBZo/Zdum6OKPA4o7544hm6smoRv1QjpTwGo= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.48.1/go.mod h1:0wEl7vrAD8mehJyohS9HZy+WyEOaQO2mJx86Cvh93kM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 h1:8nn+rsCvTq9axyEh382S0PFLBeaFwNsT43IrPWzctRU= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1/go.mod h1:viRWSEhtMZqz1rhwmOVKkWl6SwmVowfL9O2YR5gI2PE= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0 h1:5IT7xOdq17MtcdtL/vtl6mGfzhaq4m4vpollPRmlsBQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.50.0/go.mod h1:ZV4VOm0/eHR06JLrXWe09068dHpr3TRpY9Uo7T+anuA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.50.0 h1:nNMpRpnkWDAaqcpxMJvxa/Ud98gjbYwayJY4/9bdjiU= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.50.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0 h1:ig/FpDD2JofP/NExKQUbn7uOSZzJAQqogfqluZK4ed4= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.50.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= @@ -713,6 +713,8 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7X github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 h1:b5t1ZJMvV/l99y4jbz7kRFdUp3BSDkI8EhSlHczivtw= +github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI= @@ -884,8 +886,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 h1:boJj011Hh+874zpIySeApCX4GeOjPl9qhRF3QuIZq+Q= -github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= +github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41 h1:SBN/DA63+ZHwuWwPHPYoCZ/KLAjHv5g4h2MS4f2/MTI= github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41/go.mod h1:I9ULxr64UaOSUv7hcb3nX4kowodJCVS7vt7VVJk/kW4= github.com/coder/clistat v1.0.0 h1:MjiS7qQ1IobuSSgDnxcCSyBPESs44hExnh2TEqMcGnA= @@ -1314,6 +1316,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= @@ -1463,9 +1467,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylecarbs/aisdk-go v0.0.5 h1:e4HE/SMBUUZn7AS/luiIYbEtHbbtUBzJS95R6qHDYVE= +github.com/kylecarbs/aisdk-go v0.0.5/go.mod h1:3nAhClwRNo6ZfU44GrBZ8O2fCCrxJdaHb9JIz+P3LR8= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3 h1:Z9/bo5PSeMutpdiKYNt/TTSfGM1Ll0naj3QzYX9VxTc= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk= -github.com/kylecarbs/opencensus-go v0.23.1-0.20220307014935-4d0325a68f8b h1:1Y1X6aR78kMEQE1iCjQodB3lA7VO4jB88Wf8ZrzXSsA= github.com/kylecarbs/opencensus-go v0.23.1-0.20220307014935-4d0325a68f8b/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= github.com/kylecarbs/readline v0.0.0-20220211054233-0d62993714c8/go.mod h1:n/KX1BZoN1m9EwoXkn/xAV4fd3k8c++gGBsgLONaPOY= github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e h1:OP0ZMFeZkUnOzTFRfpuK3m7Kp4fNvC6qN+exwj7aI4M= @@ -1496,8 +1501,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.19.0 h1:cYKBPFD+fge273/TV6f5+TZYBSTnxV6GCJAO08D2wvA= -github.com/mark3labs/mcp-go v0.19.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE= +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= @@ -1604,6 +1609,8 @@ github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/open-policy-agent/opa v1.3.0 h1:zVvQvQg+9+FuSRBt4LgKNzJwsWl/c85kD5jPozJTydY= github.com/open-policy-agent/opa v1.3.0/go.mod h1:t9iPNhaplD2qpiBqeudzJtEX3fKHK8zdA29oFvofAHo= +github.com/openai/openai-go v0.1.0-beta.6 h1:JquYDpprfrGnlKvQQg+apy9dQ8R9mIrm+wNvAPp6jCQ= +github.com/openai/openai-go v0.1.0-beta.6/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -1792,6 +1799,7 @@ github.com/testcontainers/testcontainers-go/modules/localstack v0.36.0 h1:zVwbe4 github.com/testcontainers/testcontainers-go/modules/localstack v0.36.0/go.mod h1:rxyzj5nX/OUn7QK5PVxKYHJg1eeNtNzWMX2hSbNNJk0= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -1799,6 +1807,8 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tinylib/msgp v1.2.1 h1:6ypy2qcCznxpP4hpORzhtXyTqrBs7cfM9MCCWY8zsmU= github.com/tinylib/msgp v1.2.1/go.mod h1:2vIGs3lcUo8izAATNobrCHevYZC/LMsJtw4JPiYPHro= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= @@ -2474,6 +2484,8 @@ google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genai v0.7.0 h1:TINBYXnP+K+D8b16LfVyb6XR3kdtieXy6nJsGoEXcBc= +google.golang.org/genai v0.7.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -2603,12 +2615,12 @@ google.golang.org/genproto v0.0.0-20230323212658-478b75c54725/go.mod h1:UUQDJDOl google.golang.org/genproto v0.0.0-20230330154414-c0448cd141ea/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc= -google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= -google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= From 8b6c4de3d5f04abda23c19a5d475204887248283 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 11 Apr 2025 09:12:58 +0100 Subject: [PATCH 3/6] whoami -> get_authenticated-user --- codersdk/toolsdk/toolsdk.go | 6 +++--- codersdk/toolsdk/toolsdk_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index c28efc83dfd36..9e4642503728a 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -38,7 +38,7 @@ var ( ListWorkspaces.Generic(), ListTemplates.Generic(), ListTemplateVersionParameters.Generic(), - Whoami.Generic(), + GetAuthenticatedUser.Generic(), CreateWorkspaceBuild.Generic(), UploadTarFile.Generic(), CreateTemplateVersion.Generic(), @@ -311,9 +311,9 @@ is provisioned correctly and the agent can connect to the control plane. }, } - Whoami = Tool[codersdk.User]{ + GetAuthenticatedUser = Tool[codersdk.User]{ Tool: aisdk.Tool{ - Name: "coder_whoami", + Name: "coder_get_authenticated_user", Description: "Get the currently authenticated user, similar to the `whoami` command.", }, Handler: func(ctx context.Context, _ map[string]any) (codersdk.User, error) { diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index 6ec48ed713744..44c0fa1b879e4 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -108,7 +108,7 @@ func TestTools(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) ctx = toolsdk.WithClient(ctx, memberClient) - result, err := testTool(ctx, t, toolsdk.Whoami, map[string]any{}) + result, err := testTool(ctx, t, toolsdk.GetAuthenticatedUser, map[string]any{}) require.NoError(t, err) require.Equal(t, member.ID, result.ID) From 82803da325af76c1f569ecd039493a76cc5e4b0e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 11 Apr 2025 09:27:24 +0100 Subject: [PATCH 4/6] fix failing test --- codersdk/toolsdk/toolsdk_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index 44c0fa1b879e4..a51742fcbfe85 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -288,7 +288,10 @@ func TestTools(t *testing.T) { ctx = toolsdk.WithClient(ctx, client) // Create a new template version for use here. - tv := dbfake.TemplateVersion(t, store).SkipCreateTemplate().Do() + tv := dbfake.TemplateVersion(t, store). + // nolint:gocritic // This is in a test package and does not end up in the build + Seed(database.TemplateVersion{OrganizationID: owner.OrganizationID, CreatedBy: owner.UserID}). + SkipCreateTemplate().Do() // We're going to re-use the pre-existing template version _, err := testTool(ctx, t, toolsdk.CreateTemplate, map[string]any{ @@ -342,7 +345,7 @@ func TestMain(m *testing.M) { } } - if len(untested) > 0 { + if len(untested) > 0 && code == 0 { println("The following tools were not tested:") for _, tool := range untested { println(" - " + tool) From ae06274c95cdece8452a3a0b1e6333a9915c67a9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 11 Apr 2025 09:37:43 +0100 Subject: [PATCH 5/6] fix toolsdk testmain --- codersdk/toolsdk/toolsdk.go | 21 ++++++++++++--------- codersdk/toolsdk/toolsdk_test.go | 11 +++++++++-- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 9e4642503728a..835c37a65180e 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -32,22 +32,25 @@ func (t Tool[T]) Generic() Tool[any] { } var ( + // All is a list of all tools that can be used in the Coder CLI. + // When you add a new tool, be sure to include it here! All = []Tool[any]{ - ReportTask.Generic(), + CreateTemplateVersion.Generic(), + CreateTemplate.Generic(), CreateWorkspace.Generic(), + CreateWorkspaceBuild.Generic(), + DeleteTemplate.Generic(), + GetAuthenticatedUser.Generic(), + GetTemplateVersionLogs.Generic(), + GetWorkspace.Generic(), + GetWorkspaceAgentLogs.Generic(), + GetWorkspaceBuildLogs.Generic(), ListWorkspaces.Generic(), ListTemplates.Generic(), ListTemplateVersionParameters.Generic(), - GetAuthenticatedUser.Generic(), - CreateWorkspaceBuild.Generic(), + ReportTask.Generic(), UploadTarFile.Generic(), - CreateTemplateVersion.Generic(), - CreateTemplate.Generic(), - GetTemplateVersionLogs.Generic(), UpdateTemplateActiveVersion.Generic(), - DeleteTemplate.Generic(), - GetWorkspaceAgentLogs.Generic(), - GetWorkspaceBuildLogs.Generic(), } ReportTask = Tool[string]{ diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index a51742fcbfe85..ee48a6dd8c780 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -329,7 +329,7 @@ var testedTools sync.Map // testTool is a helper function to test a tool and mark it as tested. func testTool[T any](ctx context.Context, t *testing.T, tool toolsdk.Tool[T], args map[string]any) (T, error) { t.Helper() - testedTools.Store(tool.Tool.Name, struct{}{}) + testedTools.Store(tool.Tool.Name, true) result, err := tool.Handler(ctx, args) return result, err } @@ -337,10 +337,17 @@ func testTool[T any](ctx context.Context, t *testing.T, tool toolsdk.Tool[T], ar // TestMain runs after all tests to ensure that all tools in this package have // been tested once. func TestMain(m *testing.M) { + // Initialize testedTools + for _, tool := range toolsdk.All { + testedTools.Store(tool.Tool.Name, false) + } + code := m.Run() + + // Ensure all tools have been tested var untested []string for _, tool := range toolsdk.All { - if _, ok := testedTools.Load(tool.Tool.Name); !ok { + if tested, ok := testedTools.Load(tool.Tool.Name); !ok || !tested.(bool) { untested = append(untested, tool.Tool.Name) } } From 52cdef4bfdd7b36464fa6577f1306175e5100eab Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 11 Apr 2025 10:02:44 +0100 Subject: [PATCH 6/6] fixup! whoami -> get_authenticated-user --- cli/exp_mcp_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go index 4a8ae34a603b8..0151021579814 100644 --- a/cli/exp_mcp_test.go +++ b/cli/exp_mcp_test.go @@ -39,7 +39,7 @@ func TestExpMcpServer(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", "server", "--allowed-tools=coder_whoami") + inv, root := clitest.New(t, "exp", "mcp", "server", "--allowed-tools=coder_get_authenticated_user") inv = inv.WithContext(cancelCtx) pty := ptytest.New(t) @@ -80,7 +80,7 @@ func TestExpMcpServer(t *testing.T) { foundTools = append(foundTools, tool.Name) } slices.Sort(foundTools) - require.Equal(t, []string{"coder_whoami"}, foundTools) + require.Equal(t, []string{"coder_get_authenticated_user"}, foundTools) }) t.Run("OK", func(t *testing.T) {