diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go index 6d72439b8cb..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,6 +753,18 @@ func (s *mcpServer) startServer(ctx context.Context, inv *serpent.Invocation, in } mcpSrv.AddTools(mcpFromSDK(tool, toolDeps)) + registeredTools[tool.Tool.Name] = true + } + + // 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) @@ -1026,3 +1039,26 @@ func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool }, } } + +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..1b3d2cb380a 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" ) @@ -101,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) @@ -115,6 +130,122 @@ 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() + + 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..d1cb732e442 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,29 @@ func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool } } +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..f2ef31045e3 100644 --- a/coderd/mcp/mcp_e2e_test.go +++ b/coderd/mcp/mcp_e2e_test.go @@ -115,6 +115,35 @@ 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") + + 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/chats.go b/codersdk/toolsdk/chats.go new file mode 100644 index 00000000000..786d0eac59a --- /dev/null +++ b/codersdk/toolsdk/chats.go @@ -0,0 +1,528 @@ +package toolsdk + +import ( + "context" + "errors" + "fmt" + "net/http" + "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 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") + } + id, err := uuid.Parse(chatID) + if err != nil { + return uuid.Nil, xerrors.New("chat_id must be a valid UUID") + } + return id, nil +} + +type ChatToolFile struct { + ID string `json:"id"` + Name string `json:"name"` + MimeType string `json:"mime_type"` +} + +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, + LastError: chat.LastError, + URL: fmt.Sprintf("%s/agents/%s", deps.ServerURL(), chat.ID), + } + 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 + } + // 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") + } + 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"` + BeforeID int64 `json:"before_id"` +} + +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"` + // 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"` + // QueuedMessages is populated only on the initial page. + QueuedMessages []string `json:"queued_messages,omitempty"` +} + +// 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 { + 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]{ + Tool: aisdk.Tool{ + 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. 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{ + "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).", + }, + "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"}, + }, + }, + MCPAnnotations: mcpReadOnlyAnnotations, + Handler: func(ctx context.Context, deps Deps, args GetChatMessagesArgs) (GetChatMessagesResponse, error) { + chatID, err := parseChatID(args.ChatID) + if err != nil { + return GetChatMessagesResponse{}, err + } + 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 || 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 { + 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] + text := userFacingText(msg.Content) + if text == "" { + continue + } + messages = append(messages, ChatToolMessage{ + ID: msg.ID, + Role: msg.Role, + CreatedAt: msg.CreatedAt, + 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 + for _, msg := range resp.Messages { + if msg.ID < nextBeforeID { + nextBeforeID = msg.ID + } + } + } + return GetChatMessagesResponse{ + Messages: messages, + HasMore: resp.HasMore, + NextBeforeID: nextBeforeID, + QueuedMessages: queued, + }, nil + }, +} + +type SendChatMessageArgs struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + BusyBehavior codersdk.ChatBusyBehavior `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{ + string(codersdk.ChatBusyBehaviorQueue), + string(codersdk.ChatBusyBehaviorInterrupt), + }, + }, + }, + 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 := 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"`) + } + 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 + }, +} + +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. + +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{}, + }, + }, + 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) + } + // 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 { + case err == nil: + providerEnabled = make(map[uuid.UUID]bool, len(providers)) + for _, provider := range providers { + providerEnabled[provider.ID] = provider.Enabled + } + case isForbiddenError(err): + // 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: + 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) + } + summaries := make([]ChatModelConfigSummary, 0, len(configs)) + for _, config := range configs { + if !config.Enabled { + continue + } + // 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 + } + 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..3d94666cb14 --- /dev/null +++ b/codersdk/toolsdk/chats_test.go @@ -0,0 +1,310 @@ +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" + "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" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/toolsdk" + "github.com/coder/coder/v2/testutil" +) + +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. +// 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, + }) + firstUser := 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.") + 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{{ + 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.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.NextBeforeID, + }) + require.NoError(t, err) + require.NotEmpty(t, olderPage.Messages) + for _, msg := range olderPage.Messages { + 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) + 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("ListChatModelConfigsSkipsDisabledProviders", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + 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("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) + + // 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 { + 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) + + 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) + + close(release) + coderdtest.WaitForChatSettled(ctx, t, api, uuid.MustParse(created.ID)) + }) + + t.Run("ListChatModelConfigsMemberAndAuditor", func(t *testing.T) { + 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") + + 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) { + ctx := testutil.Context(t, testutil.WaitLong) + + 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) + _, 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") + + _, 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: 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") + } + }) +} diff --git a/codersdk/toolsdk/prompts.go b/codersdk/toolsdk/prompts.go new file mode 100644 index 00000000000..e839e662082 --- /dev/null +++ b/codersdk/toolsdk/prompts.go @@ -0,0 +1,125 @@ +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 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 + + // 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) +} + +// 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.", + RequiredTools: []string{ + ToolNameCreateChat, + ToolNameGetChat, + ToolNameGetChatMessages, + ToolNameSendChatMessage, + }, + 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.", + RequiredTools: []string{ + ToolNameGetChat, + ToolNameGetChatMessages, + }, + 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..8eb9656e1c8 --- /dev/null +++ b/codersdk/toolsdk/prompts_test.go @@ -0,0 +1,82 @@ +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.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 { + 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.") + for _, tool := range toolsdk.AgentsDelegate.RequiredTools { + require.Contains(t, text, tool) + } + }) + + 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") + for _, tool := range toolsdk.AgentsCheck.RequiredTools { + require.Contains(t, text, tool) + } + }) +} 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..e811250d408 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 @@ -196,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