mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -59,6 +59,7 @@ type AgentConn interface {
|
||||
SetExtraHeaders(h http.Header)
|
||||
|
||||
AwaitReachable(ctx context.Context) bool
|
||||
CallMCPTool(ctx context.Context, req CallMCPToolRequest) (CallMCPToolResponse, error)
|
||||
Close() error
|
||||
DebugLogs(ctx context.Context) ([]byte, error)
|
||||
DebugMagicsock(ctx context.Context) ([]byte, error)
|
||||
@@ -66,6 +67,7 @@ type AgentConn interface {
|
||||
DialContext(ctx context.Context, network string, addr string) (net.Conn, error)
|
||||
GetPeerDiagnostics() tailnet.PeerDiagnostics
|
||||
ListContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error)
|
||||
ListMCPTools(ctx context.Context) (ListMCPToolsResponse, error)
|
||||
ListProcesses(ctx context.Context) (ListProcessesResponse, error)
|
||||
ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error)
|
||||
Netcheck(ctx context.Context) (healthsdk.AgentNetcheckReport, error)
|
||||
@@ -923,6 +925,50 @@ type FileEditRequest struct {
|
||||
Files []FileEdits `json:"files"`
|
||||
}
|
||||
|
||||
// ListMCPToolsResponse is the response from the agent's
|
||||
// MCP tool discovery endpoint.
|
||||
type ListMCPToolsResponse struct {
|
||||
Tools []MCPToolInfo `json:"tools"`
|
||||
}
|
||||
|
||||
// MCPToolInfo describes a single tool discovered from an MCP
|
||||
// server configured in the workspace's .mcp.json file.
|
||||
type MCPToolInfo struct {
|
||||
// ServerName is the key from .mcp.json (e.g. "github").
|
||||
ServerName string `json:"server_name"`
|
||||
// Name is the prefixed tool name: "serverName__toolName".
|
||||
Name string `json:"name"`
|
||||
// Description is the tool's human-readable description.
|
||||
Description string `json:"description"`
|
||||
// Schema is the JSON Schema for the tool's input parameters.
|
||||
Schema map[string]any `json:"schema"`
|
||||
// Required lists required parameter names.
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
|
||||
// CallMCPToolRequest is the request body for proxying an MCP
|
||||
// tool call through the workspace agent.
|
||||
type CallMCPToolRequest struct {
|
||||
// ToolName is the prefixed tool name (e.g. "github__create_issue").
|
||||
ToolName string `json:"tool_name"`
|
||||
// Arguments is the tool input as key-value pairs.
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
// CallMCPToolResponse is the response from a proxied MCP tool call.
|
||||
type CallMCPToolResponse struct {
|
||||
Content []MCPToolContent `json:"content"`
|
||||
IsError bool `json:"is_error"`
|
||||
}
|
||||
|
||||
// MCPToolContent is a single content block in an MCP tool response.
|
||||
type MCPToolContent struct {
|
||||
Type string `json:"type"` // "text", "image", "audio", "resource"
|
||||
Text string `json:"text,omitempty"`
|
||||
Data string `json:"data,omitempty"` // base64 for binary
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
}
|
||||
|
||||
// StartProcess starts a new process on the workspace agent.
|
||||
func (c *agentConn) StartProcess(ctx context.Context, req StartProcessRequest) (StartProcessResponse, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
@@ -955,6 +1001,40 @@ func (c *agentConn) ListProcesses(ctx context.Context) (ListProcessesResponse, e
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// ListMCPTools returns tools discovered from MCP servers configured
|
||||
// in the workspace.
|
||||
func (c *agentConn) ListMCPTools(ctx context.Context) (ListMCPToolsResponse, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/mcp/tools", nil)
|
||||
if err != nil {
|
||||
return ListMCPToolsResponse{}, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return ListMCPToolsResponse{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
var resp ListMCPToolsResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// CallMCPTool proxies a tool call to an MCP server running in
|
||||
// the workspace.
|
||||
func (c *agentConn) CallMCPTool(ctx context.Context, req CallMCPToolRequest) (CallMCPToolResponse, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/mcp/call-tool", req)
|
||||
if err != nil {
|
||||
return CallMCPToolResponse{}, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return CallMCPToolResponse{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
var resp CallMCPToolResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// ProcessOutput returns the output of a tracked process on the agent.
|
||||
func (c *agentConn) ProcessOutput(ctx context.Context, id string, opts *ProcessOutputOptions) (ProcessOutputResponse, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user