Files
coder/agent/x/agentmcp/api.go
T
Kyle Carberry 0f86c4237e feat: add workspace MCP tool discovery and proxying for chat (#23680)
Coder's chat (chatd) can now discover and use MCP servers configured in
a workspace's `.mcp.json` file. This brings project-specific tooling
(GitHub, databases, docs servers, etc.) into the chat without any manual
configuration.

## How it works

The workspace agent reads `.mcp.json` from the workspace directory (same
format Claude Code uses), connects to the declared MCP servers —
spawning child processes for stdio servers and connecting over the
network for HTTP/SSE — and caches their tool lists. Two new agent HTTP
endpoints expose this:

- `GET /api/v0/mcp/tools` returns the cached tool list (supports
`?refresh=true`)
- `POST /api/v0/mcp/call-tool` proxies calls to the correct server

On each chat turn, chatd calls `ListMCPTools` through the existing
`AgentConn` tailnet connection, wraps each tool as a
`fantasy.AgentTool`, and adds them to the LLM's tool set alongside
built-in and admin-configured MCP tools. Tool names are prefixed with
the server name (`github__create_issue`) to avoid collisions.

Failed server connections are logged and skipped — they never block the
agent or break the chat. Child stdio processes are terminated on agent
shutdown.
2026-03-26 19:57:02 +00:00

89 lines
2.2 KiB
Go

package agentmcp
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// API exposes MCP tool discovery and call proxying through the
// agent.
type API struct {
logger slog.Logger
manager *Manager
}
// NewAPI creates a new MCP API handler backed by the given
// manager.
func NewAPI(logger slog.Logger, manager *Manager) *API {
return &API{
logger: logger,
manager: manager,
}
}
// Routes returns the HTTP handler for MCP-related routes.
func (api *API) Routes() http.Handler {
r := chi.NewRouter()
r.Get("/tools", api.handleListTools)
r.Post("/call-tool", api.handleCallTool)
return r
}
// handleListTools returns the cached MCP tool definitions,
// optionally refreshing them first if ?refresh=true is set.
func (api *API) handleListTools(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Allow callers to force a tool re-scan before listing.
if r.URL.Query().Get("refresh") == "true" {
if err := api.manager.RefreshTools(ctx); err != nil {
api.logger.Warn(ctx, "failed to refresh MCP tools", slog.Error(err))
}
}
tools := api.manager.Tools()
// Ensure non-nil so JSON serialization returns [] not null.
if tools == nil {
tools = []workspacesdk.MCPToolInfo{}
}
httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.ListMCPToolsResponse{
Tools: tools,
})
}
// handleCallTool proxies a tool invocation to the appropriate
// MCP server based on the tool name prefix.
func (api *API) handleCallTool(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req workspacesdk.CallMCPToolRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
resp, err := api.manager.CallTool(ctx, req)
if err != nil {
status := http.StatusBadGateway
if errors.Is(err, ErrInvalidToolName) {
status = http.StatusBadRequest
} else if errors.Is(err, ErrUnknownServer) {
status = http.StatusNotFound
}
httpapi.Write(ctx, rw, status, codersdk.Response{
Message: "MCP tool call failed.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}