From 2f6a320949fe796d94dc7b7ac6c0bb187949a65b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:04:42 +0000 Subject: [PATCH 01/14] feat: add Coder Agents chat tools to toolsdk Expose the experimental chats API through MCP so agents can start and drive Coder Agents chats: coder_create_chat, coder_get_chat, coder_get_chat_messages, coder_send_chat_message, coder_interrupt_chat, coder_archive_chat, and coder_list_chat_model_configs. Both the hosted MCP server and the CLI stdio server pick these up from toolsdk.All. --- codersdk/toolsdk/chats.go | 448 +++++++++++++++++++++++++++++++++ codersdk/toolsdk/chats_test.go | 143 +++++++++++ codersdk/toolsdk/toolsdk.go | 14 ++ docs/ai-coder/mcp-server.md | 1 + 4 files changed, 606 insertions(+) create mode 100644 codersdk/toolsdk/chats.go create mode 100644 codersdk/toolsdk/chats_test.go diff --git a/codersdk/toolsdk/chats.go b/codersdk/toolsdk/chats.go new file mode 100644 index 00000000000..c39361395a5 --- /dev/null +++ b/codersdk/toolsdk/chats.go @@ -0,0 +1,448 @@ +package toolsdk + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/aisdk-go" + "github.com/coder/coder/v2/codersdk" +) + +const chatIDDescription = "UUID of the chat." + +func parseChatID(chatID string) (uuid.UUID, error) { + if chatID == "" { + return uuid.Nil, xerrors.New("chat_id is required") + } + id, err := uuid.Parse(chatID) + if err != nil { + return uuid.Nil, xerrors.New("chat_id must be a valid UUID") + } + return id, nil +} + +// ChatToolFile is lightweight metadata about a file attached to a chat. +type ChatToolFile struct { + ID string `json:"id"` + Name string `json:"name"` + MimeType string `json:"mime_type"` +} + +// ChatToolStatus is the chat state shared by the chat tools. +type ChatToolStatus struct { + ID string `json:"id"` + Title string `json:"title"` + Status codersdk.ChatStatus `json:"status"` + Archived bool `json:"archived"` + LastError *codersdk.ChatError `json:"last_error,omitempty"` + LastTurnSummary string `json:"last_turn_summary,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + URL string `json:"url"` + Files []ChatToolFile `json:"files,omitempty"` +} + +func chatToolStatus(deps Deps, chat codersdk.Chat) ChatToolStatus { + resp := ChatToolStatus{ + ID: chat.ID.String(), + Title: chat.Title, + Status: chat.Status, + Archived: chat.Archived, + URL: fmt.Sprintf("%s/agents/%s", deps.ServerURL(), chat.ID), + } + if chat.LastError != nil { + resp.LastError = chat.LastError + } + if chat.LastTurnSummary != nil { + resp.LastTurnSummary = *chat.LastTurnSummary + } + if chat.WorkspaceID != nil { + resp.WorkspaceID = chat.WorkspaceID.String() + } + for _, file := range chat.Files { + resp.Files = append(resp.Files, ChatToolFile{ + ID: file.ID.String(), + Name: file.Name, + MimeType: file.MimeType, + }) + } + return resp +} + +type CreateChatArgs struct { + Prompt string `json:"prompt"` + OrganizationID string `json:"organization_id"` + ModelConfigID string `json:"model_config_id"` + Labels map[string]string `json:"labels"` +} + +var CreateChat = Tool[CreateChatArgs, ChatToolStatus]{ + Tool: aisdk.Tool{ + Name: ToolNameCreateChat, + Description: `Start a Coder Agents chat: a server-side AI coding agent that works autonomously from a prompt. + +The chat runs asynchronously. Poll coder_get_chat for status and read the transcript with coder_get_chat_messages.`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "prompt": map[string]any{ + "type": "string", + "description": "Initial prompt for the agent.", + }, + "organization_id": map[string]any{ + "type": "string", + "description": "Optional organization UUID. Defaults to the authenticated user's first organization.", + }, + "model_config_id": map[string]any{ + "type": "string", + "description": "Optional chat model config UUID from coder_list_chat_model_configs. Defaults to the deployment default model.", + }, + "labels": map[string]any{ + "type": "object", + "description": "Optional string key/value labels to attach to the chat.", + "additionalProperties": map[string]any{"type": "string"}, + }, + }, + Required: []string{"prompt"}, + }, + }, + MCPAnnotations: mcpMutationAnnotations, + Handler: func(ctx context.Context, deps Deps, args CreateChatArgs) (ChatToolStatus, error) { + if args.Prompt == "" { + return ChatToolStatus{}, xerrors.New("prompt is required") + } + var orgID uuid.UUID + if args.OrganizationID != "" { + var err error + orgID, err = uuid.Parse(args.OrganizationID) + if err != nil { + return ChatToolStatus{}, xerrors.New("organization_id must be a valid UUID") + } + } else { + me, err := deps.coderClient.User(ctx, codersdk.Me) + if err != nil { + return ChatToolStatus{}, err + } + if len(me.OrganizationIDs) == 0 { + return ChatToolStatus{}, xerrors.New("authenticated user belongs to no organization") + } + orgID = me.OrganizationIDs[0] + } + var modelConfigID *uuid.UUID + if args.ModelConfigID != "" { + id, err := uuid.Parse(args.ModelConfigID) + if err != nil { + return ChatToolStatus{}, xerrors.New("model_config_id must be a valid UUID") + } + modelConfigID = &id + } + chat, err := codersdk.NewExperimentalClient(deps.coderClient).CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: orgID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: args.Prompt, + }}, + ModelConfigID: modelConfigID, + Labels: args.Labels, + }) + if err != nil { + return ChatToolStatus{}, xerrors.Errorf("create chat: %w", err) + } + return chatToolStatus(deps, chat), nil + }, +} + +type GetChatArgs struct { + ChatID string `json:"chat_id"` +} + +var GetChat = Tool[GetChatArgs, ChatToolStatus]{ + Tool: aisdk.Tool{ + Name: ToolNameGetChat, + Description: `Get the status of a Coder Agents chat, including its last error, last turn summary, workspace, and attached files.`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "chat_id": map[string]any{ + "type": "string", + "description": chatIDDescription, + }, + }, + Required: []string{"chat_id"}, + }, + }, + MCPAnnotations: mcpReadOnlyAnnotations, + Handler: func(ctx context.Context, deps Deps, args GetChatArgs) (ChatToolStatus, error) { + chatID, err := parseChatID(args.ChatID) + if err != nil { + return ChatToolStatus{}, err + } + chat, err := codersdk.NewExperimentalClient(deps.coderClient).GetChat(ctx, chatID) + if err != nil { + return ChatToolStatus{}, xerrors.Errorf("get chat: %w", err) + } + return chatToolStatus(deps, chat), nil + }, +} + +type GetChatMessagesArgs struct { + ChatID string `json:"chat_id"` + Limit int `json:"limit"` +} + +// ChatToolMessage is a text-only view of a chat message. +type ChatToolMessage struct { + ID int64 `json:"id"` + Role codersdk.ChatMessageRole `json:"role"` + CreatedAt time.Time `json:"created_at"` + Text string `json:"text"` +} + +type GetChatMessagesResponse struct { + Messages []ChatToolMessage `json:"messages"` + HasMore bool `json:"has_more"` +} + +var GetChatMessages = Tool[GetChatMessagesArgs, GetChatMessagesResponse]{ + Tool: aisdk.Tool{ + Name: ToolNameGetChatMessages, + Description: `Get the newest messages of a Coder Agents chat in chronological order. + +Only text content is returned; tool calls and other internal parts are omitted.`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "chat_id": map[string]any{ + "type": "string", + "description": chatIDDescription, + }, + "limit": map[string]any{ + "type": "integer", + "description": "Maximum number of messages to fetch, from newest to oldest (1-200, default 50).", + }, + }, + Required: []string{"chat_id"}, + }, + }, + MCPAnnotations: mcpReadOnlyAnnotations, + Handler: func(ctx context.Context, deps Deps, args GetChatMessagesArgs) (GetChatMessagesResponse, error) { + chatID, err := parseChatID(args.ChatID) + if err != nil { + return GetChatMessagesResponse{}, err + } + var opts *codersdk.ChatMessagesPaginationOptions + if args.Limit > 0 { + opts = &codersdk.ChatMessagesPaginationOptions{Limit: args.Limit} + } + resp, err := codersdk.NewExperimentalClient(deps.coderClient).GetChatMessages(ctx, chatID, opts) + if err != nil { + return GetChatMessagesResponse{}, xerrors.Errorf("get chat messages: %w", err) + } + // The API returns messages newest first; reverse into + // chronological order so the transcript reads naturally. + messages := make([]ChatToolMessage, 0, len(resp.Messages)) + for i := len(resp.Messages) - 1; i >= 0; i-- { + msg := resp.Messages[i] + var texts []string + for _, part := range msg.Content { + if part.Type == codersdk.ChatMessagePartTypeText && part.Text != "" { + texts = append(texts, part.Text) + } + } + if len(texts) == 0 { + continue + } + messages = append(messages, ChatToolMessage{ + ID: msg.ID, + Role: msg.Role, + CreatedAt: msg.CreatedAt, + Text: strings.Join(texts, "\n"), + }) + } + return GetChatMessagesResponse{ + Messages: messages, + HasMore: resp.HasMore, + }, nil + }, +} + +type SendChatMessageArgs struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + BusyBehavior string `json:"busy_behavior"` +} + +type SendChatMessageResponse struct { + Queued bool `json:"queued"` + Warnings []string `json:"warnings,omitempty"` +} + +var SendChatMessage = Tool[SendChatMessageArgs, SendChatMessageResponse]{ + Tool: aisdk.Tool{ + Name: ToolNameSendChatMessage, + Description: `Send a message to a Coder Agents chat.`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "chat_id": map[string]any{ + "type": "string", + "description": chatIDDescription, + }, + "text": map[string]any{ + "type": "string", + "description": "The message to send.", + }, + "busy_behavior": map[string]any{ + "type": "string", + "description": "What to do when the chat is already processing: \"queue\" (default) processes the message after the current run, \"interrupt\" stops the current run first.", + "enum": []string{"queue", "interrupt"}, + }, + }, + Required: []string{"chat_id", "text"}, + }, + }, + MCPAnnotations: mcpMutationAnnotations, + Handler: func(ctx context.Context, deps Deps, args SendChatMessageArgs) (SendChatMessageResponse, error) { + chatID, err := parseChatID(args.ChatID) + if err != nil { + return SendChatMessageResponse{}, err + } + if args.Text == "" { + return SendChatMessageResponse{}, xerrors.New("text is required") + } + busyBehavior := codersdk.ChatBusyBehaviorQueue + switch args.BusyBehavior { + case "", string(codersdk.ChatBusyBehaviorQueue): + case string(codersdk.ChatBusyBehaviorInterrupt): + busyBehavior = codersdk.ChatBusyBehaviorInterrupt + default: + return SendChatMessageResponse{}, xerrors.New(`busy_behavior must be "queue" or "interrupt"`) + } + resp, err := codersdk.NewExperimentalClient(deps.coderClient).CreateChatMessage(ctx, chatID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: args.Text, + }}, + BusyBehavior: busyBehavior, + }) + if err != nil { + return SendChatMessageResponse{}, xerrors.Errorf("send chat message: %w", err) + } + return SendChatMessageResponse{ + Queued: resp.Queued, + Warnings: resp.Warnings, + }, nil + }, +} + +type InterruptChatArgs struct { + ChatID string `json:"chat_id"` +} + +var InterruptChat = Tool[InterruptChatArgs, ChatToolStatus]{ + Tool: aisdk.Tool{ + Name: ToolNameInterruptChat, + Description: `Interrupt a running Coder Agents chat. Progress so far is preserved.`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "chat_id": map[string]any{ + "type": "string", + "description": chatIDDescription, + }, + }, + Required: []string{"chat_id"}, + }, + }, + MCPAnnotations: mcpMutationAnnotations, + Handler: func(ctx context.Context, deps Deps, args InterruptChatArgs) (ChatToolStatus, error) { + chatID, err := parseChatID(args.ChatID) + if err != nil { + return ChatToolStatus{}, err + } + chat, err := codersdk.NewExperimentalClient(deps.coderClient).InterruptChat(ctx, chatID) + if err != nil { + return ChatToolStatus{}, xerrors.Errorf("interrupt chat: %w", err) + } + return chatToolStatus(deps, chat), nil + }, +} + +type ArchiveChatArgs struct { + ChatID string `json:"chat_id"` +} + +var ArchiveChat = Tool[ArchiveChatArgs, codersdk.Response]{ + Tool: aisdk.Tool{ + Name: ToolNameArchiveChat, + Description: `Archive a Coder Agents chat. The chat is hidden from default listings but can be unarchived from the UI.`, + Schema: aisdk.Schema{ + Properties: map[string]any{ + "chat_id": map[string]any{ + "type": "string", + "description": chatIDDescription, + }, + }, + Required: []string{"chat_id"}, + }, + }, + MCPAnnotations: mcpMutationAnnotations, + Handler: func(ctx context.Context, deps Deps, args ArchiveChatArgs) (codersdk.Response, error) { + chatID, err := parseChatID(args.ChatID) + if err != nil { + return codersdk.Response{}, err + } + archived := true + err = codersdk.NewExperimentalClient(deps.coderClient).UpdateChat(ctx, chatID, codersdk.UpdateChatRequest{ + Archived: &archived, + }) + if err != nil { + return codersdk.Response{}, xerrors.Errorf("archive chat: %w", err) + } + return codersdk.Response{ + Message: "Chat archived successfully.", + }, nil + }, +} + +// ChatModelConfigSummary describes one selectable chat model. +type ChatModelConfigSummary struct { + ID string `json:"id"` + Model string `json:"model"` + DisplayName string `json:"display_name"` + IsDefault bool `json:"is_default"` +} + +type ListChatModelConfigsResponse struct { + ModelConfigs []ChatModelConfigSummary `json:"model_configs"` +} + +var ListChatModelConfigs = Tool[NoArgs, ListChatModelConfigsResponse]{ + Tool: aisdk.Tool{ + Name: ToolNameListChatModelConfigs, + Description: `List the enabled chat models available for Coder Agents chats. Use a model config ID with coder_create_chat to pick a model.`, + Schema: aisdk.Schema{ + Properties: map[string]any{}, + Required: []string{}, + }, + }, + MCPAnnotations: mcpReadOnlyAnnotations, + Handler: func(ctx context.Context, deps Deps, _ NoArgs) (ListChatModelConfigsResponse, error) { + configs, err := codersdk.NewExperimentalClient(deps.coderClient).ListChatModelConfigs(ctx) + if err != nil { + return ListChatModelConfigsResponse{}, xerrors.Errorf("list chat model configs: %w", err) + } + summaries := make([]ChatModelConfigSummary, 0, len(configs)) + for _, config := range configs { + if !config.Enabled { + continue + } + summaries = append(summaries, ChatModelConfigSummary{ + ID: config.ID.String(), + Model: config.Model, + DisplayName: config.DisplayName, + IsDefault: config.IsDefault, + }) + } + return ListChatModelConfigsResponse{ModelConfigs: summaries}, nil + }, +} diff --git a/codersdk/toolsdk/chats_test.go b/codersdk/toolsdk/chats_test.go new file mode 100644 index 00000000000..fe9785fd609 --- /dev/null +++ b/codersdk/toolsdk/chats_test.go @@ -0,0 +1,143 @@ +package toolsdk_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/aibridgedtest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/toolsdk" + "github.com/coder/coder/v2/testutil" +) + +// Chat tools need a chat-enabled coderd (provider keys, a default model +// config, and an AI bridge daemon), so they are tested separately from +// TestTools. Subtests run sequentially and share the deployment. +// nolint:tparallel,paralleltest +func TestChatTools(t *testing.T) { + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + }) + coderdtest.CreateFirstUser(t, client) + expClient := codersdk.NewExperimentalClient(client) + defaultModelConfig := coderdtest.CreateOpenAICompatChatModelConfig(t, expClient, "") + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) + + tb, err := toolsdk.NewDeps(client) + require.NoError(t, err) + + t.Run("ListChatModelConfigs", func(t *testing.T) { + result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{}) + require.NoError(t, err) + require.Len(t, result.ModelConfigs, 1) + require.Equal(t, defaultModelConfig.ID.String(), result.ModelConfigs[0].ID) + require.Equal(t, coderdtest.TestChatModelOpenAICompat, result.ModelConfigs[0].Model) + require.True(t, result.ModelConfigs[0].IsDefault) + }) + + t.Run("Lifecycle", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + created, err := testTool(t, toolsdk.CreateChat, tb, toolsdk.CreateChatArgs{ + Prompt: "Say hello.", + Labels: map[string]string{"purpose": "toolsdk-test"}, + }) + require.NoError(t, err) + chatID, err := uuid.Parse(created.ID) + require.NoError(t, err) + require.Equal(t, client.URL.String()+"/agents/"+created.ID, created.URL) + + coderdtest.WaitForChatSettled(ctx, t, api, chatID) + + got, err := testTool(t, toolsdk.GetChat, tb, toolsdk.GetChatArgs{ChatID: created.ID}) + require.NoError(t, err) + require.Equal(t, created.ID, got.ID) + require.Equal(t, codersdk.ChatStatusWaiting, got.Status) + require.Nil(t, got.LastError) + require.False(t, got.Archived) + + sent, err := testTool(t, toolsdk.SendChatMessage, tb, toolsdk.SendChatMessageArgs{ + ChatID: created.ID, + Text: "Say hello again.", + }) + require.NoError(t, err) + require.False(t, sent.Queued) + + coderdtest.WaitForChatSettled(ctx, t, api, chatID) + + messages, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ChatID: created.ID}) + require.NoError(t, err) + require.False(t, messages.HasMore) + var texts []string + for _, msg := range messages.Messages { + texts = append(texts, string(msg.Role)+": "+msg.Text) + } + require.Contains(t, texts, "user: Say hello.") + require.Contains(t, texts, "user: Say hello again.") + require.Contains(t, texts, "assistant: Hello from test server.") + // Chronological order: the initial prompt comes first. + require.Equal(t, "user: Say hello.", texts[0]) + + archived, err := testTool(t, toolsdk.ArchiveChat, tb, toolsdk.ArchiveChatArgs{ChatID: created.ID}) + require.NoError(t, err) + require.NotEmpty(t, archived.Message) + + got, err = testTool(t, toolsdk.GetChat, tb, toolsdk.GetChatArgs{ChatID: created.ID}) + require.NoError(t, err) + require.True(t, got.Archived) + }) + + t.Run("Interrupt", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + // A provider that never answers the chat turn keeps the chat + // running so the interrupt has a deterministic target. + release := make(chan struct{}) + blockingURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if req.Stream { + select { + case <-release: + case <-req.Context().Done(): + } + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("Released.")...) + } + return chattest.OpenAINonStreamingResponse(`{"title": "Interrupt Test"}`) + }) + blockingModelConfig := coderdtest.CreateOpenAICompatChatModelConfig(t, expClient, blockingURL) + + created, err := testTool(t, toolsdk.CreateChat, tb, toolsdk.CreateChatArgs{ + Prompt: "Block forever.", + ModelConfigID: blockingModelConfig.ID.String(), + }) + require.NoError(t, err) + require.Equal(t, codersdk.ChatStatusRunning, created.Status) + + interrupted, err := testTool(t, toolsdk.InterruptChat, tb, toolsdk.InterruptChatArgs{ChatID: created.ID}) + require.NoError(t, err) + require.Equal(t, codersdk.ChatStatusInterrupting, interrupted.Status) + + close(release) + coderdtest.WaitForChatSettled(ctx, t, api, uuid.MustParse(created.ID)) + }) + + t.Run("Validation", func(t *testing.T) { + _, err := testTool(t, toolsdk.CreateChat, tb, toolsdk.CreateChatArgs{}) + require.ErrorContains(t, err, "prompt is required") + + _, err = testTool(t, toolsdk.GetChat, tb, toolsdk.GetChatArgs{ChatID: "not-a-uuid"}) + require.ErrorContains(t, err, "chat_id must be a valid UUID") + + _, err = testTool(t, toolsdk.SendChatMessage, tb, toolsdk.SendChatMessageArgs{ + ChatID: uuid.NewString(), + Text: "hi", + BusyBehavior: "bogus", + }) + require.ErrorContains(t, err, "busy_behavior") + }) +} diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 81908820a61..f8ca4bcb1b6 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -58,6 +58,13 @@ const ( ToolNameGetTaskStatus = "coder_get_task_status" ToolNameSendTaskInput = "coder_send_task_input" ToolNameGetTaskLogs = "coder_get_task_logs" + ToolNameCreateChat = "coder_create_chat" + ToolNameGetChat = "coder_get_chat" + ToolNameGetChatMessages = "coder_get_chat_messages" + ToolNameSendChatMessage = "coder_send_chat_message" + ToolNameInterruptChat = "coder_interrupt_chat" + ToolNameArchiveChat = "coder_archive_chat" + ToolNameListChatModelConfigs = "coder_list_chat_model_configs" ) func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) { @@ -338,6 +345,13 @@ var All = []GenericTool{ GetTaskStatus.Generic(), SendTaskInput.Generic(), GetTaskLogs.Generic(), + CreateChat.Generic(), + GetChat.Generic(), + GetChatMessages.Generic(), + SendChatMessage.Generic(), + InterruptChat.Generic(), + ArchiveChat.Generic(), + ListChatModelConfigs.Generic(), } type ReportTaskArgs struct { diff --git a/docs/ai-coder/mcp-server.md b/docs/ai-coder/mcp-server.md index 8b0b9194bee..506ad943c12 100644 --- a/docs/ai-coder/mcp-server.md +++ b/docs/ai-coder/mcp-server.md @@ -189,6 +189,7 @@ The MCP server exposes tools across several areas: - **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 The full, authoritative set of tools, including their names, descriptions, and From 78b3e7cd605046cde050e439b759b8a0ea42671c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:43:23 +0000 Subject: [PATCH 02/14] refactor(codersdk/toolsdk): tighten chat tools after cleanup review Type busy_behavior with the SDK enum, derive the schema enum from constants, validate the message limit locally so out-of-range values fail loudly, and drop comments and checks inconsistent with sibling tools. --- codersdk/toolsdk/chats.go | 45 ++++++++++++++++------------------ codersdk/toolsdk/chats_test.go | 14 ++++++++--- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/codersdk/toolsdk/chats.go b/codersdk/toolsdk/chats.go index c39361395a5..4b1503ca4b6 100644 --- a/codersdk/toolsdk/chats.go +++ b/codersdk/toolsdk/chats.go @@ -26,14 +26,12 @@ func parseChatID(chatID string) (uuid.UUID, error) { return id, nil } -// ChatToolFile is lightweight metadata about a file attached to a chat. type ChatToolFile struct { ID string `json:"id"` Name string `json:"name"` MimeType string `json:"mime_type"` } -// ChatToolStatus is the chat state shared by the chat tools. type ChatToolStatus struct { ID string `json:"id"` Title string `json:"title"` @@ -48,14 +46,12 @@ type ChatToolStatus struct { func chatToolStatus(deps Deps, chat codersdk.Chat) ChatToolStatus { resp := ChatToolStatus{ - ID: chat.ID.String(), - Title: chat.Title, - Status: chat.Status, - Archived: chat.Archived, - URL: fmt.Sprintf("%s/agents/%s", deps.ServerURL(), chat.ID), - } - if chat.LastError != nil { - resp.LastError = chat.LastError + ID: chat.ID.String(), + Title: chat.Title, + Status: chat.Status, + Archived: chat.Archived, + LastError: chat.LastError, + URL: fmt.Sprintf("%s/agents/%s", deps.ServerURL(), chat.ID), } if chat.LastTurnSummary != nil { resp.LastTurnSummary = *chat.LastTurnSummary @@ -126,9 +122,6 @@ The chat runs asynchronously. Poll coder_get_chat for status and read the transc if err != nil { return ChatToolStatus{}, err } - if len(me.OrganizationIDs) == 0 { - return ChatToolStatus{}, xerrors.New("authenticated user belongs to no organization") - } orgID = me.OrganizationIDs[0] } var modelConfigID *uuid.UUID @@ -192,7 +185,6 @@ type GetChatMessagesArgs struct { Limit int `json:"limit"` } -// ChatToolMessage is a text-only view of a chat message. type ChatToolMessage struct { ID int64 `json:"id"` Role codersdk.ChatMessageRole `json:"role"` @@ -231,6 +223,9 @@ Only text content is returned; tool calls and other internal parts are omitted.` if err != nil { return GetChatMessagesResponse{}, err } + if args.Limit < 0 || args.Limit > 200 { + return GetChatMessagesResponse{}, xerrors.New("limit must be between 1 and 200") + } var opts *codersdk.ChatMessagesPaginationOptions if args.Limit > 0 { opts = &codersdk.ChatMessagesPaginationOptions{Limit: args.Limit} @@ -268,9 +263,9 @@ Only text content is returned; tool calls and other internal parts are omitted.` } type SendChatMessageArgs struct { - ChatID string `json:"chat_id"` - Text string `json:"text"` - BusyBehavior string `json:"busy_behavior"` + ChatID string `json:"chat_id"` + Text string `json:"text"` + BusyBehavior codersdk.ChatBusyBehavior `json:"busy_behavior"` } type SendChatMessageResponse struct { @@ -295,7 +290,10 @@ var SendChatMessage = Tool[SendChatMessageArgs, SendChatMessageResponse]{ "busy_behavior": map[string]any{ "type": "string", "description": "What to do when the chat is already processing: \"queue\" (default) processes the message after the current run, \"interrupt\" stops the current run first.", - "enum": []string{"queue", "interrupt"}, + "enum": []string{ + string(codersdk.ChatBusyBehaviorQueue), + string(codersdk.ChatBusyBehaviorInterrupt), + }, }, }, Required: []string{"chat_id", "text"}, @@ -310,11 +308,11 @@ var SendChatMessage = Tool[SendChatMessageArgs, SendChatMessageResponse]{ if args.Text == "" { return SendChatMessageResponse{}, xerrors.New("text is required") } - busyBehavior := codersdk.ChatBusyBehaviorQueue - switch args.BusyBehavior { - case "", string(codersdk.ChatBusyBehaviorQueue): - case string(codersdk.ChatBusyBehaviorInterrupt): - busyBehavior = codersdk.ChatBusyBehaviorInterrupt + busyBehavior := args.BusyBehavior + switch busyBehavior { + case "": + busyBehavior = codersdk.ChatBusyBehaviorQueue + case codersdk.ChatBusyBehaviorQueue, codersdk.ChatBusyBehaviorInterrupt: default: return SendChatMessageResponse{}, xerrors.New(`busy_behavior must be "queue" or "interrupt"`) } @@ -404,7 +402,6 @@ var ArchiveChat = Tool[ArchiveChatArgs, codersdk.Response]{ }, } -// ChatModelConfigSummary describes one selectable chat model. type ChatModelConfigSummary struct { ID string `json:"id"` Model string `json:"model"` diff --git a/codersdk/toolsdk/chats_test.go b/codersdk/toolsdk/chats_test.go index fe9785fd609..5e02f155cdc 100644 --- a/codersdk/toolsdk/chats_test.go +++ b/codersdk/toolsdk/chats_test.go @@ -81,7 +81,6 @@ func TestChatTools(t *testing.T) { require.Contains(t, texts, "user: Say hello.") require.Contains(t, texts, "user: Say hello again.") require.Contains(t, texts, "assistant: Hello from test server.") - // Chronological order: the initial prompt comes first. require.Equal(t, "user: Say hello.", texts[0]) archived, err := testTool(t, toolsdk.ArchiveChat, tb, toolsdk.ArchiveChatArgs{ChatID: created.ID}) @@ -96,8 +95,7 @@ func TestChatTools(t *testing.T) { t.Run("Interrupt", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - // A provider that never answers the chat turn keeps the chat - // running so the interrupt has a deterministic target. + // Block the chat turn so the interrupt has a deterministic target. release := make(chan struct{}) blockingURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { if req.Stream { @@ -136,8 +134,16 @@ func TestChatTools(t *testing.T) { _, err = testTool(t, toolsdk.SendChatMessage, tb, toolsdk.SendChatMessageArgs{ ChatID: uuid.NewString(), Text: "hi", - BusyBehavior: "bogus", + BusyBehavior: codersdk.ChatBusyBehavior("bogus"), }) require.ErrorContains(t, err, "busy_behavior") + + for _, limit := range []int{-1, 201} { + _, err = testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ + ChatID: uuid.NewString(), + Limit: limit, + }) + require.ErrorContains(t, err, "limit must be between 1 and 200") + } }) } From 4b94c814a8f2918b95fd4812bd8d90003f144658 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:43:42 +0000 Subject: [PATCH 03/14] fix(codersdk/toolsdk): address Codex review of chat tools Add a before_id cursor to coder_get_chat_messages so has_more pages are reachable, and filter provider-disabled model configs out of coder_list_chat_model_configs for admin callers. --- codersdk/toolsdk/chats.go | 38 +++++++++++++++++++++++++++------ codersdk/toolsdk/chats_test.go | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/codersdk/toolsdk/chats.go b/codersdk/toolsdk/chats.go index 4b1503ca4b6..3f9b5ba4d9a 100644 --- a/codersdk/toolsdk/chats.go +++ b/codersdk/toolsdk/chats.go @@ -181,8 +181,9 @@ var GetChat = Tool[GetChatArgs, ChatToolStatus]{ } type GetChatMessagesArgs struct { - ChatID string `json:"chat_id"` - Limit int `json:"limit"` + ChatID string `json:"chat_id"` + Limit int `json:"limit"` + BeforeID int64 `json:"before_id"` } type ChatToolMessage struct { @@ -202,7 +203,7 @@ var GetChatMessages = Tool[GetChatMessagesArgs, GetChatMessagesResponse]{ Name: ToolNameGetChatMessages, Description: `Get the newest messages of a Coder Agents chat in chronological order. -Only text content is returned; tool calls and other internal parts are omitted.`, +Only text content is returned; tool calls and other internal parts are omitted. When has_more is true, pass the smallest returned message id as before_id to page through older messages.`, Schema: aisdk.Schema{ Properties: map[string]any{ "chat_id": map[string]any{ @@ -213,6 +214,10 @@ Only text content is returned; tool calls and other internal parts are omitted.` "type": "integer", "description": "Maximum number of messages to fetch, from newest to oldest (1-200, default 50).", }, + "before_id": map[string]any{ + "type": "integer", + "description": "Only fetch messages with an id lower than this cursor. Omit to fetch the newest messages.", + }, }, Required: []string{"chat_id"}, }, @@ -226,9 +231,15 @@ Only text content is returned; tool calls and other internal parts are omitted.` if args.Limit < 0 || args.Limit > 200 { return GetChatMessagesResponse{}, xerrors.New("limit must be between 1 and 200") } + if args.BeforeID < 0 { + return GetChatMessagesResponse{}, xerrors.New("before_id must be a positive message id") + } var opts *codersdk.ChatMessagesPaginationOptions - if args.Limit > 0 { - opts = &codersdk.ChatMessagesPaginationOptions{Limit: args.Limit} + if args.Limit > 0 || args.BeforeID > 0 { + opts = &codersdk.ChatMessagesPaginationOptions{ + Limit: args.Limit, + BeforeID: args.BeforeID, + } } resp, err := codersdk.NewExperimentalClient(deps.coderClient).GetChatMessages(ctx, chatID, opts) if err != nil { @@ -415,8 +426,10 @@ type ListChatModelConfigsResponse struct { var ListChatModelConfigs = Tool[NoArgs, ListChatModelConfigsResponse]{ Tool: aisdk.Tool{ - Name: ToolNameListChatModelConfigs, - Description: `List the enabled chat models available for Coder Agents chats. Use a model config ID with coder_create_chat to pick a model.`, + Name: ToolNameListChatModelConfigs, + Description: `List the enabled chat models available for Coder Agents chats. Use a model config ID with coder_create_chat to pick a model. + +Per-user provider credentials are validated when creating a chat, so coder_create_chat can still reject a listed model with an explanatory error.`, Schema: aisdk.Schema{ Properties: map[string]any{}, Required: []string{}, @@ -428,11 +441,22 @@ var ListChatModelConfigs = Tool[NoArgs, ListChatModelConfigsResponse]{ if err != nil { return ListChatModelConfigsResponse{}, xerrors.Errorf("list chat model configs: %w", err) } + // Admin model lists include configs backed by disabled providers. Filter + // them when provider state is available; non-admin lists are already filtered. + providerEnabled := map[uuid.UUID]bool{} + if providers, err := deps.coderClient.AIProviders(ctx); err == nil { + for _, provider := range providers { + providerEnabled[provider.ID] = provider.Enabled + } + } summaries := make([]ChatModelConfigSummary, 0, len(configs)) for _, config := range configs { if !config.Enabled { continue } + if enabled, ok := providerEnabled[config.AIProviderID]; ok && !enabled { + continue + } summaries = append(summaries, ChatModelConfigSummary{ ID: config.ID.String(), Model: config.Model, diff --git a/codersdk/toolsdk/chats_test.go b/codersdk/toolsdk/chats_test.go index 5e02f155cdc..6b15ec0fc8b 100644 --- a/codersdk/toolsdk/chats_test.go +++ b/codersdk/toolsdk/chats_test.go @@ -8,6 +8,7 @@ import ( "github.com/coder/coder/v2/coderd/aibridgedtest" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/x/chatd/chattest" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/toolsdk" @@ -83,6 +84,23 @@ func TestChatTools(t *testing.T) { require.Contains(t, texts, "assistant: Hello from test server.") require.Equal(t, "user: Say hello.", texts[0]) + firstPage, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ + ChatID: created.ID, + Limit: 1, + }) + require.NoError(t, err) + require.True(t, firstPage.HasMore) + require.Len(t, firstPage.Messages, 1) + olderPage, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ + ChatID: created.ID, + BeforeID: firstPage.Messages[0].ID, + }) + require.NoError(t, err) + require.NotEmpty(t, olderPage.Messages) + for _, msg := range olderPage.Messages { + require.Less(t, msg.ID, firstPage.Messages[0].ID) + } + archived, err := testTool(t, toolsdk.ArchiveChat, tb, toolsdk.ArchiveChatArgs{ChatID: created.ID}) require.NoError(t, err) require.NotEmpty(t, archived.Message) @@ -92,6 +110,27 @@ func TestChatTools(t *testing.T) { require.True(t, got.Archived) }) + t.Run("ListChatModelConfigsSkipsDisabledProviders", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + // Admin model lists include configs backed by disabled providers. + disabledProviderConfig := coderdtest.CreateOpenAICompatChatModelConfig(t, expClient, chattest.OpenAI(t)) + provider, err := client.UpdateAIProvider(ctx, disabledProviderConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{ + Enabled: ptr.Ref(false), + }) + require.NoError(t, err) + require.False(t, provider.Enabled) + + result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{}) + require.NoError(t, err) + var ids []string + for _, config := range result.ModelConfigs { + ids = append(ids, config.ID) + } + require.NotContains(t, ids, disabledProviderConfig.ID.String()) + require.Contains(t, ids, defaultModelConfig.ID.String()) + }) + t.Run("Interrupt", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) From be6e7872d6be9e4c23eab90527f2fcca60c7ef66 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:03:14 +0000 Subject: [PATCH 04/14] fix(codersdk/toolsdk): return an explicit chat message page cursor A page holding only tool-call rows filters to an empty messages slice, leaving callers with no id to derive a cursor from. Return next_before_id from the unfiltered API page instead. --- codersdk/toolsdk/chats.go | 20 +++++++++++++++++--- codersdk/toolsdk/chats_test.go | 27 ++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/codersdk/toolsdk/chats.go b/codersdk/toolsdk/chats.go index 3f9b5ba4d9a..6803ed7152f 100644 --- a/codersdk/toolsdk/chats.go +++ b/codersdk/toolsdk/chats.go @@ -196,6 +196,10 @@ type ChatToolMessage struct { type GetChatMessagesResponse struct { Messages []ChatToolMessage `json:"messages"` HasMore bool `json:"has_more"` + // NextBeforeID is the cursor for the next older page when HasMore is + // true. It is derived from the unfiltered API page, so it stays valid + // even when every message in this page was filtered out as non-text. + NextBeforeID int64 `json:"next_before_id,omitempty"` } var GetChatMessages = Tool[GetChatMessagesArgs, GetChatMessagesResponse]{ @@ -203,7 +207,7 @@ var GetChatMessages = Tool[GetChatMessagesArgs, GetChatMessagesResponse]{ Name: ToolNameGetChatMessages, Description: `Get the newest messages of a Coder Agents chat in chronological order. -Only text content is returned; tool calls and other internal parts are omitted. When has_more is true, pass the smallest returned message id as before_id to page through older messages.`, +Only text content is returned; tool calls and other internal parts are omitted. When has_more is true, pass next_before_id as before_id to page through older messages.`, Schema: aisdk.Schema{ Properties: map[string]any{ "chat_id": map[string]any{ @@ -266,9 +270,19 @@ Only text content is returned; tool calls and other internal parts are omitted. Text: strings.Join(texts, "\n"), }) } + var nextBeforeID int64 + if resp.HasMore && len(resp.Messages) > 0 { + nextBeforeID = resp.Messages[0].ID + for _, msg := range resp.Messages { + if msg.ID < nextBeforeID { + nextBeforeID = msg.ID + } + } + } return GetChatMessagesResponse{ - Messages: messages, - HasMore: resp.HasMore, + Messages: messages, + HasMore: resp.HasMore, + NextBeforeID: nextBeforeID, }, nil }, } diff --git a/codersdk/toolsdk/chats_test.go b/codersdk/toolsdk/chats_test.go index 6b15ec0fc8b..a12ac6674f0 100644 --- a/codersdk/toolsdk/chats_test.go +++ b/codersdk/toolsdk/chats_test.go @@ -8,7 +8,10 @@ import ( "github.com/coder/coder/v2/coderd/aibridgedtest" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chattest" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/toolsdk" @@ -84,22 +87,40 @@ func TestChatTools(t *testing.T) { require.Contains(t, texts, "assistant: Hello from test server.") require.Equal(t, "user: Say hello.", texts[0]) + // A tool-call-only message filters to an empty page, so the + // cursor must come from the unfiltered API page. + toolCallContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{ + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: "call-1", + ToolName: "execute", + }}) + require.NoError(t, err) + toolCallMsg := dbgen.ChatMessage(t, api.Database, database.ChatMessage{ + ChatID: chatID, + ModelConfigID: uuid.NullUUID{UUID: defaultModelConfig.ID, Valid: true}, + Role: database.ChatMessageRoleAssistant, + Content: toolCallContent, + }) + firstPage, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ ChatID: created.ID, Limit: 1, }) require.NoError(t, err) require.True(t, firstPage.HasMore) - require.Len(t, firstPage.Messages, 1) + require.Empty(t, firstPage.Messages) + require.Equal(t, toolCallMsg.ID, firstPage.NextBeforeID) olderPage, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ ChatID: created.ID, - BeforeID: firstPage.Messages[0].ID, + BeforeID: firstPage.NextBeforeID, }) require.NoError(t, err) require.NotEmpty(t, olderPage.Messages) for _, msg := range olderPage.Messages { - require.Less(t, msg.ID, firstPage.Messages[0].ID) + require.Less(t, msg.ID, firstPage.NextBeforeID) } + require.False(t, olderPage.HasMore) + require.Zero(t, olderPage.NextBeforeID) archived, err := testTool(t, toolsdk.ArchiveChat, tb, toolsdk.ArchiveChatArgs{ChatID: created.ID}) require.NoError(t, err) From 83b427b541ae3205201dbc0fe40118682dd91a95 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:04:38 +0000 Subject: [PATCH 05/14] chore(codersdk/toolsdk): drop a redundant test comment --- codersdk/toolsdk/chats_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/codersdk/toolsdk/chats_test.go b/codersdk/toolsdk/chats_test.go index a12ac6674f0..5b975c3b266 100644 --- a/codersdk/toolsdk/chats_test.go +++ b/codersdk/toolsdk/chats_test.go @@ -134,7 +134,6 @@ func TestChatTools(t *testing.T) { t.Run("ListChatModelConfigsSkipsDisabledProviders", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - // Admin model lists include configs backed by disabled providers. disabledProviderConfig := coderdtest.CreateOpenAICompatChatModelConfig(t, expClient, chattest.OpenAI(t)) provider, err := client.UpdateAIProvider(ctx, disabledProviderConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{ Enabled: ptr.Ref(false), From f21095ae0735f2123efcdf011411c434c9bf7e98 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:23:07 +0000 Subject: [PATCH 06/14] fix(codersdk/toolsdk): reject chat creation for users with no organization An admin can remove a user's only membership, so indexing OrganizationIDs[0] can panic and surface as a recovered handler panic instead of an actionable error. --- codersdk/toolsdk/chats.go | 5 +++++ codersdk/toolsdk/chats_test.go | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/codersdk/toolsdk/chats.go b/codersdk/toolsdk/chats.go index 6803ed7152f..ba562dccca9 100644 --- a/codersdk/toolsdk/chats.go +++ b/codersdk/toolsdk/chats.go @@ -122,6 +122,11 @@ The chat runs asynchronously. Poll coder_get_chat for status and read the transc if err != nil { return ChatToolStatus{}, err } + // A user's only membership can be removed by an admin, so an + // empty slice is reachable and indexing it would panic. + if len(me.OrganizationIDs) == 0 { + return ChatToolStatus{}, xerrors.New("authenticated user belongs to no organization; pass organization_id explicitly") + } orgID = me.OrganizationIDs[0] } var modelConfigID *uuid.UUID diff --git a/codersdk/toolsdk/chats_test.go b/codersdk/toolsdk/chats_test.go index 5b975c3b266..d1b356989d3 100644 --- a/codersdk/toolsdk/chats_test.go +++ b/codersdk/toolsdk/chats_test.go @@ -183,6 +183,20 @@ func TestChatTools(t *testing.T) { coderdtest.WaitForChatSettled(ctx, t, api, uuid.MustParse(created.ID)) }) + t.Run("CreateChatZeroOrgUser", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + firstUser, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + orphanClient, orphan := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationIDs[0]) + require.NoError(t, client.DeleteOrganizationMember(ctx, firstUser.OrganizationIDs[0], orphan.ID.String())) + + orphanDeps, err := toolsdk.NewDeps(orphanClient) + require.NoError(t, err) + _, err = testTool(t, toolsdk.CreateChat, orphanDeps, toolsdk.CreateChatArgs{Prompt: "hi"}) + require.ErrorContains(t, err, "belongs to no organization") + }) + t.Run("Validation", func(t *testing.T) { _, err := testTool(t, toolsdk.CreateChat, tb, toolsdk.CreateChatArgs{}) require.ErrorContains(t, err, "prompt is required") From d93cc83872d09935893ee3e4c13929191d2ff729 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:40:07 +0000 Subject: [PATCH 07/14] fix(codersdk/toolsdk): include hook notices in chat transcripts Hook-notice parts are user-facing per the SDK part contract, so coder_get_chat_messages must not filter them out with internal parts. --- codersdk/toolsdk/chats.go | 8 ++++++-- codersdk/toolsdk/chats_test.go | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/codersdk/toolsdk/chats.go b/codersdk/toolsdk/chats.go index ba562dccca9..a8e3eee24ee 100644 --- a/codersdk/toolsdk/chats.go +++ b/codersdk/toolsdk/chats.go @@ -212,7 +212,7 @@ var GetChatMessages = Tool[GetChatMessagesArgs, GetChatMessagesResponse]{ Name: ToolNameGetChatMessages, Description: `Get the newest messages of a Coder Agents chat in chronological order. -Only text content is returned; tool calls and other internal parts are omitted. When has_more is true, pass next_before_id as before_id to page through older messages.`, +Only user-facing text content is returned (including lifecycle hook notices); tool calls and other internal parts are omitted. When has_more is true, pass next_before_id as before_id to page through older messages.`, Schema: aisdk.Schema{ Properties: map[string]any{ "chat_id": map[string]any{ @@ -261,7 +261,11 @@ Only text content is returned; tool calls and other internal parts are omitted. msg := resp.Messages[i] var texts []string for _, part := range msg.Content { - if part.Type == codersdk.ChatMessagePartTypeText && part.Text != "" { + // Hook notices are user-facing per the SDK part contract, + // unlike hook context, which is model-only. + isUserFacingText := part.Type == codersdk.ChatMessagePartTypeText || + part.Type == codersdk.ChatMessagePartTypeHookNotice + if isUserFacingText && part.Text != "" { texts = append(texts, part.Text) } } diff --git a/codersdk/toolsdk/chats_test.go b/codersdk/toolsdk/chats_test.go index d1b356989d3..75df87b174b 100644 --- a/codersdk/toolsdk/chats_test.go +++ b/codersdk/toolsdk/chats_test.go @@ -87,6 +87,25 @@ func TestChatTools(t *testing.T) { require.Contains(t, texts, "assistant: Hello from test server.") require.Equal(t, "user: Say hello.", texts[0]) + hookNoticeContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{ + Type: codersdk.ChatMessagePartTypeHookNotice, + Text: "Command denied by policy.", + }}) + require.NoError(t, err) + dbgen.ChatMessage(t, api.Database, database.ChatMessage{ + ChatID: chatID, + ModelConfigID: uuid.NullUUID{UUID: defaultModelConfig.ID, Valid: true}, + Role: database.ChatMessageRoleUser, + Content: hookNoticeContent, + }) + withNotice, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ChatID: created.ID}) + require.NoError(t, err) + var noticeTexts []string + for _, msg := range withNotice.Messages { + noticeTexts = append(noticeTexts, msg.Text) + } + require.Contains(t, noticeTexts, "Command denied by policy.") + // A tool-call-only message filters to an empty page, so the // cursor must come from the unfiltered API page. toolCallContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{ From 6aba5c215400331f794c3775fb4adf2b11481e0a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:58:25 +0000 Subject: [PATCH 08/14] fix(codersdk/toolsdk): fail closed when provider state is unreadable A deployment-config reader without AI provider read access (such as an auditor) receives the unfiltered admin model config list, and silently discarding the AIProviders error let provider-disabled configs leak through. Distinguish that case and error instead. --- codersdk/toolsdk/chats.go | 25 ++++++++++++++++++++++--- codersdk/toolsdk/chats_test.go | 31 ++++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/codersdk/toolsdk/chats.go b/codersdk/toolsdk/chats.go index a8e3eee24ee..3d4d8623a04 100644 --- a/codersdk/toolsdk/chats.go +++ b/codersdk/toolsdk/chats.go @@ -2,7 +2,9 @@ package toolsdk import ( "context" + "errors" "fmt" + "net/http" "strings" "time" @@ -15,6 +17,11 @@ import ( const chatIDDescription = "UUID of the chat." +func isForbiddenError(err error) bool { + var sdkErr *codersdk.Error + return errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusForbidden +} + func parseChatID(chatID string) (uuid.UUID, error) { if chatID == "" { return uuid.Nil, xerrors.New("chat_id is required") @@ -464,13 +471,25 @@ Per-user provider credentials are validated when creating a chat, so coder_creat if err != nil { return ListChatModelConfigsResponse{}, xerrors.Errorf("list chat model configs: %w", err) } - // Admin model lists include configs backed by disabled providers. Filter - // them when provider state is available; non-admin lists are already filtered. + // Admin model lists include configs backed by disabled providers. + // Filter them with provider state; non-admin lists are already + // filtered server-side. providerEnabled := map[uuid.UUID]bool{} - if providers, err := deps.coderClient.AIProviders(ctx); err == nil { + providers, err := deps.coderClient.AIProviders(ctx) + switch { + case err == nil: for _, provider := range providers { providerEnabled[provider.ID] = provider.Enabled } + case isForbiddenError(err): + // Deployment-config readers without AI provider read access + // (such as auditors) receive the unverifiable admin list, so + // fail closed instead of leaking provider-disabled configs. + if _, dcErr := deps.coderClient.DeploymentConfig(ctx); dcErr == nil { + return ListChatModelConfigsResponse{}, xerrors.New("cannot verify provider availability for the admin model config list: missing AI provider read permission") + } + default: + return ListChatModelConfigsResponse{}, xerrors.Errorf("list AI providers: %w", err) } summaries := make([]ChatModelConfigSummary, 0, len(configs)) for _, config := range configs { diff --git a/codersdk/toolsdk/chats_test.go b/codersdk/toolsdk/chats_test.go index 75df87b174b..fbe4b68e48a 100644 --- a/codersdk/toolsdk/chats_test.go +++ b/codersdk/toolsdk/chats_test.go @@ -10,6 +10,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chattest" @@ -28,7 +29,7 @@ func TestChatTools(t *testing.T) { DeploymentValues: coderdtest.DeploymentValues(t), ChatProviderAPIKeys: &providerKeys, }) - coderdtest.CreateFirstUser(t, client) + firstUser := coderdtest.CreateFirstUser(t, client) expClient := codersdk.NewExperimentalClient(client) defaultModelConfig := coderdtest.CreateOpenAICompatChatModelConfig(t, expClient, "") aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) @@ -202,13 +203,33 @@ func TestChatTools(t *testing.T) { coderdtest.WaitForChatSettled(ctx, t, api, uuid.MustParse(created.ID)) }) + t.Run("ListChatModelConfigsMemberAndAuditor", func(t *testing.T) { + // A member cannot read providers but gets the server-filtered + // list; an auditor gets the unverifiable admin list and must + // fail closed rather than leak provider-disabled configs. + memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + memberDeps, err := toolsdk.NewDeps(memberClient) + require.NoError(t, err) + result, err := testTool(t, toolsdk.ListChatModelConfigs, memberDeps, toolsdk.NoArgs{}) + require.NoError(t, err) + var ids []string + for _, config := range result.ModelConfigs { + ids = append(ids, config.ID) + } + require.Contains(t, ids, defaultModelConfig.ID.String()) + + auditorClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID, rbac.RoleAuditor()) + auditorDeps, err := toolsdk.NewDeps(auditorClient) + require.NoError(t, err) + _, err = testTool(t, toolsdk.ListChatModelConfigs, auditorDeps, toolsdk.NoArgs{}) + require.ErrorContains(t, err, "missing AI provider read permission") + }) + t.Run("CreateChatZeroOrgUser", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - firstUser, err := client.User(ctx, codersdk.Me) - require.NoError(t, err) - orphanClient, orphan := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationIDs[0]) - require.NoError(t, client.DeleteOrganizationMember(ctx, firstUser.OrganizationIDs[0], orphan.ID.String())) + orphanClient, orphan := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + require.NoError(t, client.DeleteOrganizationMember(ctx, firstUser.OrganizationID, orphan.ID.String())) orphanDeps, err := toolsdk.NewDeps(orphanClient) require.NoError(t, err) From c73810e4140a96e245594b1d200b4b596d21eb93 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:17:45 +0000 Subject: [PATCH 09/14] fix(codersdk/toolsdk): surface queued chat messages and harden the provider probe A prompt queued behind a busy chat now appears in queued_messages so polling callers do not conclude it was lost, and a failing deployment-config probe now propagates instead of falling through to the fail-open member path. --- codersdk/toolsdk/chats.go | 56 +++++++++++++++++++++++----------- codersdk/toolsdk/chats_test.go | 39 +++++++++++++++++++++++ 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/codersdk/toolsdk/chats.go b/codersdk/toolsdk/chats.go index 3d4d8623a04..3e7abdb729d 100644 --- a/codersdk/toolsdk/chats.go +++ b/codersdk/toolsdk/chats.go @@ -212,6 +212,24 @@ type GetChatMessagesResponse struct { // true. It is derived from the unfiltered API page, so it stays valid // even when every message in this page was filtered out as non-text. NextBeforeID int64 `json:"next_before_id,omitempty"` + // QueuedMessages holds prompts waiting for the current run to finish. + // The API returns them only on the initial page (no cursor). + QueuedMessages []string `json:"queued_messages,omitempty"` +} + +// userFacingText concatenates the user-facing text parts of a message. +// Hook notices are user-facing per the SDK part contract, unlike hook +// context, which is model-only. +func userFacingText(parts []codersdk.ChatMessagePart) string { + var texts []string + for _, part := range parts { + isUserFacingText := part.Type == codersdk.ChatMessagePartTypeText || + part.Type == codersdk.ChatMessagePartTypeHookNotice + if isUserFacingText && part.Text != "" { + texts = append(texts, part.Text) + } + } + return strings.Join(texts, "\n") } var GetChatMessages = Tool[GetChatMessagesArgs, GetChatMessagesResponse]{ @@ -219,7 +237,7 @@ var GetChatMessages = Tool[GetChatMessagesArgs, GetChatMessagesResponse]{ Name: ToolNameGetChatMessages, Description: `Get the newest messages of a Coder Agents chat in chronological order. -Only user-facing text content is returned (including lifecycle hook notices); tool calls and other internal parts are omitted. When has_more is true, pass next_before_id as before_id to page through older messages.`, +Only user-facing text content is returned (including lifecycle hook notices); tool calls and other internal parts are omitted. Prompts still queued behind a busy chat appear in queued_messages. When has_more is true, pass next_before_id as before_id to page through older messages.`, Schema: aisdk.Schema{ Properties: map[string]any{ "chat_id": map[string]any{ @@ -266,26 +284,23 @@ Only user-facing text content is returned (including lifecycle hook notices); to messages := make([]ChatToolMessage, 0, len(resp.Messages)) for i := len(resp.Messages) - 1; i >= 0; i-- { msg := resp.Messages[i] - var texts []string - for _, part := range msg.Content { - // Hook notices are user-facing per the SDK part contract, - // unlike hook context, which is model-only. - isUserFacingText := part.Type == codersdk.ChatMessagePartTypeText || - part.Type == codersdk.ChatMessagePartTypeHookNotice - if isUserFacingText && part.Text != "" { - texts = append(texts, part.Text) - } - } - if len(texts) == 0 { + text := userFacingText(msg.Content) + if text == "" { continue } messages = append(messages, ChatToolMessage{ ID: msg.ID, Role: msg.Role, CreatedAt: msg.CreatedAt, - Text: strings.Join(texts, "\n"), + Text: text, }) } + var queued []string + for _, msg := range resp.QueuedMessages { + if text := userFacingText(msg.Content); text != "" { + queued = append(queued, text) + } + } var nextBeforeID int64 if resp.HasMore && len(resp.Messages) > 0 { nextBeforeID = resp.Messages[0].ID @@ -296,9 +311,10 @@ Only user-facing text content is returned (including lifecycle hook notices); to } } return GetChatMessagesResponse{ - Messages: messages, - HasMore: resp.HasMore, - NextBeforeID: nextBeforeID, + Messages: messages, + HasMore: resp.HasMore, + NextBeforeID: nextBeforeID, + QueuedMessages: queued, }, nil }, } @@ -485,8 +501,14 @@ Per-user provider credentials are validated when creating a chat, so coder_creat // Deployment-config readers without AI provider read access // (such as auditors) receive the unverifiable admin list, so // fail closed instead of leaking provider-disabled configs. - if _, dcErr := deps.coderClient.DeploymentConfig(ctx); dcErr == nil { + // Only a confirmed 403 selects the member path, whose list + // the server already filtered. + _, dcErr := deps.coderClient.DeploymentConfig(ctx) + switch { + case dcErr == nil: return ListChatModelConfigsResponse{}, xerrors.New("cannot verify provider availability for the admin model config list: missing AI provider read permission") + case !isForbiddenError(dcErr): + return ListChatModelConfigsResponse{}, xerrors.Errorf("verify deployment config access: %w", dcErr) } default: return ListChatModelConfigsResponse{}, xerrors.Errorf("list AI providers: %w", err) diff --git a/codersdk/toolsdk/chats_test.go b/codersdk/toolsdk/chats_test.go index fbe4b68e48a..1dcb41c394e 100644 --- a/codersdk/toolsdk/chats_test.go +++ b/codersdk/toolsdk/chats_test.go @@ -1,10 +1,12 @@ package toolsdk_test import ( + "net/http" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" + "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/aibridgedtest" "github.com/coder/coder/v2/coderd/coderdtest" @@ -19,6 +21,18 @@ import ( "github.com/coder/coder/v2/testutil" ) +// failPathTransport fails requests to one path and passes the rest through. +type failPathTransport struct { + path string +} + +func (t *failPathTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Path == t.path { + return nil, xerrors.New("transport down") + } + return http.DefaultTransport.RoundTrip(req) +} + // Chat tools need a chat-enabled coderd (provider keys, a default model // config, and an AI bridge daemon), so they are tested separately from // TestTools. Subtests run sequentially and share the deployment. @@ -195,6 +209,18 @@ func TestChatTools(t *testing.T) { require.NoError(t, err) require.Equal(t, codersdk.ChatStatusRunning, created.Status) + // While the run is blocked, a queued send must surface in the + // transcript's queued_messages rather than disappearing. + sent, err := testTool(t, toolsdk.SendChatMessage, tb, toolsdk.SendChatMessageArgs{ + ChatID: created.ID, + Text: "Queued while busy.", + }) + require.NoError(t, err) + require.True(t, sent.Queued) + transcript, err := testTool(t, toolsdk.GetChatMessages, tb, toolsdk.GetChatMessagesArgs{ChatID: created.ID}) + require.NoError(t, err) + require.Contains(t, transcript.QueuedMessages, "Queued while busy.") + interrupted, err := testTool(t, toolsdk.InterruptChat, tb, toolsdk.InterruptChatArgs{ChatID: created.ID}) require.NoError(t, err) require.Equal(t, codersdk.ChatStatusInterrupting, interrupted.Status) @@ -223,6 +249,19 @@ func TestChatTools(t *testing.T) { require.NoError(t, err) _, err = testTool(t, toolsdk.ListChatModelConfigs, auditorDeps, toolsdk.NoArgs{}) require.ErrorContains(t, err, "missing AI provider read permission") + + // A failing deployment-config probe must propagate, not fall + // through to the fail-open member path. + brokenProbeClient := codersdk.New(auditorClient.URL) + brokenProbeClient.SetSessionToken(auditorClient.SessionToken()) + brokenProbeClient.HTTPClient = &http.Client{ + Transport: &failPathTransport{path: "/api/v2/deployment/config"}, + } + t.Cleanup(brokenProbeClient.HTTPClient.CloseIdleConnections) + brokenProbeDeps, err := toolsdk.NewDeps(brokenProbeClient) + require.NoError(t, err) + _, err = testTool(t, toolsdk.ListChatModelConfigs, brokenProbeDeps, toolsdk.NoArgs{}) + require.ErrorContains(t, err, "verify deployment config access") }) t.Run("CreateChatZeroOrgUser", func(t *testing.T) { From 52271891aa55765fbe51a3f0b394611acb40871a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:38:32 +0000 Subject: [PATCH 10/14] fix(codersdk/toolsdk): exclude chat model configs of soft-deleted providers Provider deletion only soft-deletes the provider row, so its enabled model configs stay in the admin list while AIProviders omits the provider. Treat a successful provider enumeration as authoritative and exclude configs whose provider is absent, not just disabled ones. --- codersdk/toolsdk/chats.go | 8 ++++++-- codersdk/toolsdk/chats_test.go | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/codersdk/toolsdk/chats.go b/codersdk/toolsdk/chats.go index 3e7abdb729d..013f3f4362e 100644 --- a/codersdk/toolsdk/chats.go +++ b/codersdk/toolsdk/chats.go @@ -490,10 +490,11 @@ Per-user provider credentials are validated when creating a chat, so coder_creat // Admin model lists include configs backed by disabled providers. // Filter them with provider state; non-admin lists are already // filtered server-side. - providerEnabled := map[uuid.UUID]bool{} + var providerEnabled map[uuid.UUID]bool providers, err := deps.coderClient.AIProviders(ctx) switch { case err == nil: + providerEnabled = make(map[uuid.UUID]bool, len(providers)) for _, provider := range providers { providerEnabled[provider.ID] = provider.Enabled } @@ -518,7 +519,10 @@ Per-user provider credentials are validated when creating a chat, so coder_creat if !config.Enabled { continue } - if enabled, ok := providerEnabled[config.AIProviderID]; ok && !enabled { + // A non-nil map is the authoritative provider set: absent IDs + // are soft-deleted providers whose configs stay listed by the + // admin endpoint, so exclude them along with disabled ones. + if providerEnabled != nil && !providerEnabled[config.AIProviderID] { continue } summaries = append(summaries, ChatModelConfigSummary{ diff --git a/codersdk/toolsdk/chats_test.go b/codersdk/toolsdk/chats_test.go index 1dcb41c394e..6c2a1b55e2d 100644 --- a/codersdk/toolsdk/chats_test.go +++ b/codersdk/toolsdk/chats_test.go @@ -185,6 +185,23 @@ func TestChatTools(t *testing.T) { require.Contains(t, ids, defaultModelConfig.ID.String()) }) + t.Run("ListChatModelConfigsSkipsDeletedProviders", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + deletedProviderConfig := coderdtest.CreateOpenAICompatChatModelConfig(t, expClient, chattest.OpenAI(t)) + err := client.DeleteAIProvider(ctx, deletedProviderConfig.AIProviderID.String()) + require.NoError(t, err) + + result, err := testTool(t, toolsdk.ListChatModelConfigs, tb, toolsdk.NoArgs{}) + require.NoError(t, err) + var ids []string + for _, config := range result.ModelConfigs { + ids = append(ids, config.ID) + } + require.NotContains(t, ids, deletedProviderConfig.ID.String()) + require.Contains(t, ids, defaultModelConfig.ID.String()) + }) + t.Run("Interrupt", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) From d17c9f701b9d8f5053bede97ee6486b9071ee67d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:09:31 +0000 Subject: [PATCH 11/14] feat: expose MCP prompts for Coder Agents chat workflows Add coder_agents_delegate and coder_agents_check prompt templates per the MCP prompts spec (2026-07-28). Definitions live in the toolsdk so the coderd-hosted and CLI stdio MCP servers adapt one shared list, the same way tools flow through toolsdk.All. The stdio server registers them only when the chat tools they reference are available. --- cli/exp_mcp.go | 32 +++++++++ cli/exp_mcp_test.go | 67 ++++++++++++++++++ coderd/mcp/mcp.go | 31 +++++++++ coderd/mcp/mcp_e2e_test.go | 30 +++++++++ coderd/mcp_http.go | 1 + codersdk/toolsdk/prompts.go | 112 +++++++++++++++++++++++++++++++ codersdk/toolsdk/prompts_test.go | 73 ++++++++++++++++++++ docs/ai-coder/mcp-server.md | 12 ++++ 8 files changed, 358 insertions(+) create mode 100644 codersdk/toolsdk/prompts.go create mode 100644 codersdk/toolsdk/prompts_test.go diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index 6d72439b8cb..c3170243aa8 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -754,6 +754,14 @@ func (s *mcpServer) startServer(ctx context.Context, inv *serpent.Invocation, in mcpSrv.AddTools(mcpFromSDK(tool, toolDeps)) } + // The prompts guide the use of the chat tools, so register them only + // when those tools are available. + if s.client != nil && (len(allowedTools) == 0 || slices.Contains(allowedTools, toolsdk.ToolNameCreateChat)) { + for _, prompt := range toolsdk.AllPrompts { + mcpSrv.AddPrompts(mcpPromptFromSDK(prompt)) + } + } + srv := server.NewStdioServer(mcpSrv) done := make(chan error) go func() { @@ -1026,3 +1034,27 @@ func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool }, } } + +// mcpPromptFromSDK adapts a toolsdk.Prompt to go-mcp's server.ServerPrompt. +func mcpPromptFromSDK(sdkPrompt toolsdk.Prompt) server.ServerPrompt { + opts := []mcp.PromptOption{mcp.WithPromptDescription(sdkPrompt.Description)} + for _, arg := range sdkPrompt.Arguments { + argOpts := []mcp.ArgumentOption{mcp.ArgumentDescription(arg.Description)} + if arg.Required { + argOpts = append(argOpts, mcp.RequiredArgument()) + } + opts = append(opts, mcp.WithArgument(arg.Name, argOpts...)) + } + return server.ServerPrompt{ + Prompt: mcp.NewPrompt(sdkPrompt.Name, opts...), + Handler: func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + text, err := sdkPrompt.Render(request.Params.Arguments) + if err != nil { + return nil, err + } + return mcp.NewGetPromptResult(sdkPrompt.Description, []mcp.PromptMessage{ + mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(text)), + }), nil + }, + } +} diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go index 14989943d23..b4bb3affbf2 100644 --- a/cli/exp_mcp_test.go +++ b/cli/exp_mcp_test.go @@ -22,6 +22,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/toolsdk" "github.com/coder/coder/v2/testutil" "github.com/coder/coder/v2/testutil/expecter" ) @@ -115,6 +116,72 @@ func TestExpMcpServer(t *testing.T) { <-cmdDone }) + t.Run("Prompts", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := testutil.Logger(t) + cancelCtx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + inv, root := clitest.New(t, "exp", "mcp", "server") + inv = inv.WithContext(cancelCtx) + + var stdout *expecter.Expecter + stdout, inv.Stdout = expecter.NewPiped(t) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) + clitest.SetupConfig(t, client, root) + + cmdDone := make(chan struct{}) + go func() { + defer close(cmdDone) + err := inv.Run() + assert.NoError(t, err) + }() + + stdin.WriteLine(`{"jsonrpc":"2.0","id":1,"method":"prompts/list"}`) + output := stdout.ReadLine(ctx) + var listResponse struct { + Result struct { + Prompts []struct { + Name string `json:"name"` + } `json:"prompts"` + } `json:"result"` + } + err := json.Unmarshal([]byte(output), &listResponse) + require.NoError(t, err) + foundPrompts := make([]string, 0, len(listResponse.Result.Prompts)) + for _, prompt := range listResponse.Result.Prompts { + foundPrompts = append(foundPrompts, prompt.Name) + } + for _, prompt := range toolsdk.AllPrompts { + require.Contains(t, foundPrompts, prompt.Name) + } + + stdin.WriteLine(`{"jsonrpc":"2.0","id":2,"method":"prompts/get","params":{"name":"coder_agents_delegate","arguments":{"task":"Fix the flaky test."}}}`) + output = stdout.ReadLine(ctx) + cancel() + <-cmdDone + + var getResponse struct { + Result struct { + Messages []struct { + Role string `json:"role"` + Content struct { + Text string `json:"text"` + } `json:"content"` + } `json:"messages"` + } `json:"result"` + } + err = json.Unmarshal([]byte(output), &getResponse) + require.NoError(t, err) + require.Len(t, getResponse.Result.Messages, 1) + require.Equal(t, "user", getResponse.Result.Messages[0].Role) + require.Contains(t, getResponse.Result.Messages[0].Content.Text, "Fix the flaky test.") + }) + t.Run("OK", func(t *testing.T) { t.Parallel() diff --git a/coderd/mcp/mcp.go b/coderd/mcp/mcp.go index 59cd6566f14..4492cd0e230 100644 --- a/coderd/mcp/mcp.go +++ b/coderd/mcp/mcp.go @@ -96,6 +96,13 @@ func (s *Server) RegisterTools(client *codersdk.Client, opts ...func(*toolsdk.De return nil } +// RegisterPrompts registers all MCP prompt templates with the server. +func (s *Server) RegisterPrompts() { + for _, prompt := range toolsdk.AllPrompts { + s.mcpServer.AddPrompts(mcpPromptFromSDK(prompt)) + } +} + // ChatGPT tools are the search and fetch tools as defined in https://platform.openai.com/docs/mcp. // We do not expose any extra ones because ChatGPT has an undocumented "Safety Scan" feature. // In my experiments, if I included extra tools in the MCP server, ChatGPT would often - but not always - @@ -161,6 +168,30 @@ func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool } } +// mcpPromptFromSDK adapts a toolsdk.Prompt to go-mcp's server.ServerPrompt. +func mcpPromptFromSDK(sdkPrompt toolsdk.Prompt) server.ServerPrompt { + opts := []mcp.PromptOption{mcp.WithPromptDescription(sdkPrompt.Description)} + for _, arg := range sdkPrompt.Arguments { + argOpts := []mcp.ArgumentOption{mcp.ArgumentDescription(arg.Description)} + if arg.Required { + argOpts = append(argOpts, mcp.RequiredArgument()) + } + opts = append(opts, mcp.WithArgument(arg.Name, argOpts...)) + } + return server.ServerPrompt{ + Prompt: mcp.NewPrompt(sdkPrompt.Name, opts...), + Handler: func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + text, err := sdkPrompt.Render(request.Params.Arguments) + if err != nil { + return nil, err + } + return mcp.NewGetPromptResult(sdkPrompt.Description, []mcp.PromptMessage{ + mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(text)), + }), nil + }, + } +} + // mcpLoggerAdapter adapts slog.Logger to the mcp-go util.Logger interface type mcpLoggerAdapter struct { logger slog.Logger diff --git a/coderd/mcp/mcp_e2e_test.go b/coderd/mcp/mcp_e2e_test.go index ab48450a07f..f43c4e287e7 100644 --- a/coderd/mcp/mcp_e2e_test.go +++ b/coderd/mcp/mcp_e2e_test.go @@ -115,6 +115,36 @@ func TestMCPHTTP_E2E_ClientIntegration(t *testing.T) { // Check for some basic tools that should be available assert.Contains(t, foundTools, toolsdk.ToolNameGetAuthenticatedUser, "Should have authenticated user tool") + + // The standard toolset should also expose the prompt templates. + prompts, err := mcpClient.ListPrompts(ctx, mcp.ListPromptsRequest{}) + require.NoError(t, err) + var foundPrompts []string + for _, prompt := range prompts.Prompts { + foundPrompts = append(foundPrompts, prompt.Name) + } + for _, prompt := range toolsdk.AllPrompts { + require.Contains(t, foundPrompts, prompt.Name) + } + + promptResult, err := mcpClient.GetPrompt(ctx, mcp.GetPromptRequest{ + Params: mcp.GetPromptParams{ + Name: toolsdk.PromptNameAgentsDelegate, + Arguments: map[string]string{"task": "Fix the flaky test."}, + }, + }) + require.NoError(t, err) + require.Len(t, promptResult.Messages, 1) + require.Equal(t, mcp.RoleUser, promptResult.Messages[0].Role) + promptText, ok := promptResult.Messages[0].Content.(mcp.TextContent) + require.True(t, ok) + require.Contains(t, promptText.Text, "Fix the flaky test.") + require.Contains(t, promptText.Text, toolsdk.ToolNameCreateChat) + + _, err = mcpClient.GetPrompt(ctx, mcp.GetPromptRequest{ + Params: mcp.GetPromptParams{Name: toolsdk.PromptNameAgentsDelegate}, + }) + require.ErrorContains(t, err, "missing required prompt argument: task") require.NotNil(t, userTool) require.NotNil(t, writeFileTool) require.NotNil(t, userTool.Annotations.ReadOnlyHint) diff --git a/coderd/mcp_http.go b/coderd/mcp_http.go index 6d0dd39784e..0c0fb757654 100644 --- a/coderd/mcp_http.go +++ b/coderd/mcp_http.go @@ -80,6 +80,7 @@ func (api *API) mcpHTTPHandler() http.Handler { if err := mcpServer.RegisterTools(authenticatedClient, toolOpt); err != nil { api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err)) } + mcpServer.RegisterPrompts() case MCPToolsetChatGPT: if err := mcpServer.RegisterChatGPTTools(authenticatedClient, toolOpt); err != nil { api.Logger.Warn(r.Context(), "failed to register MCP tools", slog.Error(err)) diff --git a/codersdk/toolsdk/prompts.go b/codersdk/toolsdk/prompts.go new file mode 100644 index 00000000000..de0277fa549 --- /dev/null +++ b/codersdk/toolsdk/prompts.go @@ -0,0 +1,112 @@ +package toolsdk + +import ( + "fmt" + "strings" + + "golang.org/x/xerrors" +) + +const ( + PromptNameAgentsDelegate = "coder_agents_delegate" + PromptNameAgentsCheck = "coder_agents_check" +) + +// PromptArgument describes one argument accepted by a Prompt. +type PromptArgument struct { + Name string + Description string + Required bool +} + +// Prompt is a user-selectable MCP prompt template, defined here so the +// coderd-hosted and CLI MCP servers can adapt one shared definition into +// their protocol types, mirroring how tools flow through All. +// See https://modelcontextprotocol.io/specification/2026-07-28/server/prompts. +type Prompt struct { + Name string + Description string + Arguments []PromptArgument + + // Render returns the user-message text for a prompts/get request. + Render func(args map[string]string) (string, error) +} + +// AllPrompts is the canonical list of MCP prompts exposed by Coder MCP +// servers. +var AllPrompts = []Prompt{AgentsDelegate, AgentsCheck} + +var AgentsDelegate = Prompt{ + Name: PromptNameAgentsDelegate, + Description: "Delegate a coding task to a Coder Agents chat and monitor it to completion.", + Arguments: []PromptArgument{ + { + Name: "task", + Description: "The task the Coder Agent should perform, including all context it needs.", + Required: true, + }, + { + Name: "model_config_id", + Description: "Optional model config UUID for the chat. When omitted, a model is picked from " + ToolNameListChatModelConfigs + ".", + }, + }, + Render: func(args map[string]string) (string, error) { + task, err := requiredPromptArg(args, "task") + if err != nil { + return "", err + } + var createStep string + if modelConfigID := strings.TrimSpace(args["model_config_id"]); modelConfigID != "" { + createStep = fmt.Sprintf("1. Call %s with the task above as the prompt and model_config_id %q.", ToolNameCreateChat, modelConfigID) + } else { + createStep = fmt.Sprintf("1. Call %s with the task above as the prompt. To pick a specific model, call %s first and pass its ID as model_config_id.", ToolNameCreateChat, ToolNameListChatModelConfigs) + } + return fmt.Sprintf(`Delegate the following task to a Coder Agent and see it through to completion. + + +%s + + +Follow these steps: +%s +2. Share the returned chat URL with the user right away so they can follow along. +3. Poll %s until the chat stops running, waiting between polls. +4. Read the transcript with %s; page older history with before_id while has_more is true. +5. If the agent needs input or the result needs iteration, reply with %s and keep monitoring. +6. Report the outcome to the user, including the chat URL and a summary of what the agent did. +`, task, createStep, ToolNameGetChat, ToolNameGetChatMessages, ToolNameSendChatMessage), nil + }, +} + +var AgentsCheck = Prompt{ + Name: PromptNameAgentsCheck, + Description: "Check the status and recent activity of an existing Coder Agents chat.", + Arguments: []PromptArgument{ + { + Name: "chat_id", + Description: "UUID of the Coder Agents chat to check.", + Required: true, + }, + }, + Render: func(args map[string]string) (string, error) { + chatID, err := requiredPromptArg(args, "chat_id") + if err != nil { + return "", err + } + return fmt.Sprintf(`Check on the Coder Agents chat %q and report back. + +Follow these steps: +1. Call %s with the chat_id to get its status, last turn summary, and any last error. +2. Call %s with the chat_id for recent transcript context, including queued_messages. +3. Summarize for the user: what the agent is doing or has done, whether it is blocked or waiting for input, and any errors. Include the chat URL. +`, chatID, ToolNameGetChat, ToolNameGetChatMessages), nil + }, +} + +func requiredPromptArg(args map[string]string, name string) (string, error) { + value := strings.TrimSpace(args[name]) + if value == "" { + return "", xerrors.Errorf("missing required prompt argument: %s", name) + } + return value, nil +} diff --git a/codersdk/toolsdk/prompts_test.go b/codersdk/toolsdk/prompts_test.go new file mode 100644 index 00000000000..eb5dfc9887d --- /dev/null +++ b/codersdk/toolsdk/prompts_test.go @@ -0,0 +1,73 @@ +package toolsdk_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk/toolsdk" +) + +func TestChatPrompts(t *testing.T) { + t.Parallel() + + t.Run("Metadata", func(t *testing.T) { + t.Parallel() + names := map[string]bool{} + for _, prompt := range toolsdk.AllPrompts { + require.NotEmpty(t, prompt.Name) + require.NotEmpty(t, prompt.Description) + require.NotNil(t, prompt.Render) + require.False(t, names[prompt.Name], "duplicate prompt name %q", prompt.Name) + names[prompt.Name] = true + for _, arg := range prompt.Arguments { + require.NotEmpty(t, arg.Name) + require.NotEmpty(t, arg.Description) + } + } + }) + + t.Run("DelegateRequiresTask", func(t *testing.T) { + t.Parallel() + _, err := toolsdk.AgentsDelegate.Render(nil) + require.ErrorContains(t, err, "missing required prompt argument: task") + _, err = toolsdk.AgentsDelegate.Render(map[string]string{"task": " "}) + require.ErrorContains(t, err, "missing required prompt argument: task") + }) + + t.Run("Delegate", func(t *testing.T) { + t.Parallel() + text, err := toolsdk.AgentsDelegate.Render(map[string]string{"task": "Fix the flaky test."}) + require.NoError(t, err) + require.Contains(t, text, "Fix the flaky test.") + require.Contains(t, text, toolsdk.ToolNameCreateChat) + require.Contains(t, text, toolsdk.ToolNameListChatModelConfigs) + require.Contains(t, text, toolsdk.ToolNameSendChatMessage) + }) + + t.Run("DelegateWithModelConfig", func(t *testing.T) { + t.Parallel() + text, err := toolsdk.AgentsDelegate.Render(map[string]string{ + "task": "Fix the flaky test.", + "model_config_id": "a2913789-b213-45e3-9d18-561fbb1ec97c", + }) + require.NoError(t, err) + require.Contains(t, text, "a2913789-b213-45e3-9d18-561fbb1ec97c") + require.NotContains(t, text, toolsdk.ToolNameListChatModelConfigs) + }) + + t.Run("CheckRequiresChatID", func(t *testing.T) { + t.Parallel() + _, err := toolsdk.AgentsCheck.Render(map[string]string{}) + require.ErrorContains(t, err, "missing required prompt argument: chat_id") + }) + + t.Run("Check", func(t *testing.T) { + t.Parallel() + text, err := toolsdk.AgentsCheck.Render(map[string]string{"chat_id": "0bb52d1a-e239-4e7a-ae2a-5abbd7fbf9b5"}) + require.NoError(t, err) + require.Contains(t, text, "0bb52d1a-e239-4e7a-ae2a-5abbd7fbf9b5") + require.Contains(t, text, toolsdk.ToolNameGetChat) + require.Contains(t, text, toolsdk.ToolNameGetChatMessages) + }) +} diff --git a/docs/ai-coder/mcp-server.md b/docs/ai-coder/mcp-server.md index 506ad943c12..e811250d408 100644 --- a/docs/ai-coder/mcp-server.md +++ b/docs/ai-coder/mcp-server.md @@ -197,6 +197,18 @@ arguments, is defined in Coder's [`toolsdk` package](../../codersdk/toolsdk/toolsdk.go). Refer to it for the current list, since the available tools can change between releases. +## Available Prompts + +The MCP server also exposes +[prompts](https://modelcontextprotocol.io/specification/2026-07-28/server/prompts) +for common Coder Agents chat workflows. Clients that support prompts surface +them for you to invoke, for example as slash commands: + +- `coder_agents_delegate`: delegate a coding task to a Coder Agents chat and + monitor it to completion +- `coder_agents_check`: check the status and recent activity of an existing + Coder Agents chat + ## Troubleshooting ### "Unauthorized" errors From 20d8555167fb29df40f8a353f43e8239c5482863 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:34:34 +0000 Subject: [PATCH 12/14] chore: comment cleanup --- cli/exp_mcp.go | 3 --- coderd/mcp/mcp.go | 1 - coderd/mcp/mcp_e2e_test.go | 1 - codersdk/toolsdk/chats.go | 27 +++++++++------------------ codersdk/toolsdk/chats_test.go | 8 -------- codersdk/toolsdk/prompts.go | 5 +---- 6 files changed, 10 insertions(+), 35 deletions(-) diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index c3170243aa8..6bbec0ec6b9 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -754,8 +754,6 @@ func (s *mcpServer) startServer(ctx context.Context, inv *serpent.Invocation, in mcpSrv.AddTools(mcpFromSDK(tool, toolDeps)) } - // The prompts guide the use of the chat tools, so register them only - // when those tools are available. if s.client != nil && (len(allowedTools) == 0 || slices.Contains(allowedTools, toolsdk.ToolNameCreateChat)) { for _, prompt := range toolsdk.AllPrompts { mcpSrv.AddPrompts(mcpPromptFromSDK(prompt)) @@ -1035,7 +1033,6 @@ func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool } } -// mcpPromptFromSDK adapts a toolsdk.Prompt to go-mcp's server.ServerPrompt. func mcpPromptFromSDK(sdkPrompt toolsdk.Prompt) server.ServerPrompt { opts := []mcp.PromptOption{mcp.WithPromptDescription(sdkPrompt.Description)} for _, arg := range sdkPrompt.Arguments { diff --git a/coderd/mcp/mcp.go b/coderd/mcp/mcp.go index 4492cd0e230..d1cb732e442 100644 --- a/coderd/mcp/mcp.go +++ b/coderd/mcp/mcp.go @@ -168,7 +168,6 @@ func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool } } -// mcpPromptFromSDK adapts a toolsdk.Prompt to go-mcp's server.ServerPrompt. func mcpPromptFromSDK(sdkPrompt toolsdk.Prompt) server.ServerPrompt { opts := []mcp.PromptOption{mcp.WithPromptDescription(sdkPrompt.Description)} for _, arg := range sdkPrompt.Arguments { diff --git a/coderd/mcp/mcp_e2e_test.go b/coderd/mcp/mcp_e2e_test.go index f43c4e287e7..f2ef31045e3 100644 --- a/coderd/mcp/mcp_e2e_test.go +++ b/coderd/mcp/mcp_e2e_test.go @@ -116,7 +116,6 @@ func TestMCPHTTP_E2E_ClientIntegration(t *testing.T) { // Check for some basic tools that should be available assert.Contains(t, foundTools, toolsdk.ToolNameGetAuthenticatedUser, "Should have authenticated user tool") - // The standard toolset should also expose the prompt templates. prompts, err := mcpClient.ListPrompts(ctx, mcp.ListPromptsRequest{}) require.NoError(t, err) var foundPrompts []string diff --git a/codersdk/toolsdk/chats.go b/codersdk/toolsdk/chats.go index 013f3f4362e..786d0eac59a 100644 --- a/codersdk/toolsdk/chats.go +++ b/codersdk/toolsdk/chats.go @@ -129,8 +129,7 @@ The chat runs asynchronously. Poll coder_get_chat for status and read the transc if err != nil { return ChatToolStatus{}, err } - // A user's only membership can be removed by an admin, so an - // empty slice is reachable and indexing it would panic. + // Admins can remove a user's only organization membership. if len(me.OrganizationIDs) == 0 { return ChatToolStatus{}, xerrors.New("authenticated user belongs to no organization; pass organization_id explicitly") } @@ -212,14 +211,11 @@ type GetChatMessagesResponse struct { // true. It is derived from the unfiltered API page, so it stays valid // even when every message in this page was filtered out as non-text. NextBeforeID int64 `json:"next_before_id,omitempty"` - // QueuedMessages holds prompts waiting for the current run to finish. - // The API returns them only on the initial page (no cursor). + // QueuedMessages is populated only on the initial page. QueuedMessages []string `json:"queued_messages,omitempty"` } -// userFacingText concatenates the user-facing text parts of a message. -// Hook notices are user-facing per the SDK part contract, unlike hook -// context, which is model-only. +// Hook notices are user-facing per the SDK contract; hook context is model-only. func userFacingText(parts []codersdk.ChatMessagePart) string { var texts []string for _, part := range parts { @@ -487,9 +483,8 @@ Per-user provider credentials are validated when creating a chat, so coder_creat if err != nil { return ListChatModelConfigsResponse{}, xerrors.Errorf("list chat model configs: %w", err) } - // Admin model lists include configs backed by disabled providers. - // Filter them with provider state; non-admin lists are already - // filtered server-side. + // Admin model lists include disabled providers; non-admin lists are + // already filtered server-side. var providerEnabled map[uuid.UUID]bool providers, err := deps.coderClient.AIProviders(ctx) switch { @@ -499,11 +494,8 @@ Per-user provider credentials are validated when creating a chat, so coder_creat providerEnabled[provider.ID] = provider.Enabled } case isForbiddenError(err): - // Deployment-config readers without AI provider read access - // (such as auditors) receive the unverifiable admin list, so - // fail closed instead of leaking provider-disabled configs. - // Only a confirmed 403 selects the member path, whose list - // the server already filtered. + // Deployment-config readers can receive the unfiltered admin list + // without provider access, so fail closed unless both requests return 403. _, dcErr := deps.coderClient.DeploymentConfig(ctx) switch { case dcErr == nil: @@ -519,9 +511,8 @@ Per-user provider credentials are validated when creating a chat, so coder_creat if !config.Enabled { continue } - // A non-nil map is the authoritative provider set: absent IDs - // are soft-deleted providers whose configs stay listed by the - // admin endpoint, so exclude them along with disabled ones. + // A non-nil map is authoritative because soft-deleted providers are + // absent while their configs remain in the admin response. if providerEnabled != nil && !providerEnabled[config.AIProviderID] { continue } diff --git a/codersdk/toolsdk/chats_test.go b/codersdk/toolsdk/chats_test.go index 6c2a1b55e2d..3d94666cb14 100644 --- a/codersdk/toolsdk/chats_test.go +++ b/codersdk/toolsdk/chats_test.go @@ -21,7 +21,6 @@ import ( "github.com/coder/coder/v2/testutil" ) -// failPathTransport fails requests to one path and passes the rest through. type failPathTransport struct { path string } @@ -226,8 +225,6 @@ func TestChatTools(t *testing.T) { require.NoError(t, err) require.Equal(t, codersdk.ChatStatusRunning, created.Status) - // While the run is blocked, a queued send must surface in the - // transcript's queued_messages rather than disappearing. sent, err := testTool(t, toolsdk.SendChatMessage, tb, toolsdk.SendChatMessageArgs{ ChatID: created.ID, Text: "Queued while busy.", @@ -247,9 +244,6 @@ func TestChatTools(t *testing.T) { }) t.Run("ListChatModelConfigsMemberAndAuditor", func(t *testing.T) { - // A member cannot read providers but gets the server-filtered - // list; an auditor gets the unverifiable admin list and must - // fail closed rather than leak provider-disabled configs. memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) memberDeps, err := toolsdk.NewDeps(memberClient) require.NoError(t, err) @@ -267,8 +261,6 @@ func TestChatTools(t *testing.T) { _, err = testTool(t, toolsdk.ListChatModelConfigs, auditorDeps, toolsdk.NoArgs{}) require.ErrorContains(t, err, "missing AI provider read permission") - // A failing deployment-config probe must propagate, not fall - // through to the fail-open member path. brokenProbeClient := codersdk.New(auditorClient.URL) brokenProbeClient.SetSessionToken(auditorClient.SessionToken()) brokenProbeClient.HTTPClient = &http.Client{ diff --git a/codersdk/toolsdk/prompts.go b/codersdk/toolsdk/prompts.go index de0277fa549..3db5b0d7cf6 100644 --- a/codersdk/toolsdk/prompts.go +++ b/codersdk/toolsdk/prompts.go @@ -19,16 +19,13 @@ type PromptArgument struct { Required bool } -// Prompt is a user-selectable MCP prompt template, defined here so the -// coderd-hosted and CLI MCP servers can adapt one shared definition into -// their protocol types, mirroring how tools flow through All. +// Prompt defines an MCP prompt shared by the HTTP and CLI servers. // See https://modelcontextprotocol.io/specification/2026-07-28/server/prompts. type Prompt struct { Name string Description string Arguments []PromptArgument - // Render returns the user-message text for a prompts/get request. Render func(args map[string]string) (string, error) } From 17cedd44faf0d1db7b2810aa816d7c2048d1a87f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:43:35 +0000 Subject: [PATCH 13/14] fix(cli): register MCP prompts only when their required tools are available A restricted --allowed-tools list could advertise prompts whose workflow tools were not registered. Each prompt now declares its RequiredTools and the stdio server skips prompts with missing tools. --- cli/exp_mcp.go | 13 ++++++++++--- cli/exp_mcp_test.go | 14 ++++++++++++++ codersdk/toolsdk/prompts.go | 16 ++++++++++++++++ codersdk/toolsdk/prompts_test.go | 19 ++++++++++++++----- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index 6bbec0ec6b9..99fe7df0fe0 100644 --- a/cli/exp_mcp.go +++ b/cli/exp_mcp.go @@ -731,6 +731,7 @@ func (s *mcpServer) startServer(ctx context.Context, inv *serpent.Invocation, in } // Register tools based on the allowlist. Zero length means allow everything. + registeredTools := make(map[string]bool, len(toolsdk.All)) for _, tool := range toolsdk.All { // Skip if not allowed. if len(allowedTools) > 0 && !slices.ContainsFunc(allowedTools, func(t string) bool { @@ -752,12 +753,18 @@ func (s *mcpServer) startServer(ctx context.Context, inv *serpent.Invocation, in } mcpSrv.AddTools(mcpFromSDK(tool, toolDeps)) + registeredTools[tool.Tool.Name] = true } - if s.client != nil && (len(allowedTools) == 0 || slices.Contains(allowedTools, toolsdk.ToolNameCreateChat)) { - for _, prompt := range toolsdk.AllPrompts { - mcpSrv.AddPrompts(mcpPromptFromSDK(prompt)) + // Skip prompts whose referenced tools are unavailable so clients are + // not offered workflows they cannot run. + for _, prompt := range toolsdk.AllPrompts { + if slices.ContainsFunc(prompt.RequiredTools, func(name string) bool { + return !registeredTools[name] + }) { + continue } + mcpSrv.AddPrompts(mcpPromptFromSDK(prompt)) } srv := server.NewStdioServer(mcpSrv) diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go index b4bb3affbf2..a3a847f547b 100644 --- a/cli/exp_mcp_test.go +++ b/cli/exp_mcp_test.go @@ -102,6 +102,20 @@ func TestExpMcpServer(t *testing.T) { assert.True(t, *annotations.IdempotentHint) assert.False(t, *annotations.OpenWorldHint) + // Prompts reference chat tools, which are excluded by this + // allowlist, so none may be advertised. With no prompts + // registered the server rejects prompts/list entirely. + stdin.WriteLine(`{"jsonrpc":"2.0","id":5,"method":"prompts/list"}`) + promptsOutput := stdout.ReadLine(ctx) + var promptsResponse struct { + Error *struct { + Code int `json:"code"` + } `json:"error"` + } + err = json.Unmarshal([]byte(promptsOutput), &promptsResponse) + require.NoError(t, err) + require.NotNil(t, promptsResponse.Error, "prompts/list should fail when no prompts are registered") + // Call the tool and ensure it works. toolPayload := `{"jsonrpc":"2.0","id":3,"method":"tools/call", "params": {"name": "coder_get_authenticated_user", "arguments": {}}}` stdin.WriteLine(toolPayload) diff --git a/codersdk/toolsdk/prompts.go b/codersdk/toolsdk/prompts.go index 3db5b0d7cf6..02e2099e8ad 100644 --- a/codersdk/toolsdk/prompts.go +++ b/codersdk/toolsdk/prompts.go @@ -26,6 +26,11 @@ type Prompt struct { Description string Arguments []PromptArgument + // RequiredTools lists the tools the rendered prompt instructs the + // client to call. Servers with a restricted tool set should skip + // prompts whose required tools are unavailable. + RequiredTools []string + Render func(args map[string]string) (string, error) } @@ -36,6 +41,13 @@ var AllPrompts = []Prompt{AgentsDelegate, AgentsCheck} var AgentsDelegate = Prompt{ Name: PromptNameAgentsDelegate, Description: "Delegate a coding task to a Coder Agents chat and monitor it to completion.", + RequiredTools: []string{ + ToolNameCreateChat, + ToolNameGetChat, + ToolNameGetChatMessages, + ToolNameSendChatMessage, + ToolNameListChatModelConfigs, + }, Arguments: []PromptArgument{ { Name: "task", @@ -78,6 +90,10 @@ Follow these steps: var AgentsCheck = Prompt{ Name: PromptNameAgentsCheck, Description: "Check the status and recent activity of an existing Coder Agents chat.", + RequiredTools: []string{ + ToolNameGetChat, + ToolNameGetChatMessages, + }, Arguments: []PromptArgument{ { Name: "chat_id", diff --git a/codersdk/toolsdk/prompts_test.go b/codersdk/toolsdk/prompts_test.go index eb5dfc9887d..8eb9656e1c8 100644 --- a/codersdk/toolsdk/prompts_test.go +++ b/codersdk/toolsdk/prompts_test.go @@ -18,6 +18,14 @@ func TestChatPrompts(t *testing.T) { require.NotEmpty(t, prompt.Name) require.NotEmpty(t, prompt.Description) require.NotNil(t, prompt.Render) + require.NotEmpty(t, prompt.RequiredTools) + toolNames := make(map[string]bool, len(toolsdk.All)) + for _, tool := range toolsdk.All { + toolNames[tool.Name] = true + } + for _, name := range prompt.RequiredTools { + require.True(t, toolNames[name], "prompt %q requires unknown tool %q", prompt.Name, name) + } require.False(t, names[prompt.Name], "duplicate prompt name %q", prompt.Name) names[prompt.Name] = true for _, arg := range prompt.Arguments { @@ -40,9 +48,9 @@ func TestChatPrompts(t *testing.T) { text, err := toolsdk.AgentsDelegate.Render(map[string]string{"task": "Fix the flaky test."}) require.NoError(t, err) require.Contains(t, text, "Fix the flaky test.") - require.Contains(t, text, toolsdk.ToolNameCreateChat) - require.Contains(t, text, toolsdk.ToolNameListChatModelConfigs) - require.Contains(t, text, toolsdk.ToolNameSendChatMessage) + for _, tool := range toolsdk.AgentsDelegate.RequiredTools { + require.Contains(t, text, tool) + } }) t.Run("DelegateWithModelConfig", func(t *testing.T) { @@ -67,7 +75,8 @@ func TestChatPrompts(t *testing.T) { text, err := toolsdk.AgentsCheck.Render(map[string]string{"chat_id": "0bb52d1a-e239-4e7a-ae2a-5abbd7fbf9b5"}) require.NoError(t, err) require.Contains(t, text, "0bb52d1a-e239-4e7a-ae2a-5abbd7fbf9b5") - require.Contains(t, text, toolsdk.ToolNameGetChat) - require.Contains(t, text, toolsdk.ToolNameGetChatMessages) + for _, tool := range toolsdk.AgentsCheck.RequiredTools { + require.Contains(t, text, tool) + } }) } From 87646cfdf7ed967050edd860b5c3b8e73818699b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:58:30 +0000 Subject: [PATCH 14/14] fix(codersdk/toolsdk): drop optional model-list tool from delegate prompt requirements The delegate workflow only suggests coder_list_chat_model_configs as an optional step and omits it entirely when model_config_id is supplied, so requiring it suppressed the prompt for valid restricted allowlists. --- cli/exp_mcp_test.go | 50 +++++++++++++++++++++++++++++++++++++ codersdk/toolsdk/prompts.go | 8 +++--- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/cli/exp_mcp_test.go b/cli/exp_mcp_test.go index a3a847f547b..1b3d2cb380a 100644 --- a/cli/exp_mcp_test.go +++ b/cli/exp_mcp_test.go @@ -130,6 +130,56 @@ func TestExpMcpServer(t *testing.T) { <-cmdDone }) + t.Run("PromptsPartialAllowlist", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := testutil.Logger(t) + cancelCtx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + // The model-list tool is an optional suggestion in the delegate + // workflow, so its absence must not suppress the prompt. + inv, root := clitest.New(t, "exp", "mcp", "server", + "--allowed-tools=coder_create_chat,coder_get_chat,coder_get_chat_messages,coder_send_chat_message") + inv = inv.WithContext(cancelCtx) + + var stdout *expecter.Expecter + stdout, inv.Stdout = expecter.NewPiped(t) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) + clitest.SetupConfig(t, client, root) + + cmdDone := make(chan struct{}) + go func() { + defer close(cmdDone) + err := inv.Run() + assert.NoError(t, err) + }() + + stdin.WriteLine(`{"jsonrpc":"2.0","id":1,"method":"prompts/list"}`) + output := stdout.ReadLine(ctx) + cancel() + <-cmdDone + + var listResponse struct { + Result struct { + Prompts []struct { + Name string `json:"name"` + } `json:"prompts"` + } `json:"result"` + } + err := json.Unmarshal([]byte(output), &listResponse) + require.NoError(t, err) + foundPrompts := make([]string, 0, len(listResponse.Result.Prompts)) + for _, prompt := range listResponse.Result.Prompts { + foundPrompts = append(foundPrompts, prompt.Name) + } + require.Contains(t, foundPrompts, toolsdk.PromptNameAgentsDelegate) + require.Contains(t, foundPrompts, toolsdk.PromptNameAgentsCheck) + }) + t.Run("Prompts", func(t *testing.T) { t.Parallel() diff --git a/codersdk/toolsdk/prompts.go b/codersdk/toolsdk/prompts.go index 02e2099e8ad..e839e662082 100644 --- a/codersdk/toolsdk/prompts.go +++ b/codersdk/toolsdk/prompts.go @@ -26,9 +26,10 @@ type Prompt struct { Description string Arguments []PromptArgument - // RequiredTools lists the tools the rendered prompt instructs the - // client to call. Servers with a restricted tool set should skip - // prompts whose required tools are unavailable. + // RequiredTools lists the tools the rendered workflow cannot run + // without; optional suggestions are excluded. Servers with a + // restricted tool set should skip prompts whose required tools are + // unavailable. RequiredTools []string Render func(args map[string]string) (string, error) @@ -46,7 +47,6 @@ var AgentsDelegate = Prompt{ ToolNameGetChat, ToolNameGetChatMessages, ToolNameSendChatMessage, - ToolNameListChatModelConfigs, }, Arguments: []PromptArgument{ {