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
+71
View File
@@ -116,6 +116,11 @@ type Server struct {
// never contend with each other.
chatStreams sync.Map // uuid.UUID -> *chatStreamState
// workspaceMCPToolsCache caches workspace MCP tool definitions
// per chat to avoid re-fetching on every turn. The cache is
// keyed by chat ID and invalidated when the agent changes.
workspaceMCPToolsCache sync.Map // uuid.UUID -> *cachedWorkspaceMCPTools
usageTracker *workspacestats.UsageTracker
clock quartz.Clock
@@ -156,6 +161,13 @@ func (p *Server) chatTemplateAllowlist() map[uuid.UUID]bool {
return m
}
// cachedWorkspaceMCPTools stores workspace MCP tools discovered
// from a workspace agent, keyed by the agent ID that provided them.
type cachedWorkspaceMCPTools struct {
agentID uuid.UUID
tools []workspacesdk.MCPToolInfo
}
type turnWorkspaceContext struct {
server *Server
chatStateMu *sync.Mutex
@@ -2020,6 +2032,7 @@ func (p *Server) getOrCreateStreamState(chatID uuid.UUID) *chatStreamState {
func (p *Server) cleanupStreamIfIdle(chatID uuid.UUID, state *chatStreamState) {
if !state.buffering && len(state.subscribers) == 0 {
p.chatStreams.Delete(chatID)
p.workspaceMCPToolsCache.Delete(chatID)
}
}
@@ -3240,6 +3253,7 @@ func (p *Server) runChat(
resolvedUserPrompt string
mcpTools []fantasy.AgentTool
mcpCleanup func()
workspaceMCPTools []fantasy.AgentTool
)
// Check if instruction files need to be (re-)persisted.
// This happens when no context-file parts exist yet, or when
@@ -3295,6 +3309,62 @@ func (p *Server) runChat(
return nil
})
}
if chat.WorkspaceID.Valid {
g2.Go(func() error {
// Check cache first. On subsequent turns with the same
// agent, reuse cached tools to avoid a round-trip.
if cached, ok := p.workspaceMCPToolsCache.Load(chat.ID); ok {
entry, ok2 := cached.(*cachedWorkspaceMCPTools)
if !ok2 {
return nil
}
// Verify the agent hasn't changed.
if agent, agentErr := workspaceCtx.getWorkspaceAgent(ctx); agentErr == nil && agent.ID == entry.agentID {
for _, t := range entry.tools {
workspaceMCPTools = append(workspaceMCPTools,
chattool.NewWorkspaceMCPTool(t, workspaceCtx.getWorkspaceConn),
)
}
return nil
}
}
// Cache miss or agent changed — fetch fresh tools.
conn, connErr := workspaceCtx.getWorkspaceConn(ctx)
if connErr != nil {
logger.Warn(ctx, "failed to get workspace conn for MCP tools",
slog.Error(connErr))
return nil
}
toolsResp, listErr := conn.ListMCPTools(ctx)
if listErr != nil {
logger.Warn(ctx, "failed to list workspace MCP tools",
slog.Error(listErr))
return nil
}
// Cache the result for subsequent turns. Skip
// caching when the list is empty because the
// agent's MCP Connect may not have finished yet;
// caching an empty list would hide tools
// permanently.
if len(toolsResp.Tools) > 0 {
if agent, agentErr := workspaceCtx.getWorkspaceAgent(ctx); agentErr == nil {
p.workspaceMCPToolsCache.Store(chat.ID, &cachedWorkspaceMCPTools{
agentID: agent.ID,
tools: toolsResp.Tools,
})
}
}
for _, t := range toolsResp.Tools {
workspaceMCPTools = append(workspaceMCPTools,
chattool.NewWorkspaceMCPTool(t, workspaceCtx.getWorkspaceConn),
)
}
return nil
})
}
// All g2 goroutines return nil; error is discarded.
_ = g2.Wait()
if mcpCleanup != nil {
@@ -3713,6 +3783,7 @@ func (p *Server) runChat(
// after the built-in tools so the LLM sees them as
// additional capabilities.
tools = append(tools, mcpTools...)
tools = append(tools, workspaceMCPTools...)
// Build provider-native tools (e.g., web search) based on
// the model configuration.
+10
View File
@@ -1551,6 +1551,10 @@ func TestPersistToolResultWithBinaryData(t *testing.T) {
mockConn.EXPECT().
SetExtraHeaders(gomock.Any()).
AnyTimes()
mockConn.EXPECT().
ListMCPTools(gomock.Any()).
Return(workspacesdk.ListMCPToolsResponse{}, nil).
AnyTimes()
mockConn.EXPECT().
LS(gomock.Any(), gomock.Any(), gomock.Any()).
Return(workspacesdk.LSResponse{}, nil).
@@ -3151,6 +3155,10 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) {
// for the initial screenshot check in the computer use path.
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
mockConn.EXPECT().
ListMCPTools(gomock.Any()).
Return(workspacesdk.ListMCPToolsResponse{}, nil).
AnyTimes()
mockConn.EXPECT().
ExecuteDesktopAction(gomock.Any(), gomock.Any()).
Return(workspacesdk.DesktopActionResponse{
@@ -3595,6 +3603,8 @@ func TestMCPServerToolInvocation(t *testing.T) {
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
mockConn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes()
mockConn.EXPECT().ListMCPTools(gomock.Any()).
Return(workspacesdk.ListMCPToolsResponse{}, nil).AnyTimes()
mockConn.EXPECT().LS(gomock.Any(), gomock.Any(), gomock.Any()).
Return(workspacesdk.LSResponse{}, nil).AnyTimes()
mockConn.EXPECT().ReadFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
+151
View File
@@ -0,0 +1,151 @@
package chattool
import (
"context"
"encoding/base64"
"encoding/json"
"strings"
"charm.land/fantasy"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// WorkspaceMCPTool wraps a single MCP tool discovered in a
// workspace, proxying calls through the workspace agent
// connection. It implements fantasy.AgentTool so it can be
// registered alongside built-in chat tools.
type WorkspaceMCPTool struct {
info fantasy.ToolInfo
getConn func(context.Context) (workspacesdk.AgentConn, error)
providerOpts fantasy.ProviderOptions
}
// NewWorkspaceMCPTool creates a tool wrapper from an MCPToolInfo
// discovered on a workspace agent. Each tool proxies calls back
// through the agent connection.
func NewWorkspaceMCPTool(
tool workspacesdk.MCPToolInfo,
getConn func(context.Context) (workspacesdk.AgentConn, error),
) *WorkspaceMCPTool {
required := tool.Required
if required == nil {
required = []string{}
}
return &WorkspaceMCPTool{
info: fantasy.ToolInfo{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Schema,
Required: required,
Parallel: true,
},
getConn: getConn,
}
}
func (t *WorkspaceMCPTool) Info() fantasy.ToolInfo {
return t.info
}
func (t *WorkspaceMCPTool) Run(
ctx context.Context,
params fantasy.ToolCall,
) (fantasy.ToolResponse, error) {
conn, err := t.getConn(ctx)
if err != nil {
return fantasy.NewTextErrorResponse(
"workspace connection failed: " + err.Error(),
), nil
}
var args map[string]any
if params.Input != "" {
if err := json.Unmarshal(
[]byte(params.Input), &args,
); err != nil {
return fantasy.NewTextErrorResponse(
"invalid JSON input: " + err.Error(),
), nil
}
}
resp, err := conn.CallMCPTool(ctx, workspacesdk.CallMCPToolRequest{
ToolName: t.info.Name,
Arguments: args,
})
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return convertMCPToolResponse(resp), nil
}
func (t *WorkspaceMCPTool) ProviderOptions() fantasy.ProviderOptions {
return t.providerOpts
}
func (t *WorkspaceMCPTool) SetProviderOptions(
opts fantasy.ProviderOptions,
) {
t.providerOpts = opts
}
// convertMCPToolResponse translates a workspace agent MCP tool
// response into a fantasy.ToolResponse. Text content blocks are
// collected and joined; binary content (image/media) is returned
// only when no text is available, matching the mcpclient
// conversion strategy.
func convertMCPToolResponse(
resp workspacesdk.CallMCPToolResponse,
) fantasy.ToolResponse {
var (
textParts []string
binaryResult *fantasy.ToolResponse
)
for _, c := range resp.Content {
switch c.Type {
case "text":
textParts = append(textParts, c.Text)
case "image", "audio":
if c.Data == "" {
continue
}
data, err := base64.StdEncoding.DecodeString(c.Data)
if err != nil {
textParts = append(textParts,
"[binary decode error: "+err.Error()+"]",
)
continue
}
if binaryResult == nil {
r := fantasy.ToolResponse{
Type: c.Type,
Data: data,
MediaType: c.MediaType,
IsError: resp.IsError,
}
binaryResult = &r
}
default:
textParts = append(textParts, c.Text)
}
}
// Prefer text content. Only fall back to binary when no
// text was collected.
if len(textParts) > 0 {
r := fantasy.NewTextResponse(
strings.Join(textParts, "\n"),
)
r.IsError = resp.IsError
return r
}
if binaryResult != nil {
return *binaryResult
}
r := fantasy.NewTextResponse("")
r.IsError = resp.IsError
return r
}