feat: migrate coderd MCP server to official MCP Go SDK (#28056)

## Stack Context

PR 1 of 6 in a stack that migrates every Coder MCP surface from the
archived `github.com/mark3labs/mcp-go` library to the official
`github.com/modelcontextprotocol/go-sdk` v1.7.0, adding MCP 2026-07-28
support while keeping compatibility with clients speaking 2024-11-05
through 2025-06-18.

Stack: #28056 -> #28057 -> #28058 -> #28059 -> #28060 -> #28061

## Why

The coderd Streamable HTTP MCP server (`/api/experimental/mcp/http`) is
the foundation layer: it introduces the official SDK dependency and the
shared `RegisterSDKTool` helper the CLI server reuses.

- The server runs the SDK handler in stateless mode with `JSONResponse:
true`, preserving the previous `application/json` POST wire format. GET
and DELETE return 405, and no `Mcp-Session-Id` is issued, both permitted
by the Streamable HTTP spec.
- `DisableLocalhostProtection` is set because coderd commonly listens on
loopback behind a reverse proxy with a public Host header; the
endpoint's bearer authentication is the relevant access control.
- Tool registration builds raw JSON object schemas and omits empty
`required`, keeping `tools/list` output byte-identical to the previous
server (verified with a golden comparison).
- SDK logs are adapted to `cdr.dev/slog/v3`; only warnings and errors
are forwarded because the SDK logs several INFO lines per stateless
request.
- Tests cover the modern 2026-07-28 flow, legacy 2025-06-18 initialize,
unsupported protocol version rejection (`-32022`), and non-POST method
behavior.

## Known behavior deltas vs the old endpoint

Both deltas come from the SDK enforcing the Streamable HTTP spec where
mark3labs was lenient, on an experimental endpoint:

- POST requests whose `Accept` header lists `application/json` without
`text/event-stream` are now rejected with 400 (the spec requires clients
to list both; a missing `Accept` header is still tolerated). mark3labs
did not validate `Accept` at all.
- The old server generated an unvalidated `Mcp-Session-Id` response
header; the stateless SDK handler issues none. Clients that merely echo
the header back are unaffected.

## Validation

Beyond unit/integration tests, a remote dogfood UAT ran protocol
conformance against a live dev server built from the stack tip: version
negotiation matrix (2024-11-05 through bogus/omitted values), auth,
session/method semantics, tool schema sanity, tools/call happy and error
paths (unknown tool, schema-violating args, malformed JSON, jsonrpc
"1.0"), and a concurrency smoke test. No 500s or connection drops; error
shapes are clean JSON-RPC/HTTP errors.

> Mux created this PR on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-08-13 09:50:18 +00:00
committed by GitHub
parent d509e1e6a0
commit 08a1525f78
4 changed files with 192 additions and 70 deletions
+98 -68
View File
@@ -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),
)
mcpSrv := mcp.NewServer(&mcp.Implementation{
Name: MCPServerName,
Version: buildinfo.Version(),
}, &mcp.ServerOptions{
Instructions: MCPServerInstructions,
Logger: stdslog.New(&slogHandler{logger: logger}),
})
// 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),
)
// 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 (l *mcpLoggerAdapter) Errorf(format string, v ...any) {
l.logger.Error(context.Background(), fmt.Sprintf(format, v...))
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 (h *slogHandler) WithGroup(string) stdslog.Handler {
return h
}
+85 -2
View File
@@ -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)
}
}
+3
View File
@@ -514,10 +514,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
@@ -541,6 +543,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
+6
View File
@@ -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=