feat: remove task management MCP tools (#28354)

Removes the six task-management tools from the MCP tool surface in
`codersdk/toolsdk`:

- `coder_create_task`
- `coder_delete_task`
- `coder_list_tasks`
- `coder_get_task_status`
- `coder_send_task_input`
- `coder_get_task_logs`

Both the coderd-hosted MCP server (`coderd/mcp`) and the CLI `coder exp
mcp server` derive their tool list from `toolsdk.All`, so removing the
registry entries removes the tools from both servers with no server-side
changes.

The MCP server documentation (`docs/ai-coder/mcp-server.md`) is updated
to drop the task-management entry from the tool overview.

## What stays

- `coder_report_task` is intentionally kept: it is the agent-side
status-reporting mechanism (`WithTaskReporter`), already excluded from
the hosted MCP server, and required by `coder exp mcp server`, Claude
Code configuration, and scaletest.
- The `codersdk` Task SDK methods (`CreateTask`, `DeleteTask`, `Tasks`,
`TaskLogs`, etc.) and the Tasks feature (CLI, UI, API) are untouched;
this PR only removes MCP tool registration.
- The task-only `taskIDDescription` helper is deleted; `userDescription`
stays (used by `coder_create_workspace`).

The code change is deletion-only: 970 removed lines across `toolsdk.go`
and its test file, no added behavior.

## Validation

- `go build ./...`, `go test ./coderd/mcp/`, and targeted
`golangci-lint` pass. `go test ./codersdk/toolsdk/` passes all tests;
the package-level goleak failure (agentssh goroutines) reproduces
identically on unmodified `main` and is unrelated to this diff.
- `pnpm run lint-docs` passes on the docs change.
- Remote dogfood UAT ran against the toolsdk change and passed:
`tools/list` on both the hosted MCP endpoint and `coder exp mcp server`
confirms the six tools are absent and the remaining surface is intact
(34 hosted tools; CLI adds `search`/`fetch`), a `tools/call` of
`coder_get_authenticated_user` succeeds, and calling a removed tool
returns `-32602 unknown tool`. `coder_report_task` still registers in
the CLI server when `CODER_MCP_APP_STATUS_SLUG` is set.
- The failing `Pixel / Review` commit status is unrelated: this PR
contains no `site/` changes, and the same status fails on the last 8
`main` commits including this PR's merge base (`676b9bb06bf`).

> Mux acted on Mike's behalf to create this pull request.
This commit is contained in:
Michael Suchacz
2026-08-20 12:07:30 +02:00
committed by GitHub
parent 5e0710de92
commit 2f996d765a
3 changed files with 2 additions and 973 deletions
-308
View File
@@ -52,12 +52,6 @@ const (
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"
ToolNameCreateChat = "coder_create_chat"
ToolNameGetChat = "coder_get_chat"
ToolNameDownloadChatFile = "coder_download_chat_file"
@@ -342,12 +336,6 @@ var All = []GenericTool{
WorkspaceEditFiles.Generic(),
WorkspacePortForward.Generic(),
WorkspaceListApps.Generic(),
CreateTask.Generic(),
DeleteTask.Generic(),
ListTasks.Generic(),
GetTaskStatus.Generic(),
SendTaskInput.Generic(),
GetTaskLogs.Generic(),
CreateChat.Generic(),
GetChat.Generic(),
DownloadChatFile.Generic(),
@@ -2203,298 +2191,6 @@ var WorkspaceListApps = Tool[WorkspaceListAppsArgs, WorkspaceListAppsResponse]{
},
}
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
@@ -2525,10 +2221,6 @@ const workspaceDescription = "The workspace ID or name in the format [owner/]wor
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)
}
-662
View File
@@ -6,8 +6,6 @@ import (
"encoding/json"
"flag"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
@@ -22,7 +20,6 @@ import (
"go.uber.org/goleak"
"golang.org/x/xerrors"
agentapi "github.com/coder/agentapi-sdk-go"
"github.com/coder/aisdk-go"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agenttest"
@@ -31,12 +28,10 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/httpapi"
"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/codersdk/workspacesdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
)
@@ -1587,384 +1582,6 @@ func TestTools(t *testing.T) {
}
})
t.Run("CreateTask", func(t *testing.T) {
t.Parallel()
presetID := uuid.New()
// nolint:gocritic // This is in a test package and does not end up in the build
aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{
OrganizationID: owner.OrganizationID,
CreatedBy: member.ID,
HasAITask: sql.NullBool{
Bool: true,
Valid: true,
},
}).Preset(database.TemplateVersionPreset{
ID: presetID,
DesiredInstances: sql.NullInt32{
Int32: 1,
Valid: true,
},
}).Do()
tests := []struct {
name string
args toolsdk.CreateTaskArgs
error string
}{
{
name: "OK",
args: toolsdk.CreateTaskArgs{
TemplateVersionID: aiTV.TemplateVersion.ID.String(),
Input: "do a barrel roll",
User: "me",
},
},
{
name: "NoUser",
args: toolsdk.CreateTaskArgs{
TemplateVersionID: aiTV.TemplateVersion.ID.String(),
Input: "do another barrel roll",
},
},
{
name: "NoInput",
args: toolsdk.CreateTaskArgs{
TemplateVersionID: aiTV.TemplateVersion.ID.String(),
},
error: "input is required",
},
{
name: "NotTaskTemplate",
args: toolsdk.CreateTaskArgs{
TemplateVersionID: r.TemplateVersion.ID.String(),
Input: "do yet another barrel roll",
},
error: "Template does not have a valid \"coder_ai_task\" resource.",
},
{
name: "WithPreset",
args: toolsdk.CreateTaskArgs{
TemplateVersionID: aiTV.TemplateVersion.ID.String(),
TemplateVersionPresetID: presetID.String(),
Input: "not enough barrel rolls",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
_, err = testTool(t, toolsdk.CreateTask, tb, tt.args)
if tt.error != "" {
require.Error(t, err)
require.ErrorContains(t, err, tt.error)
} else {
require.NoError(t, err)
}
})
}
})
t.Run("DeleteTask", func(t *testing.T) {
t.Parallel()
// nolint:gocritic // This is in a test package and does not end up in the build
aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{
OrganizationID: owner.OrganizationID,
CreatedBy: member.ID,
HasAITask: sql.NullBool{
Bool: true,
Valid: true,
},
}).Do()
build1 := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
Name: "delete-task-workspace-1",
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
TemplateID: aiTV.Template.ID,
}).WithTask(database.TaskTable{
Name: "delete-task-1",
Prompt: "delete task 1",
}, nil).Do()
task1 := build1.Task
build2 := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
Name: "delete-task-workspace-2",
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
TemplateID: aiTV.Template.ID,
}).WithTask(database.TaskTable{
Name: "delete-task-2",
Prompt: "delete task 2",
}, nil).Do()
task2 := build2.Task
tests := []struct {
name string
args toolsdk.DeleteTaskArgs
error string
}{
{
name: "ByUUID",
args: toolsdk.DeleteTaskArgs{
TaskID: task1.ID.String(),
},
},
{
name: "ByIdentifier",
args: toolsdk.DeleteTaskArgs{
TaskID: task2.Name,
},
},
{
name: "NoID",
args: toolsdk.DeleteTaskArgs{},
error: "task_id is required",
},
{
name: "NoTaskByID",
args: toolsdk.DeleteTaskArgs{
TaskID: uuid.New().String(),
},
error: "Resource not found",
},
{
name: "NoTaskByWorkspaceIdentifier",
args: toolsdk.DeleteTaskArgs{
TaskID: "non-existent",
},
error: "Resource not found",
},
{
name: "ExistsButNotATask",
args: toolsdk.DeleteTaskArgs{
TaskID: r.Workspace.ID.String(),
},
error: "Resource not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
_, err = testTool(t, toolsdk.DeleteTask, tb, tt.args)
if tt.error != "" {
require.Error(t, err)
require.ErrorContains(t, err, tt.error)
} else {
require.NoError(t, err)
}
})
}
})
t.Run("ListTasks", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
_, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
taskClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// Create a template with AI task support using the proper flow.
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: echo.ApplyComplete,
ProvisionGraph: []*proto.Response{
{Type: &proto.Response_Graph{Graph: &proto.GraphComplete{
HasAiTasks: true,
}}},
},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
// This task should not show up since listing is user-scoped.
_, err := client.CreateTask(ctx, member.Username, codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: "task for member",
Name: "list-task-workspace-member",
})
require.NoError(t, err)
// Create tasks for taskUser. These should show up in the list.
for i := range 5 {
taskName := fmt.Sprintf("list-task-workspace-%d", i)
task, err := taskClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: fmt.Sprintf("task %d", i),
Name: taskName,
})
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid, "task should have workspace ID")
// For the first task, stop the workspace to make it paused.
if i == 0 {
ws, err := taskClient.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, taskClient, ws.LatestBuild.ID)
// Stop the workspace to set task status to paused.
build, err := taskClient.CreateWorkspaceBuild(ctx, task.WorkspaceID.UUID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStop,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, taskClient, build.ID)
}
}
tests := []struct {
name string
args toolsdk.ListTasksArgs
expected []string
error string
}{
{
name: "ListAllOwned",
args: toolsdk.ListTasksArgs{},
expected: []string{
"list-task-workspace-0",
"list-task-workspace-1",
"list-task-workspace-2",
"list-task-workspace-3",
"list-task-workspace-4",
},
},
{
name: "ListFiltered",
args: toolsdk.ListTasksArgs{
Status: codersdk.TaskStatusPaused,
},
expected: []string{
"list-task-workspace-0",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tb, err := toolsdk.NewDeps(taskClient)
require.NoError(t, err)
res, err := testTool(t, toolsdk.ListTasks, tb, tt.args)
if tt.error != "" {
require.Error(t, err)
require.ErrorContains(t, err, tt.error)
} else {
require.NoError(t, err)
require.Len(t, res.Tasks, len(tt.expected))
for _, task := range res.Tasks {
require.Contains(t, tt.expected, task.Name)
}
}
})
}
})
t.Run("GetTask", func(t *testing.T) {
t.Parallel()
// nolint:gocritic // This is in a test package and does not end up in the build
aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{
OrganizationID: owner.OrganizationID,
CreatedBy: member.ID,
HasAITask: sql.NullBool{
Bool: true,
Valid: true,
},
}).Do()
build := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
Name: "get-task-workspace-1",
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
TemplateID: aiTV.Template.ID,
}).WithTask(database.TaskTable{
Name: "get-task-1",
Prompt: "get task",
}, nil).Do()
task := build.Task
tests := []struct {
name string
args toolsdk.GetTaskStatusArgs
expected codersdk.TaskStatus
error string
}{
{
name: "ByUUID",
args: toolsdk.GetTaskStatusArgs{
TaskID: task.ID.String(),
},
expected: codersdk.TaskStatusInitializing,
},
{
name: "ByIdentifier",
args: toolsdk.GetTaskStatusArgs{
TaskID: task.Name,
},
expected: codersdk.TaskStatusInitializing,
},
{
name: "NoID",
args: toolsdk.GetTaskStatusArgs{},
error: "task_id is required",
},
{
name: "NoTaskByID",
args: toolsdk.GetTaskStatusArgs{
TaskID: uuid.New().String(),
},
error: "Resource not found",
},
{
name: "NoTaskByWorkspaceIdentifier",
args: toolsdk.GetTaskStatusArgs{
TaskID: "non-existent",
},
error: "Resource not found",
},
{
name: "ExistsButNotATask",
args: toolsdk.GetTaskStatusArgs{
TaskID: r.Workspace.ID.String(),
},
error: "Resource not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
res, err := testTool(t, toolsdk.GetTaskStatus, tb, tt.args)
if tt.error != "" {
require.Error(t, err)
require.ErrorContains(t, err, tt.error)
} else {
require.NoError(t, err)
require.Equal(t, tt.expected, res.Status)
}
})
}
})
t.Run("WorkspaceListApps", func(t *testing.T) {
t.Parallel()
@@ -2104,285 +1721,6 @@ func TestTools(t *testing.T) {
})
}
})
t.Run("SendTaskInput", func(t *testing.T) {
t.Parallel()
// Start a fake AgentAPI that accepts GET /status and POST /message.
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/status" {
httpapi.Write(r.Context(), rw, http.StatusOK, agentapi.GetStatusResponse{
Status: agentapi.StatusStable,
})
return
}
if r.Method == http.MethodPost && r.URL.Path == "/message" {
rw.Header().Set("Content-Type", "application/json")
var req agentapi.PostMessageParams
ok := httpapi.Read(r.Context(), rw, r, &req)
assert.True(t, ok, "failed to read request")
assert.Equal(t, req.Content, "frob the baz")
assert.Equal(t, req.Type, agentapi.MessageTypeUser)
httpapi.Write(r.Context(), rw, http.StatusOK, agentapi.PostMessageResponse{
Ok: true,
})
return
}
rw.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
// nolint:gocritic // This is in a test package and does not end up in the build
aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{
OrganizationID: owner.OrganizationID,
CreatedBy: member.ID,
HasAITask: sql.NullBool{
Bool: true,
Valid: true,
},
}).Do()
ws := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
Name: "send-task-input-ws",
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
TemplateID: aiTV.Template.ID,
}).WithTask(database.TaskTable{
Name: "send-task-input",
Prompt: "send task input",
}, &proto.App{Url: srv.URL}).Do()
task := ws.Task
_ = agenttest.New(t, client.URL, ws.AgentToken)
coderdtest.NewWorkspaceAgentWaiter(t, client, ws.Workspace.ID).
WaitFor(coderdtest.AgentsReady)
ctx := testutil.Context(t, testutil.WaitShort)
// Ensure the app is healthy (required to send task input).
err := store.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{
ID: task.WorkspaceAppID.UUID,
Health: database.WorkspaceAppHealthHealthy,
})
require.NoError(t, err)
tests := []struct {
name string
args toolsdk.SendTaskInputArgs
error string
}{
{
name: "ByUUID",
args: toolsdk.SendTaskInputArgs{
TaskID: task.ID.String(),
Input: "frob the baz",
},
},
{
name: "ByIdentifier",
args: toolsdk.SendTaskInputArgs{
TaskID: task.Name,
Input: "frob the baz",
},
},
{
name: "NoID",
args: toolsdk.SendTaskInputArgs{},
error: "task_id is required",
},
{
name: "NoInput",
args: toolsdk.SendTaskInputArgs{
TaskID: "send-task-input",
},
error: "input is required",
},
{
name: "NoTaskByID",
args: toolsdk.SendTaskInputArgs{
TaskID: uuid.New().String(),
Input: "this is ignored",
},
error: "Resource not found",
},
{
name: "NoTaskByWorkspaceIdentifier",
args: toolsdk.SendTaskInputArgs{
TaskID: "non-existent",
Input: "this is ignored",
},
error: "Resource not found",
},
{
name: "ExistsButNotATask",
args: toolsdk.SendTaskInputArgs{
TaskID: r.Workspace.ID.String(),
Input: "this is ignored",
},
error: "Resource not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
_, err = testTool(t, toolsdk.SendTaskInput, tb, tt.args)
if tt.error != "" {
require.Error(t, err)
require.ErrorContains(t, err, tt.error)
} else {
require.NoError(t, err)
}
})
}
})
t.Run("GetTaskLogs", func(t *testing.T) {
t.Parallel()
messages := []agentapi.Message{
{
Id: 0,
Content: "welcome",
Role: agentapi.RoleAgent,
},
{
Id: 1,
Content: "frob the dazzle",
Role: agentapi.RoleUser,
},
{
Id: 2,
Content: "frob dazzled",
Role: agentapi.RoleAgent,
},
}
// Start a fake AgentAPI that returns some messages.
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/messages" {
httpapi.Write(r.Context(), rw, http.StatusOK, agentapi.GetMessagesResponse{
Messages: messages,
})
return
}
rw.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
// nolint:gocritic // This is in a test package and does not end up in the build
aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{
OrganizationID: owner.OrganizationID,
CreatedBy: member.ID,
HasAITask: sql.NullBool{
Bool: true,
Valid: true,
},
}).Do()
ws := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
Name: "get-task-logs-ws",
OrganizationID: owner.OrganizationID,
OwnerID: member.ID,
TemplateID: aiTV.Template.ID,
}).WithTask(database.TaskTable{
Name: "get-task-logs",
Prompt: "get task logs",
}, &proto.App{Url: srv.URL}).Do()
task := ws.Task
_ = agenttest.New(t, client.URL, ws.AgentToken)
coderdtest.NewWorkspaceAgentWaiter(t, client, ws.Workspace.ID).
WaitFor(coderdtest.AgentsReady)
ctx := testutil.Context(t, testutil.WaitShort)
// Ensure the app is healthy (required to read task logs).
err := store.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{
ID: task.WorkspaceAppID.UUID,
Health: database.WorkspaceAppHealthHealthy,
})
require.NoError(t, err)
tests := []struct {
name string
args toolsdk.GetTaskLogsArgs
expected []agentapi.Message
error string
}{
{
name: "ByUUID",
args: toolsdk.GetTaskLogsArgs{
TaskID: task.ID.String(),
},
expected: messages,
},
{
name: "ByIdentifier",
args: toolsdk.GetTaskLogsArgs{
TaskID: task.Name,
},
expected: messages,
},
{
name: "NoID",
args: toolsdk.GetTaskLogsArgs{},
error: "task_id is required",
},
{
name: "NoTaskByID",
args: toolsdk.GetTaskLogsArgs{
TaskID: uuid.New().String(),
},
error: "Resource not found",
},
{
name: "NoTaskByWorkspaceIdentifier",
args: toolsdk.GetTaskLogsArgs{
TaskID: "non-existent",
},
error: "Resource not found",
},
{
name: "ExistsButNotATask",
args: toolsdk.GetTaskLogsArgs{
TaskID: r.Workspace.ID.String(),
},
error: "Resource not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tb, err := toolsdk.NewDeps(memberClient)
require.NoError(t, err)
res, err := testTool(t, toolsdk.GetTaskLogs, tb, tt.args)
if tt.error != "" {
require.Error(t, err)
require.ErrorContains(t, err, tt.error)
} else {
require.NoError(t, err)
require.Len(t, res.Logs, len(tt.expected))
for i, msg := range tt.expected {
require.Equal(t, msg.Id, int64(res.Logs[i].ID))
require.Equal(t, msg.Content, res.Logs[i].Content)
if msg.Role == agentapi.RoleUser {
require.Equal(t, codersdk.TaskLogTypeInput, res.Logs[i].Type)
} else {
require.Equal(t, codersdk.TaskLogTypeOutput, res.Logs[i].Type)
}
require.Equal(t, msg.Time, res.Logs[i].Time)
}
}
})
}
})
}
// TestedTools keeps track of which tools have been tested.
+2 -3
View File
@@ -94,8 +94,8 @@ through `2026-07-28`, does not issue `Mcp-Session-Id` headers, and answers
`GET` and `DELETE` with `405 Method Not Allowed` (there is no standalone
server-event stream or explicit session termination, both permitted by the
specification). The server exposes tools only; MCP resources, prompts,
structured tool output, elicitation, and the MCP Tasks extension (which is
unrelated to Coder's task tools) are not implemented.
structured tool output, elicitation, and the MCP Tasks extension are not
implemented.
### Prerequisites
@@ -198,7 +198,6 @@ The MCP server exposes tools across several areas:
- **Template operations**: list, inspect, create, and manage templates and versions
- **File operations**: read, write, and edit files in a workspace
- **Workspace interaction**: run commands, forward ports, list apps, and read logs
- **Task management**: create, list, inspect, and control tasks
- **Coder Agents chats**: create chats, send messages, read transcripts and status, interrupt, archive, and list available models
- **User and system**: authenticated user details, tar uploads, and task reporting