Files
coder/coderd/mcp/mcp_test.go
T
Michael Suchacz 08a1525f78 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.
2026-08-13 09:50:18 +00:00

217 lines
6.1 KiB
Go

package mcp_test
import (
"bytes"
"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"
mcpserver "github.com/coder/coder/v2/coderd/mcp"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/toolsdk"
"github.com/coder/coder/v2/testutil"
)
func TestMCPServer_Creation(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
server, err := mcpserver.NewServer(logger)
require.NoError(t, err)
require.NotNil(t, server)
}
func TestMCPServer_Handler(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
server, err := mcpserver.NewServer(logger)
require.NoError(t, err)
// Test that server implements http.Handler interface
var handler http.Handler = server
require.NotNil(t, handler)
}
func TestMCPHTTP_InitializeRequest(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
server, err := mcpserver.NewServer(logger)
require.NoError(t, err)
// Use server directly as http.Handler
handler := server
// Create initialize request
initRequest := map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": map[string]any{
"protocolVersion": mcp.LATEST_PROTOCOL_VERSION,
"capabilities": map[string]any{},
"clientInfo": map[string]any{
"name": "test-client",
"version": "1.0.0",
},
},
}
body, err := json.Marshal(initRequest)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json,text/event-stream")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Logf("Response body: %s", recorder.Body.String())
}
assert.Equal(t, http.StatusOK, recorder.Code)
sessionID := recorder.Header().Get("Mcp-Session-Id")
assert.Empty(t, sessionID)
// Parse response
var response map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "2.0", response["jsonrpc"])
assert.Equal(t, float64(1), response["id"])
result, ok := response["result"].(map[string]any)
require.True(t, ok)
assert.Equal(t, mcp.LATEST_PROTOCOL_VERSION, result["protocolVersion"])
assert.Contains(t, result, "capabilities")
assert.Contains(t, result, "serverInfo")
}
func TestMCPHTTP_ToolRegistration(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
server, err := mcpserver.NewServer(logger)
require.NoError(t, err)
// Test registering tools with nil client should return error
err = server.RegisterTools(nil)
require.Error(t, err)
require.Contains(t, err.Error(), "client cannot be nil", "Should reject nil client with appropriate error message")
// Test registering tools with valid client should succeed
client := codersdk.New(testutil.MustURL(t, "http://not-used"))
err = server.RegisterTools(client)
require.NoError(t, err)
// Verify that all expected tools are available in the toolsdk
expectedToolCount := len(toolsdk.All)
require.Greater(t, expectedToolCount, 0, "Should have some tools available")
// Verify specific tools are present by checking tool names
toolNames := make([]string, len(toolsdk.All))
for i, tool := range toolsdk.All {
toolNames[i] = tool.Name
}
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)
}
}