Files
Cian Johnston 04cc983833 fix: add preset support to MCP tools (#24694)
The chat tools (`read_template`, `create_workspace`) did not surface or
respect template version presets. Presets were invisible to the LLM and
preset parameter defaults were never applied at workspace creation. The
`toolsdk` MCP surface had the same gap (ref #24695, now subsumed here).

## What this changes

- **`read_template`** returns presets with `id`, `name`, `default`,
`description`, `icon`, `parameters`, and `desired_prebuild_instances`
(when set), so the LLM can pick the right preset and prefer
prebuilt-backed ones.
- **`create_workspace`** accepts a `preset_id`. The wsbuilder applies
preset parameter defaults and may claim a prebuilt workspace.
- **`start_workspace`** does *not* accept a preset. Presets are a
creation-time choice; subsequent starts use the workspace's existing
version and parameters. Users who need a specific preset or version on
an existing chat can create the workspace out-of-band (CLI / UI / API)
with the desired configuration and attach the chat to it.
- **`toolsdk`** gains `GetTemplate` (with presets including
`desired_prebuild_instances`), preset support on `CreateWorkspace`, and
preset + `rich_parameters` support on `CreateWorkspaceBuild`. The
`template_version_preset_id` description warns about preset/version
affinity.


> 🤖 Generated with [Coder Agents](https://coder.com/agents) and reviewed by a human.

Co-authored-by: Max schwenk <maschwenk@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:57:52 +01:00

2516 lines
82 KiB
Go

package toolsdk
import (
"archive/tar"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"runtime/debug"
"strconv"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/aisdk-go"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// Tool name constants to avoid hardcoded strings
const (
ToolNameReportTask = "coder_report_task"
ToolNameGetWorkspace = "coder_get_workspace"
ToolNameCreateWorkspace = "coder_create_workspace"
ToolNameListWorkspaces = "coder_list_workspaces"
ToolNameListTemplates = "coder_list_templates"
ToolNameListTemplateVersionParams = "coder_template_version_parameters"
ToolNameGetTemplate = "coder_get_template"
ToolNameGetAuthenticatedUser = "coder_get_authenticated_user"
ToolNameCreateWorkspaceBuild = "coder_create_workspace_build"
ToolNameCreateTemplateVersion = "coder_create_template_version"
ToolNameGetWorkspaceAgentLogs = "coder_get_workspace_agent_logs"
ToolNameGetWorkspaceBuildLogs = "coder_get_workspace_build_logs"
ToolNameGetTemplateVersionLogs = "coder_get_template_version_logs"
ToolNameUpdateTemplateActiveVersion = "coder_update_template_active_version"
ToolNameUploadTarFile = "coder_upload_tar_file"
ToolNameCreateTemplate = "coder_create_template"
ToolNameDeleteTemplate = "coder_delete_template"
ToolNameWorkspaceBash = "coder_workspace_bash"
ToolNameChatGPTSearch = "search"
ToolNameChatGPTFetch = "fetch"
ToolNameWorkspaceLS = "coder_workspace_ls"
ToolNameWorkspaceReadFile = "coder_workspace_read_file"
ToolNameWorkspaceWriteFile = "coder_workspace_write_file"
ToolNameWorkspaceEditFile = "coder_workspace_edit_file"
ToolNameWorkspaceEditFiles = "coder_workspace_edit_files"
ToolNameWorkspacePortForward = "coder_workspace_port_forward"
ToolNameWorkspaceListApps = "coder_workspace_list_apps"
ToolNameCreateTask = "coder_create_task"
ToolNameDeleteTask = "coder_delete_task"
ToolNameListTasks = "coder_list_tasks"
ToolNameGetTaskStatus = "coder_get_task_status"
ToolNameSendTaskInput = "coder_send_task_input"
ToolNameGetTaskLogs = "coder_get_task_logs"
)
func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) {
d := Deps{
coderClient: client,
}
for _, opt := range opts {
opt(&d)
}
if d.agentConnFn == nil && d.coderClient != nil {
workspaceClient := workspacesdk.New(d.coderClient)
d.agentConnFn = func(ctx context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) {
conn, err := workspaceClient.DialAgent(ctx, agentID, nil)
if err != nil {
return nil, nil, err
}
return conn, nil, nil
}
}
// Allow nil client for unauthenticated operation
// This enables tools that don't require user authentication to function
return d, nil
}
// Deps provides access to tool dependencies.
type Deps struct {
coderClient *codersdk.Client
report func(ReportTaskArgs) error
agentConnFn workspacesdk.AgentConnFunc
}
func (d Deps) ServerURL() string {
serverURLCopy := *d.coderClient.URL
serverURLCopy.Path = ""
serverURLCopy.RawQuery = ""
return serverURLCopy.String()
}
func WithTaskReporter(fn func(ReportTaskArgs) error) func(*Deps) {
return func(d *Deps) {
d.report = fn
}
}
// WithAgentConnFunc overrides how workspace tools open logical connections to
// workspace agents.
func WithAgentConnFunc(agentConnFn workspacesdk.AgentConnFunc) func(*Deps) {
return func(d *Deps) {
d.agentConnFn = agentConnFn
}
}
// openAgentConn opens a ready workspace agent session for workspace inputs in
// [owner/]workspace[.agent] format.
func openAgentConn(ctx context.Context, deps Deps, workspace string) (workspacesdk.AgentConn, error) {
if deps.coderClient == nil {
return nil, xerrors.New("workspace tools require an authenticated client")
}
workspaceName := NormalizeWorkspaceInput(workspace)
_, workspaceAgent, err := findWorkspaceAndAgent(ctx, deps.coderClient, workspaceName)
if err != nil {
return nil, xerrors.Errorf("failed to find workspace: %w", err)
}
if err := cliui.Agent(ctx, io.Discard, workspaceAgent.ID, cliui.AgentOptions{
FetchInterval: 0,
Fetch: deps.coderClient.WorkspaceAgent,
FetchLogs: deps.coderClient.WorkspaceAgentLogsAfter,
// Always wait for startup scripts.
Wait: true,
}); err != nil {
return nil, xerrors.Errorf("agent not ready: %w", err)
}
conn, release, err := deps.agentConnFn(ctx, workspaceAgent.ID)
if err != nil {
return nil, xerrors.Errorf("failed to dial agent: %w", err)
}
wrappedConn := workspacesdk.WrapAgentConn(conn, func() error {
if release != nil {
release()
}
return nil
})
if wrappedConn == nil {
return nil, xerrors.New("agent connection function returned nil connection")
}
return wrappedConn, nil
}
// HandlerFunc is a typed function that handles a tool call.
type HandlerFunc[Arg, Ret any] func(context.Context, Deps, Arg) (Ret, error)
// Tool consists of an aisdk.Tool and a corresponding typed handler function.
type Tool[Arg, Ret any] struct {
aisdk.Tool
Handler HandlerFunc[Arg, Ret]
// MCPAnnotations is the shared source of truth for MCP tool
// classification. Both the coderd-hosted MCP server and the CLI MCP
// server translate these hints into mcp.Tool.Annotations so hosts can
// consistently group tools.
MCPAnnotations MCPToolAnnotations
// UserClientOptional indicates whether this tool can function without a valid
// user authentication token. If true, the tool will be available even when
// running in an unauthenticated mode with just an agent token.
UserClientOptional bool
}
// MCPToolAnnotations describes how an MCP host should classify a tool.
type MCPToolAnnotations struct {
ReadOnlyHint bool
DestructiveHint bool
IdempotentHint bool
OpenWorldHint bool
}
var (
mcpReadOnlyAnnotations = MCPToolAnnotations{
ReadOnlyHint: true,
DestructiveHint: false,
IdempotentHint: true,
OpenWorldHint: false,
}
mcpMutationAnnotations = MCPToolAnnotations{
ReadOnlyHint: false,
DestructiveHint: false,
IdempotentHint: false,
OpenWorldHint: false,
}
mcpDestructiveAnnotations = MCPToolAnnotations{
ReadOnlyHint: false,
DestructiveHint: true,
IdempotentHint: false,
OpenWorldHint: false,
}
)
// Generic returns a type-erased version of a TypedTool where the arguments and
// return values are converted to/from json.RawMessage.
// This allows the tool to be referenced without knowing the concrete arguments
// or return values. The original TypedHandlerFunc is wrapped to handle type
// conversion.
func (t Tool[Arg, Ret]) Generic() GenericTool {
return GenericTool{
Tool: t.Tool,
MCPAnnotations: t.MCPAnnotations,
UserClientOptional: t.UserClientOptional,
Handler: wrap(func(ctx context.Context, deps Deps, args json.RawMessage) (json.RawMessage, error) {
var typedArgs Arg
if err := json.Unmarshal(args, &typedArgs); err != nil {
return nil, xerrors.Errorf("failed to unmarshal args: %w", err)
}
ret, err := t.Handler(ctx, deps, typedArgs)
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(ret); err != nil {
return json.RawMessage{}, err
}
return buf.Bytes(), err
}, WithCleanContext, WithRecover),
}
}
// GenericTool is a type-erased wrapper for GenericTool.
// This allows referencing the tool without knowing the concrete argument or
// return type. The Handler function allows calling the tool with known types.
type GenericTool struct {
aisdk.Tool
Handler GenericHandlerFunc
// MCPAnnotations are host hints used when this tool is exposed over MCP.
MCPAnnotations MCPToolAnnotations
// UserClientOptional indicates whether this tool can function without a valid
// user authentication token. If true, the tool will be available even when
// running in an unauthenticated mode with just an agent token.
UserClientOptional bool
}
// GenericHandlerFunc is a function that handles a tool call.
type GenericHandlerFunc func(context.Context, Deps, json.RawMessage) (json.RawMessage, error)
// NoArgs just represents an empty argument struct.
type NoArgs struct{}
// WithRecover wraps a HandlerFunc to recover from panics and return an error.
func WithRecover(h GenericHandlerFunc) GenericHandlerFunc {
return func(ctx context.Context, deps Deps, args json.RawMessage) (ret json.RawMessage, err error) {
defer func() {
if r := recover(); r != nil {
if buildinfo.IsDev() {
// Capture stack trace in dev builds
stack := debug.Stack()
err = xerrors.Errorf("tool handler panic: %v\nstack trace:\n%s", r, stack)
} else {
// Simple error message in production builds
err = xerrors.Errorf("tool handler panic: %v", r)
}
}
}()
return h(ctx, deps, args)
}
}
// WithCleanContext wraps a HandlerFunc to provide it with a new context.
// This ensures that no data is passed using context.Value.
// If a deadline is set on the parent context, it will be passed to the child
// context.
func WithCleanContext(h GenericHandlerFunc) GenericHandlerFunc {
return func(parent context.Context, deps Deps, args json.RawMessage) (ret json.RawMessage, err error) {
child, childCancel := context.WithCancel(context.Background())
defer childCancel()
// Ensure that the child context has the same deadline as the parent
// context.
if deadline, ok := parent.Deadline(); ok {
deadlineCtx, deadlineCancel := context.WithDeadline(child, deadline)
defer deadlineCancel()
child = deadlineCtx
}
// Ensure that cancellation propagates from the parent context to the child context.
go func() {
select {
case <-child.Done():
return
case <-parent.Done():
childCancel()
}
}()
return h(child, deps, args)
}
}
// wrap wraps the provided GenericHandlerFunc with the provided middleware functions.
func wrap(hf GenericHandlerFunc, mw ...func(GenericHandlerFunc) GenericHandlerFunc) GenericHandlerFunc {
for _, m := range mw {
hf = m(hf)
}
return hf
}
// 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!
var All = []GenericTool{
CreateTemplate.Generic(),
CreateTemplateVersion.Generic(),
CreateWorkspace.Generic(),
CreateWorkspaceBuild.Generic(),
DeleteTemplate.Generic(),
ListTemplates.Generic(),
ListTemplateVersionParameters.Generic(),
GetTemplate.Generic(),
ListWorkspaces.Generic(),
GetAuthenticatedUser.Generic(),
GetTemplateVersionLogs.Generic(),
GetWorkspace.Generic(),
GetWorkspaceAgentLogs.Generic(),
GetWorkspaceBuildLogs.Generic(),
ReportTask.Generic(),
UploadTarFile.Generic(),
UpdateTemplateActiveVersion.Generic(),
WorkspaceBash.Generic(),
ChatGPTSearch.Generic(),
ChatGPTFetch.Generic(),
WorkspaceLS.Generic(),
WorkspaceReadFile.Generic(),
WorkspaceWriteFile.Generic(),
WorkspaceEditFile.Generic(),
WorkspaceEditFiles.Generic(),
WorkspacePortForward.Generic(),
WorkspaceListApps.Generic(),
CreateTask.Generic(),
DeleteTask.Generic(),
ListTasks.Generic(),
GetTaskStatus.Generic(),
SendTaskInput.Generic(),
GetTaskLogs.Generic(),
}
type ReportTaskArgs struct {
Link string `json:"link"`
State string `json:"state"`
Summary string `json:"summary"`
}
var ReportTask = Tool[ReportTaskArgs, codersdk.Response]{
Tool: aisdk.Tool{
Name: ToolNameReportTask,
Description: `Report progress on your work.
The user observes your work through a Task UI. To keep them updated
on your progress, or if you need help - use this tool.
Good Tasks
- "Cloning the repository <repository-url>"
- "Working on <feature-name>"
- "Figuring our why <issue> is happening"
Bad Tasks
- "I'm working on it"
- "I'm trying to fix it"
- "I'm trying to implement <feature-name>"
Use the "state" field to indicate your progress. Periodically report
progress with state "working" to keep the user updated. It is not possible to send too many updates!
ONLY report a "complete", "idle", or "failure" state if you have FULLY completed the task.
`,
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 and must not include newlines or other control characters.",
},
"link": map[string]any{
"type": "string",
"description": "A link to a relevant resource, such as a PR or issue.",
},
"state": map[string]any{
"type": "string",
"description": "The state of your task. This can be one of the following: working, complete, idle, or failure. Select the state that best represents your current progress.",
"enum": []string{
string(codersdk.WorkspaceAppStatusStateWorking),
string(codersdk.WorkspaceAppStatusStateComplete),
string(codersdk.WorkspaceAppStatusStateIdle),
string(codersdk.WorkspaceAppStatusStateFailure),
},
},
},
Required: []string{"summary", "link", "state"},
},
},
MCPAnnotations: mcpMutationAnnotations,
UserClientOptional: true,
Handler: func(_ context.Context, deps Deps, args ReportTaskArgs) (codersdk.Response, error) {
if len(args.Summary) > 160 {
return codersdk.Response{}, xerrors.New("summary must be less than 160 characters")
}
// Check if task reporting is available to prevent nil pointer dereference
if deps.report == nil {
return codersdk.Response{}, xerrors.New("task reporting not available. Please ensure a task reporter is configured.")
}
err := deps.report(args)
if err != nil {
return codersdk.Response{}, err
}
return codersdk.Response{
Message: "Thanks for reporting!",
}, nil
},
}
type GetWorkspaceArgs struct {
WorkspaceID string `json:"workspace_id"`
}
var GetWorkspace = Tool[GetWorkspaceArgs, codersdk.Workspace]{
Tool: aisdk.Tool{
Name: ToolNameGetWorkspace,
Description: `Get a workspace by name or 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",
"description": workspaceDescription,
},
},
Required: []string{"workspace_id"},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
Handler: func(ctx context.Context, deps Deps, args GetWorkspaceArgs) (codersdk.Workspace, error) {
return deps.coderClient.ResolveWorkspace(ctx, NormalizeWorkspaceInput(args.WorkspaceID))
},
}
type CreateWorkspaceArgs struct {
Name string `json:"name"`
RichParameters map[string]string `json:"rich_parameters"`
TemplateID string `json:"template_id,omitempty"`
TemplateVersionID string `json:"template_version_id,omitempty"`
TemplateVersionPresetID string `json:"template_version_preset_id,omitempty"`
User string `json:"user"`
}
// richParametersFromMap converts the map shape used on tool args into the
// slice shape used on the wire. Iteration order is undefined, which is fine
// because wsbuilder treats RichParameterValues as a set keyed by Name.
func richParametersFromMap(m map[string]string) []codersdk.WorkspaceBuildParameter {
if len(m) == 0 {
return nil
}
out := make([]codersdk.WorkspaceBuildParameter, 0, len(m))
for k, v := range m {
out = append(out, codersdk.WorkspaceBuildParameter{Name: k, Value: v})
}
return out
}
var CreateWorkspace = Tool[CreateWorkspaceArgs, codersdk.Workspace]{
Tool: aisdk.Tool{
Name: ToolNameCreateWorkspace,
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.
Before creating a workspace, always confirm the template choice with the user by:
1. Listing the available templates that match their request.
2. Recommending the most relevant option.
2. Asking the user to confirm which template to use.
It is important to not create a workspace without confirming the template
choice with the user.
After creating a workspace, watch the build logs and wait for the workspace to
be ready before trying to use or connect to the workspace.
`,
Schema: aisdk.Schema{
Properties: map[string]any{
"user": map[string]any{
"type": "string",
"description": userDescription("create a workspace"),
},
"template_id": map[string]any{
"type": "string",
"description": "ID of the template to create the workspace from. The server resolves the active version. Prefer this over template_version_id unless you specifically need to pin a non-active version. Obtain this from coder_list_templates or coder_get_template.",
},
"template_version_id": map[string]any{
"type": "string",
"description": "ID of a specific template version to create the workspace from. Use only when pinning a non-active version is required; otherwise prefer template_id. Mutually exclusive with template_id.",
},
"template_version_preset_id": map[string]any{
"type": "string",
"description": "Optional ID of a template version preset to create the workspace from. Obtain available presets from coder_get_template. When set, the preset's parameter values take precedence over conflicting entries in rich_parameters.",
},
"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", "name", "rich_parameters"},
},
},
MCPAnnotations: mcpMutationAnnotations,
Handler: func(ctx context.Context, deps Deps, args CreateWorkspaceArgs) (codersdk.Workspace, error) {
// The REST API requires exactly one of template_id or
// template_version_id. Pre-validate here so the LLM gets a
// clear, actionable error instead of an opaque server-side
// validation failure.
if (args.TemplateID == "") == (args.TemplateVersionID == "") {
return codersdk.Workspace{}, xerrors.New("exactly one of template_id or template_version_id must be provided")
}
var (
tID uuid.UUID
tvID uuid.UUID
err error
)
if args.TemplateID != "" {
tID, err = uuid.Parse(args.TemplateID)
if err != nil {
return codersdk.Workspace{}, xerrors.New("template_id must be a valid UUID")
}
}
if args.TemplateVersionID != "" {
tvID, err = uuid.Parse(args.TemplateVersionID)
if err != nil {
return codersdk.Workspace{}, xerrors.New("template_version_id must be a valid UUID")
}
}
var tvPresetID uuid.UUID
if args.TemplateVersionPresetID != "" {
tvPresetID, err = uuid.Parse(args.TemplateVersionPresetID)
if err != nil {
return codersdk.Workspace{}, xerrors.New("template_version_preset_id must be a valid UUID")
}
}
if args.User == "" {
args.User = codersdk.Me
}
req := codersdk.CreateWorkspaceRequest{
TemplateID: tID,
TemplateVersionID: tvID,
Name: args.Name,
RichParameterValues: richParametersFromMap(args.RichParameters),
}
if tvPresetID != uuid.Nil {
req.TemplateVersionPresetID = tvPresetID
}
// When no preset is supplied, wsbuilder may still auto-bind a
// preset whose parameter values exactly match RichParameterValues.
// This is intentional pre-existing server-side behavior; the tool
// surface does not suppress it.
workspace, err := deps.coderClient.CreateUserWorkspace(ctx, args.User, req)
if err != nil {
return codersdk.Workspace{}, err
}
return workspace, nil
},
}
type ListWorkspacesArgs struct {
Owner string `json:"owner"`
}
var ListWorkspaces = Tool[ListWorkspacesArgs, []MinimalWorkspace]{
Tool: aisdk.Tool{
Name: ToolNameListWorkspaces,
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.",
},
},
Required: []string{},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
Handler: func(ctx context.Context, deps Deps, args ListWorkspacesArgs) ([]MinimalWorkspace, error) {
owner := args.Owner
if owner == "" {
owner = codersdk.Me
}
workspaces, err := deps.coderClient.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
},
}
var ListTemplates = Tool[NoArgs, []MinimalTemplate]{
Tool: aisdk.Tool{
Name: ToolNameListTemplates,
Description: "Lists templates for the authenticated user.",
Schema: aisdk.Schema{
Properties: map[string]any{},
Required: []string{},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
Handler: func(ctx context.Context, deps Deps, _ NoArgs) ([]MinimalTemplate, error) {
templates, err := deps.coderClient.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
},
}
type ListTemplateVersionParametersArgs struct {
TemplateVersionID string `json:"template_version_id"`
}
var ListTemplateVersionParameters = Tool[ListTemplateVersionParametersArgs, []codersdk.TemplateVersionParameter]{
Tool: aisdk.Tool{
Name: ToolNameListTemplateVersionParams,
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"},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
Handler: func(ctx context.Context, deps Deps, args ListTemplateVersionParametersArgs) ([]codersdk.TemplateVersionParameter, error) {
templateVersionID, err := uuid.Parse(args.TemplateVersionID)
if err != nil {
return nil, xerrors.Errorf("template_version_id must be a valid UUID: %w", err)
}
parameters, err := deps.coderClient.TemplateVersionRichParameters(ctx, templateVersionID)
if err != nil {
return nil, err
}
return parameters, nil
},
}
type GetTemplateArgs struct {
TemplateID string `json:"template_id"`
}
// TemplateDetail extends MinimalTemplate with the active version's
// rich parameters and presets. Presets are omitted when the template
// has none, to mirror the chattool read_template response shape.
type TemplateDetail struct {
MinimalTemplate
Parameters []codersdk.TemplateVersionParameter `json:"parameters"`
Presets []presetView `json:"presets,omitempty"`
}
// presetView is a tool-local projection of codersdk.Preset with
// snake_case JSON keys that match the field names referenced in
// the create_workspace tool description. codersdk.Preset has no
// JSON tags, so its fields would otherwise serialize as PascalCase
// and the LLM would look for keys that do not exist on the wire.
type presetView struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Default bool `json:"default"`
DesiredPrebuildInstances *int `json:"desired_prebuild_instances,omitempty"`
Parameters []presetParameterView `json:"parameters"`
}
type presetParameterView struct {
Name string `json:"name"`
Value string `json:"value"`
}
func toPresetView(p codersdk.Preset) presetView {
params := make([]presetParameterView, 0, len(p.Parameters))
for _, pp := range p.Parameters {
params = append(params, presetParameterView{
Name: pp.Name,
Value: pp.Value,
})
}
return presetView{
ID: p.ID,
Name: p.Name,
Description: p.Description,
Default: p.Default,
DesiredPrebuildInstances: p.DesiredPrebuildInstances,
Parameters: params,
}
}
var GetTemplate = Tool[GetTemplateArgs, TemplateDetail]{
Tool: aisdk.Tool{
Name: ToolNameGetTemplate,
Description: `Get details about a workspace template, including its configurable parameters and available presets for the active version.
Use this after finding a template with coder_list_templates and before creating a workspace with coder_create_workspace. Presets, when present, can be passed to coder_create_workspace as template_version_preset_id.
When selecting a preset: if a preset is marked default and the user has not specified preferences, prefer that preset. Presets with desired_prebuild_instances > 0 may have prebuilt workspaces available for faster startup; prefer those when startup speed matters.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"template_id": map[string]any{
"type": "string",
"description": "ID of the template to read details for. Obtain this from coder_list_templates.",
},
},
Required: []string{"template_id"},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
Handler: func(ctx context.Context, deps Deps, args GetTemplateArgs) (TemplateDetail, error) {
templateID, err := uuid.Parse(args.TemplateID)
if err != nil {
return TemplateDetail{}, xerrors.Errorf("template_id must be a valid UUID: %w", err)
}
template, err := deps.coderClient.Template(ctx, templateID)
if err != nil {
return TemplateDetail{}, xerrors.Errorf("get template: %w", err)
}
// A template without an active version would cause the
// follow-up calls to issue confusing "not found" errors
// against a zero UUID. Fail clearly instead.
if template.ActiveVersionID == uuid.Nil {
return TemplateDetail{}, xerrors.New("template has no active version")
}
parameters, err := deps.coderClient.TemplateVersionRichParameters(ctx, template.ActiveVersionID)
if err != nil {
return TemplateDetail{}, xerrors.Errorf("get template parameters: %w", err)
}
presets, err := deps.coderClient.TemplateVersionPresets(ctx, template.ActiveVersionID)
if err != nil {
return TemplateDetail{}, xerrors.Errorf("get template presets: %w", err)
}
detail := TemplateDetail{
MinimalTemplate: MinimalTemplate{
DisplayName: template.DisplayName,
ID: template.ID.String(),
Name: template.Name,
Description: template.Description,
ActiveVersionID: template.ActiveVersionID,
ActiveUserCount: template.ActiveUserCount,
},
Parameters: parameters,
}
for _, p := range presets {
detail.Presets = append(detail.Presets, toPresetView(p))
}
return detail, nil
},
}
var GetAuthenticatedUser = Tool[NoArgs, codersdk.User]{
Tool: aisdk.Tool{
Name: ToolNameGetAuthenticatedUser,
Description: "Get the currently authenticated user, similar to the `whoami` command.",
Schema: aisdk.Schema{
Properties: map[string]any{},
Required: []string{},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
Handler: func(ctx context.Context, deps Deps, _ NoArgs) (codersdk.User, error) {
return deps.coderClient.User(ctx, "me")
},
}
type CreateWorkspaceBuildArgs struct {
RichParameters map[string]string `json:"rich_parameters,omitempty"`
TemplateVersionID string `json:"template_version_id"`
TemplateVersionPresetID string `json:"template_version_preset_id,omitempty"`
Transition string `json:"transition"`
WorkspaceID string `json:"workspace_id"`
}
var CreateWorkspaceBuild = Tool[CreateWorkspaceBuildArgs, codersdk.WorkspaceBuild]{
Tool: aisdk.Tool{
Name: ToolNameCreateWorkspaceBuild,
Description: `Create a new workspace build for an existing workspace. Use this to start, stop, or delete.
For start transitions, optionally pass template_version_preset_id to apply a
preset (obtain available presets from coder_get_template), or rich_parameters
to override individual parameter values. Both fields are rejected on stop and
delete transitions because they are scoped to a starting build.
After creating a workspace build, watch the build logs and wait for the
workspace build to complete before trying to start another build or use or
connect to the workspace.
`,
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",
"enum": []string{"start", "stop", "delete"},
},
"template_version_id": map[string]any{
"type": "string",
"description": "(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.",
},
"template_version_preset_id": map[string]any{
"type": "string",
"description": "(Optional) ID of a template version preset to apply. Only valid for start transitions. Obtain available presets from coder_get_template. Presets are scoped to the template version they were created on; pass template_version_id with the same version the preset came from when the workspace's current build is on a different version, otherwise the build may apply mismatched parameter defaults. When set, the preset's parameter values take precedence over conflicting entries in rich_parameters.",
},
"rich_parameters": map[string]any{
"type": "object",
"description": "(Optional) Key/value pairs of rich parameters to apply to the build. Only valid for start transitions.",
},
},
Required: []string{"workspace_id", "transition"},
},
},
MCPAnnotations: mcpDestructiveAnnotations,
Handler: func(ctx context.Context, deps Deps, args CreateWorkspaceBuildArgs) (codersdk.WorkspaceBuild, error) {
workspaceID, err := uuid.Parse(args.WorkspaceID)
if err != nil {
return codersdk.WorkspaceBuild{}, xerrors.Errorf("workspace_id must be a valid UUID: %w", err)
}
transition := codersdk.WorkspaceTransition(args.Transition)
// Presets and rich_parameters are scoped to a starting build;
// they have no meaning on stop or delete transitions. Surface
// both violations at once via errors.Join so agents fix them
// in a single round-trip instead of one tool call per error.
if transition != codersdk.WorkspaceTransitionStart {
var errs []error
if args.TemplateVersionPresetID != "" {
errs = append(errs, xerrors.New("template_version_preset_id is only valid for start transitions"))
}
if len(args.RichParameters) > 0 {
errs = append(errs, xerrors.New("rich_parameters is only valid for start transitions"))
}
if len(errs) > 0 {
return codersdk.WorkspaceBuild{}, errors.Join(errs...)
}
}
cbr := codersdk.CreateWorkspaceBuildRequest{
Transition: transition,
RichParameterValues: richParametersFromMap(args.RichParameters),
}
if args.TemplateVersionID != "" {
cbr.TemplateVersionID, err = uuid.Parse(args.TemplateVersionID)
if err != nil {
return codersdk.WorkspaceBuild{}, xerrors.Errorf("template_version_id must be a valid UUID: %w", err)
}
}
if args.TemplateVersionPresetID != "" {
cbr.TemplateVersionPresetID, err = uuid.Parse(args.TemplateVersionPresetID)
if err != nil {
return codersdk.WorkspaceBuild{}, xerrors.Errorf("template_version_preset_id must be a valid UUID: %w", err)
}
}
return deps.coderClient.CreateWorkspaceBuild(ctx, workspaceID, cbr)
},
}
type CreateTemplateVersionArgs struct {
FileID string `json:"file_id"`
TemplateID string `json:"template_id"`
}
var CreateTemplateVersion = Tool[CreateTemplateVersionArgs, codersdk.TemplateVersion]{
Tool: aisdk.Tool{
Name: ToolNameCreateTemplateVersion,
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.
<terraform-spec>
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. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.
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.
</terraform-spec>
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:
<aws-ec2-instance>
// 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]
}
}
</aws-ec2-instance>
<gcp-vm-instance>
// 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
}
// In order to use google-instance-identity, a service account *must* be provided.
service_account {
email = data.google_compute_default_service_account.default.email
scopes = ["cloud-platform"]
}
# ONLY FOR WINDOWS:
# metadata = {
# windows-startup-script-ps1 = coder_agent.main.init_script
# }
# 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.
#
# The agent MUST be started in here.
metadata_startup_script = <<EOMETA
#!/usr/bin/env sh
set -eux
# If user does not exist, create it and set up passwordless sudo
if ! id -u "${local.linux_user}" >/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
}
</gcp-vm-instance>
<azure-vm-instance>
// 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
}
</azure-vm-instance>
<docker-container>
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
}
}
</docker-container>
<kubernetes-pod>
// 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
}
}
}
}
}
}
</kubernetes-pod>
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"},
},
},
MCPAnnotations: mcpMutationAnnotations,
Handler: func(ctx context.Context, deps Deps, args CreateTemplateVersionArgs) (codersdk.TemplateVersion, error) {
me, err := deps.coderClient.User(ctx, "me")
if err != nil {
return codersdk.TemplateVersion{}, err
}
fileID, err := uuid.Parse(args.FileID)
if err != nil {
return codersdk.TemplateVersion{}, xerrors.Errorf("file_id must be a valid UUID: %w", err)
}
var templateID uuid.UUID
if args.TemplateID != "" {
tid, err := uuid.Parse(args.TemplateID)
if err != nil {
return codersdk.TemplateVersion{}, xerrors.Errorf("template_id must be a valid UUID: %w", err)
}
templateID = tid
}
templateVersion, err := deps.coderClient.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
},
}
type GetWorkspaceAgentLogsArgs struct {
WorkspaceAgentID string `json:"workspace_agent_id"`
}
var GetWorkspaceAgentLogs = Tool[GetWorkspaceAgentLogsArgs, []string]{
Tool: aisdk.Tool{
Name: ToolNameGetWorkspaceAgentLogs,
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"},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
Handler: func(ctx context.Context, deps Deps, args GetWorkspaceAgentLogsArgs) ([]string, error) {
workspaceAgentID, err := uuid.Parse(args.WorkspaceAgentID)
if err != nil {
return nil, xerrors.Errorf("workspace_agent_id must be a valid UUID: %w", err)
}
logs, closer, err := deps.coderClient.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
},
}
type GetWorkspaceBuildLogsArgs struct {
WorkspaceBuildID string `json:"workspace_build_id"`
}
var GetWorkspaceBuildLogs = Tool[GetWorkspaceBuildLogsArgs, []string]{
Tool: aisdk.Tool{
Name: ToolNameGetWorkspaceBuildLogs,
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"},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
Handler: func(ctx context.Context, deps Deps, args GetWorkspaceBuildLogsArgs) ([]string, error) {
workspaceBuildID, err := uuid.Parse(args.WorkspaceBuildID)
if err != nil {
return nil, xerrors.Errorf("workspace_build_id must be a valid UUID: %w", err)
}
logs, closer, err := deps.coderClient.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
},
}
type GetTemplateVersionLogsArgs struct {
TemplateVersionID string `json:"template_version_id"`
}
var GetTemplateVersionLogs = Tool[GetTemplateVersionLogsArgs, []string]{
Tool: aisdk.Tool{
Name: ToolNameGetTemplateVersionLogs,
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"},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
Handler: func(ctx context.Context, deps Deps, args GetTemplateVersionLogsArgs) ([]string, error) {
templateVersionID, err := uuid.Parse(args.TemplateVersionID)
if err != nil {
return nil, xerrors.Errorf("template_version_id must be a valid UUID: %w", err)
}
logs, closer, err := deps.coderClient.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
},
}
type UpdateTemplateActiveVersionArgs struct {
TemplateID string `json:"template_id"`
TemplateVersionID string `json:"template_version_id"`
}
var UpdateTemplateActiveVersion = Tool[UpdateTemplateActiveVersionArgs, string]{
Tool: aisdk.Tool{
Name: ToolNameUpdateTemplateActiveVersion,
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"},
},
},
MCPAnnotations: mcpMutationAnnotations,
Handler: func(ctx context.Context, deps Deps, args UpdateTemplateActiveVersionArgs) (string, error) {
templateID, err := uuid.Parse(args.TemplateID)
if err != nil {
return "", xerrors.Errorf("template_id must be a valid UUID: %w", err)
}
templateVersionID, err := uuid.Parse(args.TemplateVersionID)
if err != nil {
return "", xerrors.Errorf("template_version_id must be a valid UUID: %w", err)
}
err = deps.coderClient.UpdateActiveTemplateVersion(ctx, templateID, codersdk.UpdateActiveTemplateVersion{
ID: templateVersionID,
})
if err != nil {
return "", err
}
return "Successfully updated active version!", nil
},
}
type UploadTarFileArgs struct {
Files map[string]string `json:"files"`
}
var UploadTarFile = Tool[UploadTarFileArgs, codersdk.UploadResponse]{
Tool: aisdk.Tool{
Name: ToolNameUploadTarFile,
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{
"files": map[string]any{
"type": "object",
"description": "A map of file names to file contents.",
},
},
Required: []string{"files"},
},
},
MCPAnnotations: mcpMutationAnnotations,
Handler: func(ctx context.Context, deps Deps, args UploadTarFileArgs) (codersdk.UploadResponse, error) {
pipeReader, pipeWriter := io.Pipe()
done := make(chan struct{})
go func() {
defer func() {
_ = pipeWriter.Close()
close(done)
}()
tarWriter := tar.NewWriter(pipeWriter)
for name, content := range args.Files {
header := &tar.Header{
Name: name,
Size: int64(len(content)),
Mode: 0o644,
}
if err := tarWriter.WriteHeader(header); err != nil {
_ = pipeWriter.CloseWithError(err)
return
}
if _, err := tarWriter.Write([]byte(content)); err != nil {
_ = pipeWriter.CloseWithError(err)
return
}
}
if err := tarWriter.Close(); err != nil {
_ = pipeWriter.CloseWithError(err)
}
}()
resp, err := deps.coderClient.Upload(ctx, codersdk.ContentTypeTar, pipeReader)
if err != nil {
_ = pipeReader.CloseWithError(err)
<-done
return codersdk.UploadResponse{}, err
}
<-done
return resp, nil
},
}
type CreateTemplateArgs struct {
Description string `json:"description"`
DisplayName string `json:"display_name"`
Icon string `json:"icon"`
Name string `json:"name"`
VersionID string `json:"version_id"`
}
var CreateTemplate = Tool[CreateTemplateArgs, codersdk.Template]{
Tool: aisdk.Tool{
Name: ToolNameCreateTemplate,
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"},
},
},
MCPAnnotations: mcpMutationAnnotations,
Handler: func(ctx context.Context, deps Deps, args CreateTemplateArgs) (codersdk.Template, error) {
me, err := deps.coderClient.User(ctx, "me")
if err != nil {
return codersdk.Template{}, err
}
versionID, err := uuid.Parse(args.VersionID)
if err != nil {
return codersdk.Template{}, xerrors.Errorf("version_id must be a valid UUID: %w", err)
}
template, err := deps.coderClient.CreateTemplate(ctx, me.OrganizationIDs[0], codersdk.CreateTemplateRequest{
Name: args.Name,
DisplayName: args.DisplayName,
Description: args.Description,
VersionID: versionID,
})
if err != nil {
return codersdk.Template{}, err
}
return template, nil
},
}
type DeleteTemplateArgs struct {
TemplateID string `json:"template_id"`
}
var DeleteTemplate = Tool[DeleteTemplateArgs, codersdk.Response]{
Tool: aisdk.Tool{
Name: ToolNameDeleteTemplate,
Description: "Delete a template. This is irreversible.",
Schema: aisdk.Schema{
Properties: map[string]any{
"template_id": map[string]any{
"type": "string",
},
},
Required: []string{"template_id"},
},
},
MCPAnnotations: mcpDestructiveAnnotations,
Handler: func(ctx context.Context, deps Deps, args DeleteTemplateArgs) (codersdk.Response, error) {
templateID, err := uuid.Parse(args.TemplateID)
if err != nil {
return codersdk.Response{}, xerrors.Errorf("template_id must be a valid UUID: %w", err)
}
err = deps.coderClient.DeleteTemplate(ctx, templateID)
if err != nil {
return codersdk.Response{}, err
}
return codersdk.Response{
Message: "Template deleted successfully.",
}, 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"`
}
type WorkspaceLSArgs struct {
Workspace string `json:"workspace"`
Path string `json:"path"`
}
type WorkspaceLSFile struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
}
type WorkspaceLSResponse struct {
Contents []WorkspaceLSFile `json:"contents"`
}
var WorkspaceLS = Tool[WorkspaceLSArgs, WorkspaceLSResponse]{
Tool: aisdk.Tool{
Name: ToolNameWorkspaceLS,
Description: `List directories in a workspace.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"workspace": map[string]any{
"type": "string",
"description": workspaceAgentDescription,
},
"path": map[string]any{
"type": "string",
"description": "The absolute path of the directory in the workspace to list.",
},
},
Required: []string{"path", "workspace"},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args WorkspaceLSArgs) (WorkspaceLSResponse, error) {
conn, err := openAgentConn(ctx, deps, args.Workspace)
if err != nil {
return WorkspaceLSResponse{}, err
}
defer conn.Close()
res, err := conn.LS(ctx, args.Path, workspacesdk.LSRequest{})
if err != nil {
return WorkspaceLSResponse{}, err
}
contents := make([]WorkspaceLSFile, len(res.Contents))
for i, f := range res.Contents {
contents[i] = WorkspaceLSFile{
Path: f.AbsolutePathString,
IsDir: f.IsDir,
}
}
return WorkspaceLSResponse{Contents: contents}, nil
},
}
type WorkspaceReadFileArgs struct {
Workspace string `json:"workspace"`
Path string `json:"path"`
Offset int64 `json:"offset"`
Limit int64 `json:"limit"`
}
type WorkspaceReadFileResponse struct {
// Content is the base64-encoded bytes from the file.
Content []byte `json:"content"`
MimeType string `json:"mimeType"`
}
const maxFileLimit = 1 << 20 // 1MiB
var WorkspaceReadFile = Tool[WorkspaceReadFileArgs, WorkspaceReadFileResponse]{
Tool: aisdk.Tool{
Name: ToolNameWorkspaceReadFile,
Description: `Read from a file in a workspace.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"workspace": map[string]any{
"type": "string",
"description": workspaceAgentDescription,
},
"path": map[string]any{
"type": "string",
"description": "The absolute path of the file to read in the workspace.",
},
"offset": map[string]any{
"type": "integer",
"description": "A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.",
},
"limit": map[string]any{
"type": "integer",
"description": "The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.",
},
},
Required: []string{"path", "workspace"},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args WorkspaceReadFileArgs) (WorkspaceReadFileResponse, error) {
conn, err := openAgentConn(ctx, deps, args.Workspace)
if err != nil {
return WorkspaceReadFileResponse{}, err
}
defer conn.Close()
// Ideally we could stream this all the way back, but it looks like the MCP
// interfaces only allow returning full responses which means the whole
// thing has to be read into memory. So, add a maximum limit to compensate.
limit := args.Limit
if limit == 0 {
limit = maxFileLimit
} else if limit > maxFileLimit {
return WorkspaceReadFileResponse{}, xerrors.Errorf("limit must be %d or less, got %d", maxFileLimit, limit)
}
reader, mimeType, err := conn.ReadFile(ctx, args.Path, args.Offset, limit)
if err != nil {
return WorkspaceReadFileResponse{}, err
}
defer reader.Close()
bs, err := io.ReadAll(reader)
if err != nil {
return WorkspaceReadFileResponse{}, xerrors.Errorf("read response body: %w", err)
}
return WorkspaceReadFileResponse{Content: bs, MimeType: mimeType}, nil
},
}
type WorkspaceWriteFileArgs struct {
Workspace string `json:"workspace"`
Path string `json:"path"`
Content []byte `json:"content"`
}
var WorkspaceWriteFile = Tool[WorkspaceWriteFileArgs, codersdk.Response]{
Tool: aisdk.Tool{
Name: ToolNameWorkspaceWriteFile,
Description: `Write a file in a workspace.
If a file write fails due to syntax errors or encoding issues, do NOT switch
to using bash commands as a workaround. Instead:
1. Read the error message carefully to identify the issue
2. Fix the content encoding/syntax
3. Retry with this tool
The content parameter expects base64-encoded bytes. Ensure your source content
is correct before encoding it. If you encounter errors, decode and verify the
content you are trying to write, then re-encode it properly.
`,
Schema: aisdk.Schema{
Properties: map[string]any{
"workspace": map[string]any{
"type": "string",
"description": workspaceAgentDescription,
},
"path": map[string]any{
"type": "string",
"description": "The absolute path of the file to write in the workspace.",
},
"content": map[string]any{
"type": "string",
"description": "The base64-encoded bytes to write to the file.",
},
},
Required: []string{"path", "workspace", "content"},
},
},
MCPAnnotations: mcpDestructiveAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args WorkspaceWriteFileArgs) (codersdk.Response, error) {
conn, err := openAgentConn(ctx, deps, args.Workspace)
if err != nil {
return codersdk.Response{}, err
}
defer conn.Close()
reader := bytes.NewReader(args.Content)
err = conn.WriteFile(ctx, args.Path, reader)
if err != nil {
return codersdk.Response{}, err
}
return codersdk.Response{
Message: "File written successfully.",
}, nil
},
}
type WorkspaceEditFileArgs struct {
Workspace string `json:"workspace"`
Path string `json:"path"`
Edits []workspacesdk.FileEdit `json:"edits"`
}
// WorkspaceEditFilesResponse is the response shape for the edit-file
// and edit-files tools. Message preserves the existing success text.
// Files carries the per-file results returned by the agent
// (populated when the agent-side IncludeDiff flag was set). The
// field is named Files (matching the agent's FileEditResponse.Files)
// so future per-file error or status fields can be added without a
// second wire break.
type WorkspaceEditFilesResponse struct {
Message string `json:"message"`
Files []workspacesdk.FileEditResult `json:"files,omitempty"`
}
var WorkspaceEditFile = Tool[WorkspaceEditFileArgs, WorkspaceEditFilesResponse]{
Tool: aisdk.Tool{
Name: ToolNameWorkspaceEditFile,
Description: `Edit a file in a workspace.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"workspace": map[string]any{
"type": "string",
"description": workspaceAgentDescription,
},
"path": map[string]any{
"type": "string",
"description": "The absolute path of the file to write in the workspace.",
},
"edits": map[string]any{
"type": "array",
"description": "An array of edit operations.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"search": map[string]any{
"type": "string",
"description": "The old string to replace.",
},
"replace": map[string]any{
"type": "string",
"description": "The new string that replaces the old string.",
},
},
"required": []string{"search", "replace"},
},
},
},
Required: []string{"path", "workspace", "edits"},
},
},
MCPAnnotations: mcpDestructiveAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFileArgs) (WorkspaceEditFilesResponse, error) {
conn, err := openAgentConn(ctx, deps, args.Workspace)
if err != nil {
return WorkspaceEditFilesResponse{}, err
}
defer conn.Close()
resp, err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{
{
Path: args.Path,
Edits: args.Edits,
},
},
IncludeDiff: true,
})
if err != nil {
return WorkspaceEditFilesResponse{}, err
}
return WorkspaceEditFilesResponse{
Message: "File edited successfully.",
Files: resp.Files,
}, nil
},
}
type WorkspaceEditFilesArgs struct {
Workspace string `json:"workspace"`
Files []workspacesdk.FileEdits `json:"files"`
}
var WorkspaceEditFiles = Tool[WorkspaceEditFilesArgs, WorkspaceEditFilesResponse]{
Tool: aisdk.Tool{
Name: ToolNameWorkspaceEditFiles,
Description: `Edit one or more files in a workspace.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"workspace": map[string]any{
"type": "string",
"description": workspaceAgentDescription,
},
"files": map[string]any{
"type": "array",
"description": "An array of files to edit.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "The absolute path of the file to write in the workspace.",
},
"edits": map[string]any{
"type": "array",
"description": "An array of edit operations.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"search": map[string]any{
"type": "string",
"description": "The old string to replace. Must uniquely match exactly one location in the file unless replace_all is true. Include enough surrounding context to make the match unique.",
},
"replace": map[string]any{
"type": "string",
"description": "The new string that replaces the old string.",
},
"replace_all": map[string]any{
"type": "boolean",
"description": "When true, replaces all occurrences of the search string. Defaults to false, which requires the search string to match exactly once.",
},
},
"required": []string{"search", "replace"},
},
},
},
"required": []string{"path", "edits"},
},
},
},
Required: []string{"workspace", "files"},
},
},
MCPAnnotations: mcpDestructiveAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFilesArgs) (WorkspaceEditFilesResponse, error) {
conn, err := openAgentConn(ctx, deps, args.Workspace)
if err != nil {
return WorkspaceEditFilesResponse{}, err
}
defer conn.Close()
resp, err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{
Files: args.Files,
IncludeDiff: true,
})
if err != nil {
return WorkspaceEditFilesResponse{}, err
}
return WorkspaceEditFilesResponse{
Message: "File(s) edited successfully.",
Files: resp.Files,
}, nil
},
}
type WorkspacePortForwardArgs struct {
Workspace string `json:"workspace"`
Port int `json:"port"`
}
type WorkspacePortForwardResponse struct {
URL string `json:"url"`
}
var WorkspacePortForward = Tool[WorkspacePortForwardArgs, WorkspacePortForwardResponse]{
Tool: aisdk.Tool{
Name: ToolNameWorkspacePortForward,
Description: `Fetch URLs that forward to the specified port.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"workspace": map[string]any{
"type": "string",
"description": workspaceAgentDescription,
},
"port": map[string]any{
"type": "number",
"description": "The port to forward.",
},
},
Required: []string{"workspace", "port"},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args WorkspacePortForwardArgs) (WorkspacePortForwardResponse, error) {
workspaceName := NormalizeWorkspaceInput(args.Workspace)
workspace, workspaceAgent, err := findWorkspaceAndAgent(ctx, deps.coderClient, workspaceName)
if err != nil {
return WorkspacePortForwardResponse{}, xerrors.Errorf("failed to find workspace: %w", err)
}
res, err := deps.coderClient.AppHost(ctx)
if err != nil {
return WorkspacePortForwardResponse{}, xerrors.Errorf("failed to get app host: %w", err)
}
if res.Host == "" {
return WorkspacePortForwardResponse{}, xerrors.New("no app host for forwarding has been configured")
}
url := appurl.ApplicationURL{
AppSlugOrPort: strconv.Itoa(args.Port),
AgentName: workspaceAgent.Name,
WorkspaceName: workspace.Name,
Username: workspace.OwnerName,
}
return WorkspacePortForwardResponse{
URL: deps.coderClient.URL.Scheme + "://" + strings.Replace(res.Host, "*", url.String(), 1),
}, nil
},
}
type WorkspaceListAppsArgs struct {
Workspace string `json:"workspace"`
}
type WorkspaceListApp struct {
Name string `json:"name"`
URL string `json:"url"`
}
type WorkspaceListAppsResponse struct {
Apps []WorkspaceListApp `json:"apps"`
}
var WorkspaceListApps = Tool[WorkspaceListAppsArgs, WorkspaceListAppsResponse]{
Tool: aisdk.Tool{
Name: ToolNameWorkspaceListApps,
Description: `List the URLs of Coder apps running in a workspace for a single agent.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"workspace": map[string]any{
"type": "string",
"description": workspaceAgentDescription,
},
},
Required: []string{"workspace"},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args WorkspaceListAppsArgs) (WorkspaceListAppsResponse, error) {
workspaceName := NormalizeWorkspaceInput(args.Workspace)
_, workspaceAgent, err := findWorkspaceAndAgent(ctx, deps.coderClient, workspaceName)
if err != nil {
return WorkspaceListAppsResponse{}, xerrors.Errorf("failed to find workspace: %w", err)
}
var res WorkspaceListAppsResponse
for _, app := range workspaceAgent.Apps {
name := app.DisplayName
if name == "" {
name = app.Slug
}
res.Apps = append(res.Apps, WorkspaceListApp{
Name: name,
URL: app.URL,
})
}
return res, nil
},
}
type CreateTaskArgs struct {
Input string `json:"input"`
TemplateVersionID string `json:"template_version_id"`
TemplateVersionPresetID string `json:"template_version_preset_id"`
User string `json:"user"`
}
var CreateTask = Tool[CreateTaskArgs, codersdk.Task]{
Tool: aisdk.Tool{
Name: ToolNameCreateTask,
Description: `Create a task.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"input": map[string]any{
"type": "string",
"description": "Input/prompt for the task.",
},
"template_version_id": map[string]any{
"type": "string",
"description": "ID of the template version to create the task from.",
},
"template_version_preset_id": map[string]any{
"type": "string",
"description": "Optional ID of the template version preset to create the task from.",
},
"user": map[string]any{
"type": "string",
"description": userDescription("create a task"),
},
},
Required: []string{"input", "template_version_id"},
},
},
MCPAnnotations: mcpMutationAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args CreateTaskArgs) (codersdk.Task, error) {
if args.Input == "" {
return codersdk.Task{}, xerrors.New("input is required")
}
tvID, err := uuid.Parse(args.TemplateVersionID)
if err != nil {
return codersdk.Task{}, xerrors.New("template_version_id must be a valid UUID")
}
var tvPresetID uuid.UUID
if args.TemplateVersionPresetID != "" {
tvPresetID, err = uuid.Parse(args.TemplateVersionPresetID)
if err != nil {
return codersdk.Task{}, xerrors.New("template_version_preset_id must be a valid UUID")
}
}
if args.User == "" {
args.User = codersdk.Me
}
task, err := deps.coderClient.CreateTask(ctx, args.User, codersdk.CreateTaskRequest{
Input: args.Input,
TemplateVersionID: tvID,
TemplateVersionPresetID: tvPresetID,
})
if err != nil {
return codersdk.Task{}, xerrors.Errorf("create task: %w", err)
}
return task, nil
},
}
type DeleteTaskArgs struct {
TaskID string `json:"task_id"`
}
var DeleteTask = Tool[DeleteTaskArgs, codersdk.Response]{
Tool: aisdk.Tool{
Name: ToolNameDeleteTask,
Description: `Delete a task.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"task_id": map[string]any{
"type": "string",
"description": taskIDDescription("delete"),
},
},
Required: []string{"task_id"},
},
},
MCPAnnotations: mcpDestructiveAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args DeleteTaskArgs) (codersdk.Response, error) {
if args.TaskID == "" {
return codersdk.Response{}, xerrors.New("task_id is required")
}
task, err := deps.coderClient.TaskByIdentifier(ctx, args.TaskID)
if err != nil {
return codersdk.Response{}, xerrors.Errorf("resolve task: %w", err)
}
err = deps.coderClient.DeleteTask(ctx, task.OwnerName, task.ID)
if err != nil {
return codersdk.Response{}, xerrors.Errorf("delete task: %w", err)
}
return codersdk.Response{
Message: "Task deleted successfully",
}, nil
},
}
type ListTasksArgs struct {
Status codersdk.TaskStatus `json:"status"`
User string `json:"user"`
}
type ListTasksResponse struct {
Tasks []codersdk.Task `json:"tasks"`
}
var ListTasks = Tool[ListTasksArgs, ListTasksResponse]{
Tool: aisdk.Tool{
Name: ToolNameListTasks,
Description: `List tasks.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"status": map[string]any{
"type": "string",
"description": "Optional filter by task status.",
},
"user": map[string]any{
"type": "string",
"description": userDescription("list tasks"),
},
},
Required: []string{},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args ListTasksArgs) (ListTasksResponse, error) {
if args.User == "" {
args.User = codersdk.Me
}
tasks, err := deps.coderClient.Tasks(ctx, &codersdk.TasksFilter{
Owner: args.User,
Status: args.Status,
})
if err != nil {
return ListTasksResponse{}, xerrors.Errorf("list tasks: %w", err)
}
return ListTasksResponse{
Tasks: tasks,
}, nil
},
}
type GetTaskStatusArgs struct {
TaskID string `json:"task_id"`
}
type GetTaskStatusResponse struct {
Status codersdk.TaskStatus `json:"status"`
State *codersdk.TaskStateEntry `json:"state"`
}
var GetTaskStatus = Tool[GetTaskStatusArgs, GetTaskStatusResponse]{
Tool: aisdk.Tool{
Name: ToolNameGetTaskStatus,
Description: `Get the status of a task.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"task_id": map[string]any{
"type": "string",
"description": taskIDDescription("get"),
},
},
Required: []string{"task_id"},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args GetTaskStatusArgs) (GetTaskStatusResponse, error) {
if args.TaskID == "" {
return GetTaskStatusResponse{}, xerrors.New("task_id is required")
}
task, err := deps.coderClient.TaskByIdentifier(ctx, args.TaskID)
if err != nil {
return GetTaskStatusResponse{}, xerrors.Errorf("resolve task %q: %w", args.TaskID, err)
}
return GetTaskStatusResponse{
Status: task.Status,
State: task.CurrentState,
}, nil
},
}
type SendTaskInputArgs struct {
TaskID string `json:"task_id"`
Input string `json:"input"`
}
var SendTaskInput = Tool[SendTaskInputArgs, codersdk.Response]{
Tool: aisdk.Tool{
Name: ToolNameSendTaskInput,
Description: `Send input to a running task.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"task_id": map[string]any{
"type": "string",
"description": taskIDDescription("prompt"),
},
"input": map[string]any{
"type": "string",
"description": "The input to send to the task.",
},
},
Required: []string{"task_id", "input"},
},
},
MCPAnnotations: mcpMutationAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args SendTaskInputArgs) (codersdk.Response, error) {
if args.TaskID == "" {
return codersdk.Response{}, xerrors.New("task_id is required")
}
if args.Input == "" {
return codersdk.Response{}, xerrors.New("input is required")
}
task, err := deps.coderClient.TaskByIdentifier(ctx, args.TaskID)
if err != nil {
return codersdk.Response{}, xerrors.Errorf("resolve task %q: %w", args.TaskID, err)
}
err = deps.coderClient.TaskSend(ctx, task.OwnerName, task.ID, codersdk.TaskSendRequest{
Input: args.Input,
})
if err != nil {
return codersdk.Response{}, xerrors.Errorf("send task input %q: %w", args.TaskID, err)
}
return codersdk.Response{
Message: "Input sent to task successfully.",
}, nil
},
}
type GetTaskLogsArgs struct {
TaskID string `json:"task_id"`
}
var GetTaskLogs = Tool[GetTaskLogsArgs, codersdk.TaskLogsResponse]{
Tool: aisdk.Tool{
Name: ToolNameGetTaskLogs,
Description: `Get the logs of a task.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"task_id": map[string]any{
"type": "string",
"description": taskIDDescription("query"),
},
},
Required: []string{"task_id"},
},
},
MCPAnnotations: mcpReadOnlyAnnotations,
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args GetTaskLogsArgs) (codersdk.TaskLogsResponse, error) {
if args.TaskID == "" {
return codersdk.TaskLogsResponse{}, xerrors.New("task_id is required")
}
task, err := deps.coderClient.TaskByIdentifier(ctx, args.TaskID)
if err != nil {
return codersdk.TaskLogsResponse{}, err
}
logs, err := deps.coderClient.TaskLogs(ctx, task.OwnerName, task.ID)
if err != nil {
return codersdk.TaskLogsResponse{}, xerrors.Errorf("get task logs %q: %w", args.TaskID, err)
}
return logs, nil
},
}
// NormalizeWorkspaceInput converts workspace name input to standard format.
// Handles the following input formats:
// - workspace → workspace
// - workspace.agent → workspace.agent
// - owner/workspace → owner/workspace
// - owner--workspace → owner/workspace
// - owner/workspace.agent → owner/workspace.agent
// - owner--workspace.agent → owner/workspace.agent
// - agent.workspace.owner → owner/workspace.agent (Coder Connect format)
func NormalizeWorkspaceInput(input string) string {
// Handle the special Coder Connect format: agent.workspace.owner
// This format uses only dots and has exactly 3 parts
if strings.Count(input, ".") == 2 && !strings.Contains(input, "/") && !strings.Contains(input, "--") {
parts := strings.Split(input, ".")
if len(parts) == 3 {
// Convert agent.workspace.owner → owner/workspace.agent
return fmt.Sprintf("%s/%s.%s", parts[2], parts[1], parts[0])
}
}
// Convert -- separator to / separator for consistency
normalized := strings.ReplaceAll(input, "--", "/")
return normalized
}
const workspaceDescription = "The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used."
const workspaceAgentDescription = "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used."
func taskIDDescription(action string) string {
return fmt.Sprintf("ID or workspace identifier in the format [owner/]workspace[.agent] for the task to %s. If an owner is not specified, the authenticated user is used.", action)
}
func userDescription(action string) string {
return fmt.Sprintf("Username or ID of the user for which to %s. Omit or use the `me` keyword to %s for the authenticated user.", action, action)
}