mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08: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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user