Files
coder/coderd/mcp/mcp.go
T
Atif Ali bd5b62c976 feat: expose MCP tool annotations for tool grouping (#23195)
## Summary
- add shared MCP annotation metadata to toolsdk tools
- emit MCP tool annotations from both coderd and CLI MCP servers
- cover annotation serialization in toolsdk, coderd MCP e2e, and CLI MCP
tests

## Why
- Coder already exposed MCP tools, but it did not populate MCP tool
annotation hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`,
`openWorldHint`).
- Hosts such as Claude Desktop use those hints to classify and group
tools, so without them Coder tools can get lumped together.
- This change adds a shared annotation source in `toolsdk` and has both
MCP servers emit those hints through `mcp.Tool.Annotations`, avoiding
drift between local and remote MCP implementations.

## Testing
- Tested locally on Cladue Desktop and the tools are categorized
correctly.

<table>
<tr>
 <td> Before
 <td> After
<tr>
<td> <img width="613" height="183" alt="image"
src="https://github.com/user-attachments/assets/29d2e3fb-53bc-4ea7-bdb3-f10df4ef996b"
/>
<td> <img width="600" height="457" alt="image"
src="https://github.com/user-attachments/assets/cc384036-c9a7-4db9-9400-43ad51920ff5"
/>
</table>

Note: Done using Coder Agents, reviewed and tested by human locally
2026-03-18 10:21:45 +00:00

176 lines
5.3 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) 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)
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) 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)
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...))
}