feat: add Coder Agents chat tools to the MCP toolsdk (#28025)

Exposes the experimental Coder Agents chats API through the MCP tool
registry, so MCP clients (the hosted `/api/experimental/mcp/http` server
and `coder exp mcp server`) can start and drive server-side coding
agents.

New tools in `codersdk/toolsdk`, all thin wrappers over existing
`codersdk.ExperimentalClient` methods:

| Tool | Wraps |
|---|---|
| `coder_create_chat` | `CreateChat` (prompt, optional org, model
config, labels) |
| `coder_get_chat` | `GetChat` (status, last error, last turn summary,
workspace, files) |
| `coder_get_chat_messages` | `GetChatMessages` (user-facing parts,
chronological, cursor pagination, queued prompts) |
| `coder_send_chat_message` | `CreateChatMessage` (queue or interrupt
busy behavior) |
| `coder_interrupt_chat` | `InterruptChat` |
| `coder_archive_chat` | `UpdateChat` with `archived: true` |
| `coder_list_chat_model_configs` | `ListChatModelConfigs` (enabled
configs with default flag) |

Both MCP servers register tools from `toolsdk.All`, so no additional
wiring is needed. Responses are trimmed to what an MCP caller needs (IDs
as strings, user-facing transcripts) rather than full SDK payloads. No
new endpoints and no database changes.

Also adds MCP
[prompts](https://modelcontextprotocol.io/specification/2026-07-28/server/prompts)
for the chat workflows, defined once in `codersdk/toolsdk` and
registered by both servers:

| Prompt | Purpose |
|---|---|
| `coder_agents_delegate` | delegate a task to a Coder Agents chat and
monitor it to completion |
| `coder_agents_check` | check the status and recent activity of an
existing chat |

Each prompt declares the tools its workflow needs; the stdio server
skips prompts whose tools are excluded by `--allowed-tools`.

Tests run the tools against a chat-enabled coderdtest instance (fake
OpenAI-compatible provider plus in-process AI bridge), covering the full
lifecycle, an interrupt against a blocked turn, pagination cursors,
permission-dependent model config filtering, and argument validation.
Prompt coverage spans SDK rendering, the hosted
`prompts/list`/`prompts/get` round trip, and the stdio server including
allowlist gating.

> Mux created this PR on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-08-13 18:32:47 +02:00
committed by GitHub
parent bca5d72c1c
commit 8d4d0b35dd
11 changed files with 1290 additions and 0 deletions
+13
View File
@@ -732,6 +732,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 {
@@ -753,6 +754,18 @@ func (s *mcpServer) startServer(ctx context.Context, inv *serpent.Invocation, in
}
coderdmcp.RegisterSDKTool(mcpSrv, 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
}
coderdmcp.RegisterSDKPrompt(mcpSrv, prompt)
}
done := make(chan error)
+144
View File
@@ -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"
)
@@ -107,6 +108,21 @@ 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.
stdin.WriteLine(`{"jsonrpc":"2.0","id":5,"method":"prompts/list"}`)
promptsOutput := stdout.ReadLine(ctx)
var promptsResponse struct {
Result struct {
Prompts []struct {
Name string `json:"name"`
} `json:"prompts"`
} `json:"result"`
}
err = json.Unmarshal([]byte(promptsOutput), &promptsResponse)
require.NoError(t, err)
require.Empty(t, promptsResponse.Result.Prompts, "no prompts should be advertised when their tools are excluded")
// 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)
@@ -121,6 +137,134 @@ 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)
}()
// The SDK server enforces the MCP lifecycle, so complete the
// initialize handshake before listing prompts.
stdin.WriteLine(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`)
_ = stdout.ReadLine(ctx)
stdin.WriteLine(`{"jsonrpc":"2.0","method":"notifications/initialized"}`)
stdin.WriteLine(`{"jsonrpc":"2.0","id":2,"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)
}()
// The SDK server enforces the MCP lifecycle, so complete the
// initialize handshake before listing prompts.
stdin.WriteLine(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`)
_ = stdout.ReadLine(ctx)
stdin.WriteLine(`{"jsonrpc":"2.0","method":"notifications/initialized"}`)
stdin.WriteLine(`{"jsonrpc":"2.0","id":2,"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":3,"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()