diff --git a/coderd/mcp/mcp.go b/coderd/mcp/mcp.go index 59cd6566f14d3..622c4fc93d19e 100644 --- a/coderd/mcp/mcp.go +++ b/coderd/mcp/mcp.go @@ -1,19 +1,16 @@ package mcp import ( - "bytes" "context" - "encoding/json" - "fmt" + stdslog "log/slog" "net/http" - "time" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" "golang.org/x/xerrors" "cdr.dev/slog/v3" "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/toolsdk" ) @@ -33,40 +30,48 @@ type Server struct { Logger slog.Logger // mcpServer is the underlying MCP server - mcpServer *server.MCPServer + mcpServer *mcp.Server - // streamableServer handles HTTP transport - streamableServer *server.StreamableHTTPServer + handler http.Handler } // 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), - ) + mcpSrv := mcp.NewServer(&mcp.Implementation{ + Name: MCPServerName, + Version: buildinfo.Version(), + }, &mcp.ServerOptions{ + Instructions: MCPServerInstructions, + Logger: stdslog.New(&slogHandler{logger: logger}), + }) + + // Stateless mode runs each request as its own short-lived session, + // omits Mcp-Session-Id, and answers GET and DELETE with 405, all + // permitted by the Streamable HTTP transport spec. + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return mcpSrv + }, &mcp.StreamableHTTPOptions{ + Stateless: true, + // Use application/json instead of SSE framing because Coder + // tools emit no notifications and do not need a stream. + JSONResponse: true, + // coderd often listens on loopback behind a reverse proxy, + // which trips the SDK's localhost DNS-rebinding check (loopback + // local address with a public Host header). The endpoint's + // bearer authentication is the relevant access control. + DisableLocalhostProtection: true, + }) return &Server{ - Logger: logger, - mcpServer: mcpSrv, - streamableServer: streamableServer, + Logger: logger, + mcpServer: mcpSrv, + handler: handler, }, nil } // ServeHTTP implements http.Handler interface func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - s.streamableServer.ServeHTTP(w, r) + s.handler.ServeHTTP(w, r) } // Register all available MCP tools with the server excluding: @@ -91,7 +96,7 @@ func (s *Server) RegisterTools(client *codersdk.Client, opts ...func(*toolsdk.De continue } - s.mcpServer.AddTools(mcpFromSDK(tool, toolDeps)) + RegisterSDKTool(s.mcpServer, tool, toolDeps) } return nil } @@ -116,60 +121,85 @@ func (s *Server) RegisterChatGPTTools(client *codersdk.Client, opts ...func(*too continue } - s.mcpServer.AddTools(mcpFromSDK(tool, toolDeps)) + RegisterSDKTool(s.mcpServer, 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 { +// RegisterSDKTool registers a [toolsdk.GenericTool] with an MCP server. +func RegisterSDKTool(srv *mcp.Server, sdkTool toolsdk.GenericTool, tb toolsdk.Deps) { 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 - }, + inputSchema := map[string]any{ + "type": "object", + "properties": sdkTool.Schema.Properties, } + if len(sdkTool.Schema.Required) > 0 { + inputSchema["required"] = sdkTool.Schema.Required + } + + srv.AddTool(&mcp.Tool{ + Name: sdkTool.Name, + Description: sdkTool.Description, + InputSchema: inputSchema, + // Set pointer-valued hints even when false so every hint + // remains explicit on the wire. + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: sdkTool.MCPAnnotations.ReadOnlyHint, + DestructiveHint: ptr.Ref(sdkTool.MCPAnnotations.DestructiveHint), + IdempotentHint: sdkTool.MCPAnnotations.IdempotentHint, + OpenWorldHint: ptr.Ref(sdkTool.MCPAnnotations.OpenWorldHint), + }, + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + result, err := sdkTool.Handler(ctx, tb, req.Params.Arguments) + if err != nil { + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(result)}, + }, + }, nil + }) } -// mcpLoggerAdapter adapts slog.Logger to the mcp-go util.Logger interface -type mcpLoggerAdapter struct { +type slogHandler struct { logger slog.Logger } -func (l *mcpLoggerAdapter) Infof(format string, v ...any) { - l.logger.Info(context.Background(), fmt.Sprintf(format, v...)) +// The SDK logs several INFO lines per stateless request (session +// connect/disconnect), so only warnings and errors are forwarded. +func (*slogHandler) Enabled(_ context.Context, level stdslog.Level) bool { + return level >= stdslog.LevelWarn +} + +func (h *slogHandler) Handle(ctx context.Context, record stdslog.Record) error { + fields := make([]slog.Field, 0, record.NumAttrs()) + record.Attrs(func(attr stdslog.Attr) bool { + fields = append(fields, slog.F(attr.Key, attr.Value.Any())) + return true + }) + switch { + case record.Level >= stdslog.LevelError: + h.logger.Error(ctx, record.Message, fields...) + case record.Level >= stdslog.LevelWarn: + h.logger.Warn(ctx, record.Message, fields...) + default: + h.logger.Info(ctx, record.Message, fields...) + } + return nil +} + +func (h *slogHandler) WithAttrs(attrs []stdslog.Attr) stdslog.Handler { + fields := make([]slog.Field, 0, len(attrs)) + for _, attr := range attrs { + fields = append(fields, slog.F(attr.Key, attr.Value.Any())) + } + return &slogHandler{logger: h.logger.With(fields...)} } -func (l *mcpLoggerAdapter) Errorf(format string, v ...any) { - l.logger.Error(context.Background(), fmt.Sprintf(format, v...)) +func (h *slogHandler) WithGroup(string) stdslog.Handler { + return h } diff --git a/coderd/mcp/mcp_test.go b/coderd/mcp/mcp_test.go index b7b5a714780d9..754f1e408c8e9 100644 --- a/coderd/mcp/mcp_test.go +++ b/coderd/mcp/mcp_test.go @@ -5,9 +5,11 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/mark3labs/mcp-go/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -81,9 +83,8 @@ func TestMCPHTTP_InitializeRequest(t *testing.T) { } assert.Equal(t, http.StatusOK, recorder.Code) - // Check that a session ID was returned sessionID := recorder.Header().Get("Mcp-Session-Id") - assert.NotEmpty(t, sessionID) + assert.Empty(t, sessionID) // Parse response var response map[string]any @@ -131,3 +132,85 @@ func TestMCPHTTP_ToolRegistration(t *testing.T) { require.Contains(t, toolNames, toolsdk.ToolNameReportTask, "Should include ReportTask (UserClientOptional)") require.Contains(t, toolNames, toolsdk.ToolNameGetAuthenticatedUser, "Should include GetAuthenticatedUser (requires auth)") } + +func TestMCPHTTP_ModernProtocol(t *testing.T) { + t.Parallel() + + logger := testutil.Logger(t) + + server, err := mcpserver.NewServer(logger) + require.NoError(t, err) + client := codersdk.New(testutil.MustURL(t, "http://not-used")) + err = server.RegisterTools(client) + require.NoError(t, err) + + ts := httptest.NewServer(server) + defer ts.Close() + + ctx := testutil.Context(t, testutil.WaitShort) + mcpClient := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil) + session, err := mcpClient.Connect(ctx, &sdkmcp.StreamableClientTransport{ + Endpoint: ts.URL, + }, nil) + require.NoError(t, err) + defer session.Close() + + init := session.InitializeResult() + require.Equal(t, "2026-07-28", init.ProtocolVersion) + require.Equal(t, mcpserver.MCPServerName, init.ServerInfo.Name) + require.Equal(t, mcpserver.MCPServerInstructions, init.Instructions) + + tools, err := session.ListTools(ctx, nil) + require.NoError(t, err) + require.NotEmpty(t, tools.Tools) +} + +func TestMCPHTTP_UnsupportedProtocolVersion(t *testing.T) { + t.Parallel() + + logger := testutil.Logger(t) + + server, err := mcpserver.NewServer(logger) + require.NoError(t, err) + + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{` + + `"io.modelcontextprotocol/protocolVersion":"2099-01-01",` + + `"io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},` + + `"io.modelcontextprotocol/clientCapabilities":{}}}}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json,text/event-stream") + req.Header.Set("MCP-Protocol-Version", "2099-01-01") + req.Header.Set("Mcp-Method", "tools/list") + + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, req) + + var response struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response), "body: %s", recorder.Body.String()) + require.Equal(t, -32022, response.Error.Code) +} + +func TestMCPHTTP_TransportMethods(t *testing.T) { + t.Parallel() + + logger := testutil.Logger(t) + + server, err := mcpserver.NewServer(logger) + require.NoError(t, err) + + for _, method := range []string{http.MethodGet, http.MethodDelete} { + req := httptest.NewRequest(method, "/", nil) + if method == http.MethodGet { + req.Header.Set("Accept", "text/event-stream") + } + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, req) + require.Equal(t, http.StatusMethodNotAllowed, recorder.Code, "method %s", method) + } +} diff --git a/go.mod b/go.mod index fe7eaed9b7abc..4bc710f935f77 100644 --- a/go.mod +++ b/go.mod @@ -509,10 +509,12 @@ require ( github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/linkdata/deadlock v0.5.5 // indirect github.com/minio/simdjson-go v0.4.5 // indirect github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect github.com/q-uint/parser v0.3.1 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/trailofbits/go-mutexasserts v0.0.0-20250514102930-c1f3d2e37561 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/collector/featuregate v1.51.1-0.20260205185216-81bc641f26c0 // indirect @@ -536,6 +538,7 @@ require ( github.com/go-git/go-git/v5 v5.19.2 github.com/invopop/jsonschema v0.14.0 github.com/mark3labs/mcp-go v0.38.0 + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/nats-io/nats-server/v2 v2.14.2 github.com/nats-io/nats.go v1.52.0 github.com/openai/openai-go/v3 v3.50.0 diff --git a/go.sum b/go.sum index a6ac01ad8b20d..359d125a892ab 100644 --- a/go.sum +++ b/go.sum @@ -667,6 +667,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/nftables v0.2.0 h1:PbJwaBmbVLzpeldoeUKGkE2RjstrjPKMl6oLrfEJ6/8= @@ -927,6 +929,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/mocktools/go-smtp-mock/v2 v2.5.0 h1:0wUW3YhTHUO6SEqWczCHpLynwIfXieGtxpWJa44YVCM= github.com/mocktools/go-smtp-mock/v2 v2.5.0/go.mod h1:h9AOf/IXLSU2m/1u4zsjtOM/WddPwdOUBz56dV9f81M= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -1089,6 +1093,8 @@ github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxo github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=