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.
This commit is contained in:
Kyle Carberry
2026-03-26 19:57:02 +00:00
committed by GitHub
parent 02b58534a0
commit 0f86c4237e
12 changed files with 1459 additions and 0 deletions
@@ -69,6 +69,21 @@ func (mr *MockAgentConnMockRecorder) AwaitReachable(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AwaitReachable", reflect.TypeOf((*MockAgentConn)(nil).AwaitReachable), ctx)
}
// CallMCPTool mocks base method.
func (m *MockAgentConn) CallMCPTool(ctx context.Context, req workspacesdk.CallMCPToolRequest) (workspacesdk.CallMCPToolResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CallMCPTool", ctx, req)
ret0, _ := ret[0].(workspacesdk.CallMCPToolResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CallMCPTool indicates an expected call of CallMCPTool.
func (mr *MockAgentConnMockRecorder) CallMCPTool(ctx, req any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CallMCPTool", reflect.TypeOf((*MockAgentConn)(nil).CallMCPTool), ctx, req)
}
// Close mocks base method.
func (m *MockAgentConn) Close() error {
m.ctrl.T.Helper()
@@ -245,6 +260,21 @@ func (mr *MockAgentConnMockRecorder) ListContainers(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListContainers", reflect.TypeOf((*MockAgentConn)(nil).ListContainers), ctx)
}
// ListMCPTools mocks base method.
func (m *MockAgentConn) ListMCPTools(ctx context.Context) (workspacesdk.ListMCPToolsResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListMCPTools", ctx)
ret0, _ := ret[0].(workspacesdk.ListMCPToolsResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListMCPTools indicates an expected call of ListMCPTools.
func (mr *MockAgentConnMockRecorder) ListMCPTools(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListMCPTools", reflect.TypeOf((*MockAgentConn)(nil).ListMCPTools), ctx)
}
// ListProcesses mocks base method.
func (m *MockAgentConn) ListProcesses(ctx context.Context) (workspacesdk.ListProcessesResponse, error) {
m.ctrl.T.Helper()