diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go
index 2726f2a3d53cc..8b8c96ab41863 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
}()
@@ -527,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.
@@ -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..0151021579814 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_get_authenticated_user")
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_get_authenticated_user"}, 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..835c37a65180e
--- /dev/null
+++ b/codersdk/toolsdk/toolsdk.go
@@ -0,0 +1,1244 @@
+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 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]{
+ 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(),
+ ReportTask.Generic(),
+ UploadTarFile.Generic(),
+ UpdateTemplateActiveVersion.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
+ },
+ }
+
+ GetAuthenticatedUser = Tool[codersdk.User]{
+ Tool: aisdk.Tool{
+ 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) {
+ 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..ee48a6dd8c780
--- /dev/null
+++ b/codersdk/toolsdk/toolsdk_test.go
@@ -0,0 +1,367 @@
+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.GetAuthenticatedUser, 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).
+ // 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{
+ "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, true)
+ 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) {
+ // 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 tested, ok := testedTools.Load(tool.Tool.Name); !ok || !tested.(bool) {
+ untested = append(untested, tool.Tool.Name)
+ }
+ }
+
+ if len(untested) > 0 && code == 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/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=
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
- }
-}