mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user