mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
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.
241 lines
7.5 KiB
Go
241 lines
7.5 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
stdslog "log/slog"
|
|
"net/http"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/buildinfo"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/toolsdk"
|
|
)
|
|
|
|
const (
|
|
// MCPServerName is the name used for the MCP server.
|
|
MCPServerName = "Coder"
|
|
// MCPServerInstructions is the instructions text for the MCP server.
|
|
MCPServerInstructions = "Coder MCP Server providing workspace and template management tools"
|
|
|
|
// Used in tests and aibridge.
|
|
MCPEndpoint = "/api/experimental/mcp/http"
|
|
)
|
|
|
|
// Server represents an MCP HTTP server instance
|
|
type Server struct {
|
|
Logger slog.Logger
|
|
|
|
// mcpServer is the underlying MCP server
|
|
mcpServer *mcp.Server
|
|
|
|
handler http.Handler
|
|
}
|
|
|
|
// NewServer creates a new MCP HTTP server
|
|
func NewServer(logger slog.Logger) (*Server, error) {
|
|
mcpSrv := mcp.NewServer(&mcp.Implementation{
|
|
Name: MCPServerName,
|
|
Version: buildinfo.Version(),
|
|
}, &mcp.ServerOptions{
|
|
Instructions: MCPServerInstructions,
|
|
Logger: stdslog.New(&slogHandler{logger: logger}),
|
|
})
|
|
|
|
// Stateless mode runs each request as its own short-lived session,
|
|
// omits Mcp-Session-Id, and answers GET and DELETE with 405, all
|
|
// permitted by the Streamable HTTP transport spec.
|
|
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
|
|
return mcpSrv
|
|
}, &mcp.StreamableHTTPOptions{
|
|
Stateless: true,
|
|
// Use application/json instead of SSE framing because Coder
|
|
// tools emit no notifications and do not need a stream.
|
|
JSONResponse: true,
|
|
// coderd often listens on loopback behind a reverse proxy,
|
|
// which trips the SDK's localhost DNS-rebinding check (loopback
|
|
// local address with a public Host header). The endpoint's
|
|
// bearer authentication is the relevant access control.
|
|
DisableLocalhostProtection: true,
|
|
})
|
|
|
|
return &Server{
|
|
Logger: logger,
|
|
mcpServer: mcpSrv,
|
|
handler: handler,
|
|
}, nil
|
|
}
|
|
|
|
// ServeHTTP implements http.Handler interface
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.handler.ServeHTTP(w, r)
|
|
}
|
|
|
|
// Register all available MCP tools with the server excluding:
|
|
// - ReportTask - which requires dependencies not available in the remote MCP context
|
|
// - ChatGPT search and fetch tools, which are redundant with the standard tools.
|
|
func (s *Server) RegisterTools(client *codersdk.Client, opts ...func(*toolsdk.Deps)) error {
|
|
if client == nil {
|
|
return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client")
|
|
}
|
|
|
|
// Create tool dependencies
|
|
toolDeps, err := toolsdk.NewDeps(client, opts...)
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to initialize tool dependencies: %w", err)
|
|
}
|
|
|
|
for _, tool := range toolsdk.All {
|
|
// the ReportTask tool requires dependencies not available in the remote MCP context
|
|
// the ChatGPT search and fetch tools are redundant with the standard tools.
|
|
if tool.Name == toolsdk.ToolNameReportTask ||
|
|
tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch {
|
|
continue
|
|
}
|
|
|
|
RegisterSDKTool(s.mcpServer, tool, toolDeps)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RegisterPrompts registers all MCP prompt templates with the server.
|
|
func (s *Server) RegisterPrompts() {
|
|
for _, prompt := range toolsdk.AllPrompts {
|
|
RegisterSDKPrompt(s.mcpServer, 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 -
|
|
// refuse to add Coder as a connector.
|
|
func (s *Server) RegisterChatGPTTools(client *codersdk.Client, opts ...func(*toolsdk.Deps)) error {
|
|
if client == nil {
|
|
return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client")
|
|
}
|
|
|
|
// Create tool dependencies
|
|
toolDeps, err := toolsdk.NewDeps(client, opts...)
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to initialize tool dependencies: %w", err)
|
|
}
|
|
|
|
for _, tool := range toolsdk.All {
|
|
if tool.Name != toolsdk.ToolNameChatGPTSearch && tool.Name != toolsdk.ToolNameChatGPTFetch {
|
|
continue
|
|
}
|
|
|
|
RegisterSDKTool(s.mcpServer, tool, toolDeps)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RegisterSDKTool registers a [toolsdk.GenericTool] with an MCP server.
|
|
func RegisterSDKTool(srv *mcp.Server, sdkTool toolsdk.GenericTool, tb toolsdk.Deps) {
|
|
if sdkTool.Schema.Properties == nil {
|
|
panic("developer error: schema properties cannot be nil")
|
|
}
|
|
|
|
inputSchema := map[string]any{
|
|
"type": "object",
|
|
"properties": sdkTool.Schema.Properties,
|
|
}
|
|
if len(sdkTool.Schema.Required) > 0 {
|
|
inputSchema["required"] = sdkTool.Schema.Required
|
|
}
|
|
|
|
srv.AddTool(&mcp.Tool{
|
|
Name: sdkTool.Name,
|
|
Description: sdkTool.Description,
|
|
InputSchema: inputSchema,
|
|
// Set pointer-valued hints even when false so every hint
|
|
// remains explicit on the wire.
|
|
Annotations: &mcp.ToolAnnotations{
|
|
ReadOnlyHint: sdkTool.MCPAnnotations.ReadOnlyHint,
|
|
DestructiveHint: ptr.Ref(sdkTool.MCPAnnotations.DestructiveHint),
|
|
IdempotentHint: sdkTool.MCPAnnotations.IdempotentHint,
|
|
OpenWorldHint: ptr.Ref(sdkTool.MCPAnnotations.OpenWorldHint),
|
|
},
|
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
result, err := sdkTool.Handler(ctx, tb, req.Params.Arguments)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &mcp.CallToolResult{
|
|
Content: []mcp.Content{
|
|
&mcp.TextContent{Text: string(result)},
|
|
},
|
|
}, nil
|
|
})
|
|
}
|
|
|
|
// RegisterSDKPrompt registers a [toolsdk.Prompt] with an MCP server.
|
|
func RegisterSDKPrompt(srv *mcp.Server, sdkPrompt toolsdk.Prompt) {
|
|
args := make([]*mcp.PromptArgument, 0, len(sdkPrompt.Arguments))
|
|
for _, arg := range sdkPrompt.Arguments {
|
|
args = append(args, &mcp.PromptArgument{
|
|
Name: arg.Name,
|
|
Description: arg.Description,
|
|
Required: arg.Required,
|
|
})
|
|
}
|
|
srv.AddPrompt(&mcp.Prompt{
|
|
Name: sdkPrompt.Name,
|
|
Description: sdkPrompt.Description,
|
|
Arguments: args,
|
|
}, func(_ context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
|
|
text, err := sdkPrompt.Render(req.Params.Arguments)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &mcp.GetPromptResult{
|
|
Description: sdkPrompt.Description,
|
|
Messages: []*mcp.PromptMessage{
|
|
{Role: "user", Content: &mcp.TextContent{Text: text}},
|
|
},
|
|
}, nil
|
|
})
|
|
}
|
|
|
|
type slogHandler struct {
|
|
logger slog.Logger
|
|
}
|
|
|
|
// The SDK logs several INFO lines per stateless request (session
|
|
// connect/disconnect), so only warnings and errors are forwarded.
|
|
func (*slogHandler) Enabled(_ context.Context, level stdslog.Level) bool {
|
|
return level >= stdslog.LevelWarn
|
|
}
|
|
|
|
func (h *slogHandler) Handle(ctx context.Context, record stdslog.Record) error {
|
|
fields := make([]slog.Field, 0, record.NumAttrs())
|
|
record.Attrs(func(attr stdslog.Attr) bool {
|
|
fields = append(fields, slog.F(attr.Key, attr.Value.Any()))
|
|
return true
|
|
})
|
|
switch {
|
|
case record.Level >= stdslog.LevelError:
|
|
h.logger.Error(ctx, record.Message, fields...)
|
|
case record.Level >= stdslog.LevelWarn:
|
|
h.logger.Warn(ctx, record.Message, fields...)
|
|
default:
|
|
h.logger.Info(ctx, record.Message, fields...)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *slogHandler) WithAttrs(attrs []stdslog.Attr) stdslog.Handler {
|
|
fields := make([]slog.Field, 0, len(attrs))
|
|
for _, attr := range attrs {
|
|
fields = append(fields, slog.F(attr.Key, attr.Value.Any()))
|
|
}
|
|
return &slogHandler{logger: h.logger.With(fields...)}
|
|
}
|
|
|
|
func (h *slogHandler) WithGroup(string) stdslog.Handler {
|
|
return h
|
|
}
|