mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
181e103201
## Problem Coderd can expose an MCP server at `/api/experimental/mcp/http` (we have this enabled on dogfood). Its workspace tools dialed agents through a per-call client-side tailnet stack. Every tool call re-created a WireGuard device, netstack, magicsock + UDP sockets, DERP connection, coordinator websocket, and their goroutines — in a process that already runs a long-lived shared tailnet. The duplicate stacks drove up resource usage under load. ## Fix Route this server's tool calls through the existing shared tailnet, so none of those transports are reconstructed per call. Closing an `AgentConn` now releases a tunnel reference instead of tearing down a transport. ## Potential follow-up `coder exp mcp server` still builds a fresh tailnet per call. It pays per-call latency and causes coordinator/DERP churn. A shared CLI tailnet is more involved — unlike coderd, the CLI has no existing shared tailnet to reuse, so it would need a new long-lived client-side tailnet with reconnect, sleep/wake, and idle-destination handling. There's less motivation to optimize this, given the client-side MCP does not compete for resources with coderd. Closes CODAGT-199 > Generated by mux, but reviewed by a human
176 lines
5.4 KiB
Go
176 lines
5.4 KiB
Go
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/buildinfo"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/toolsdk"
|
|
)
|
|
|
|
const (
|
|
// MCPServerName is the name used for the MCP server.
|
|
MCPServerName = "Coder"
|
|
// MCPServerInstructions is the instructions text for the MCP server.
|
|
MCPServerInstructions = "Coder MCP Server providing workspace and template management tools"
|
|
|
|
// Used in tests and aibridge.
|
|
MCPEndpoint = "/api/experimental/mcp/http"
|
|
)
|
|
|
|
// Server represents an MCP HTTP server instance
|
|
type Server struct {
|
|
Logger slog.Logger
|
|
|
|
// mcpServer is the underlying MCP server
|
|
mcpServer *server.MCPServer
|
|
|
|
// streamableServer handles HTTP transport
|
|
streamableServer *server.StreamableHTTPServer
|
|
}
|
|
|
|
// NewServer creates a new MCP HTTP server
|
|
func NewServer(logger slog.Logger) (*Server, error) {
|
|
// Create the core MCP server
|
|
mcpSrv := server.NewMCPServer(
|
|
MCPServerName,
|
|
buildinfo.Version(),
|
|
server.WithInstructions(MCPServerInstructions),
|
|
)
|
|
|
|
// Create logger adapter for mcp-go
|
|
mcpLogger := &mcpLoggerAdapter{logger: logger}
|
|
|
|
// Create streamable HTTP server with configuration
|
|
streamableServer := server.NewStreamableHTTPServer(mcpSrv,
|
|
server.WithHeartbeatInterval(30*time.Second),
|
|
server.WithLogger(mcpLogger),
|
|
)
|
|
|
|
return &Server{
|
|
Logger: logger,
|
|
mcpServer: mcpSrv,
|
|
streamableServer: streamableServer,
|
|
}, nil
|
|
}
|
|
|
|
// ServeHTTP implements http.Handler interface
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.streamableServer.ServeHTTP(w, r)
|
|
}
|
|
|
|
// Register all available MCP tools with the server excluding:
|
|
// - ReportTask - which requires dependencies not available in the remote MCP context
|
|
// - ChatGPT search and fetch tools, which are redundant with the standard tools.
|
|
func (s *Server) RegisterTools(client *codersdk.Client, opts ...func(*toolsdk.Deps)) error {
|
|
if client == nil {
|
|
return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client")
|
|
}
|
|
|
|
// Create tool dependencies
|
|
toolDeps, err := toolsdk.NewDeps(client, opts...)
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to initialize tool dependencies: %w", err)
|
|
}
|
|
|
|
for _, tool := range toolsdk.All {
|
|
// the ReportTask tool requires dependencies not available in the remote MCP context
|
|
// the ChatGPT search and fetch tools are redundant with the standard tools.
|
|
if tool.Name == toolsdk.ToolNameReportTask ||
|
|
tool.Name == toolsdk.ToolNameChatGPTSearch || tool.Name == toolsdk.ToolNameChatGPTFetch {
|
|
continue
|
|
}
|
|
|
|
s.mcpServer.AddTools(mcpFromSDK(tool, toolDeps))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ChatGPT tools are the search and fetch tools as defined in https://platform.openai.com/docs/mcp.
|
|
// We do not expose any extra ones because ChatGPT has an undocumented "Safety Scan" feature.
|
|
// In my experiments, if I included extra tools in the MCP server, ChatGPT would often - but not always -
|
|
// refuse to add Coder as a connector.
|
|
func (s *Server) RegisterChatGPTTools(client *codersdk.Client, opts ...func(*toolsdk.Deps)) error {
|
|
if client == nil {
|
|
return xerrors.New("client cannot be nil: MCP HTTP server requires authenticated client")
|
|
}
|
|
|
|
// Create tool dependencies
|
|
toolDeps, err := toolsdk.NewDeps(client, opts...)
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to initialize tool dependencies: %w", err)
|
|
}
|
|
|
|
for _, tool := range toolsdk.All {
|
|
if tool.Name != toolsdk.ToolNameChatGPTSearch && tool.Name != toolsdk.ToolNameChatGPTFetch {
|
|
continue
|
|
}
|
|
|
|
s.mcpServer.AddTools(mcpFromSDK(tool, toolDeps))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mcpFromSDK adapts a toolsdk.Tool to go-mcp's server.ServerTool
|
|
func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool {
|
|
if sdkTool.Schema.Properties == nil {
|
|
panic("developer error: schema properties cannot be nil")
|
|
}
|
|
|
|
return server.ServerTool{
|
|
Tool: mcp.Tool{
|
|
Name: sdkTool.Name,
|
|
Description: sdkTool.Description,
|
|
InputSchema: mcp.ToolInputSchema{
|
|
Type: "object",
|
|
Properties: sdkTool.Schema.Properties,
|
|
Required: sdkTool.Schema.Required,
|
|
},
|
|
Annotations: mcp.ToolAnnotation{
|
|
ReadOnlyHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.ReadOnlyHint),
|
|
DestructiveHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.DestructiveHint),
|
|
IdempotentHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.IdempotentHint),
|
|
OpenWorldHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.OpenWorldHint),
|
|
},
|
|
},
|
|
Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
var buf bytes.Buffer
|
|
if err := json.NewEncoder(&buf).Encode(request.Params.Arguments); err != nil {
|
|
return nil, xerrors.Errorf("failed to encode request arguments: %w", err)
|
|
}
|
|
result, err := sdkTool.Handler(ctx, tb, buf.Bytes())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &mcp.CallToolResult{
|
|
Content: []mcp.Content{
|
|
mcp.NewTextContent(string(result)),
|
|
},
|
|
}, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// mcpLoggerAdapter adapts slog.Logger to the mcp-go util.Logger interface
|
|
type mcpLoggerAdapter struct {
|
|
logger slog.Logger
|
|
}
|
|
|
|
func (l *mcpLoggerAdapter) Infof(format string, v ...any) {
|
|
l.logger.Info(context.Background(), fmt.Sprintf(format, v...))
|
|
}
|
|
|
|
func (l *mcpLoggerAdapter) Errorf(format string, v ...any) {
|
|
l.logger.Error(context.Background(), fmt.Sprintf(format, v...))
|
|
}
|