diff --git a/README.md b/README.md index f9bf664d..645665e3 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ npm run test-servers:build # (from clients/web) → tsc -p test-servers, emits The Vite alias `@modelcontextprotocol/inspector-test-server` (in `clients/web/vite.config.ts`) points at `test-servers/build/index.js` so `getTestMcpServerPath()` resolves to a real `.js` path. +A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler` — set `transport.modern` in the JSON config (`true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict), or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`. + ## Building ```bash diff --git a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts new file mode 100644 index 00000000..69eb84c9 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { + eraToVersionNegotiation, + MODERN_PROTOCOL_VERSION, +} from "@inspector/core/mcp/types.js"; +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, + createEchoTool, +} from "@modelcontextprotocol/inspector-test-server"; +import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server"; +import type { ContentBlock } from "@modelcontextprotocol/client"; + +/** + * Live coverage of the modern (2026-07-28) connection path (#1700). The bundled + * 2025 test servers negotiate legacy only; the `modern` preset here mounts the + * SDK's `createMcpHandler`, so an SDK v2 client negotiating + * `protocolEra: "auto" | "modern"` reaches the modern leg end-to-end. This is + * the live counterpart to the `auto → legacy` (stdio) test in + * `inspectorClient.test.ts`. + */ +describe("modern-era negotiation (2026-07-28)", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // Ignore disconnect errors + } + client = null; + } + if (server) { + try { + await server.stop(); + } catch { + // Ignore server stop errors + } + server = null; + } + }); + + async function startServer( + modern: ServerConfig["modern"] = {}, + ): Promise { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("modern-era-test", "1.0.0"), + tools: [createEchoTool()], + modern, + }); + await started.start(); + server = started; + return started; + } + + async function connectWithEra( + url: string, + era: "legacy" | "auto" | "modern", + ): Promise { + const connected = new InspectorClient( + { type: "streamable-http", url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation(era), + }, + ); + await connected.connect(); + client = connected; + return connected; + } + + it("negotiates the modern era under 'auto' with a populated discover result", async () => { + const started = await startServer(); + const connected = await connectWithEra(started.url, "auto"); + + expect(connected.getProtocolEra()).toBe("modern"); + expect(connected.getProtocolVersion()).toBe(MODERN_PROTOCOL_VERSION); + + // Unlike a legacy server, a modern server answers server/discover, so the + // discover result is populated (supported versions + capabilities). + const discover = connected.getDiscoverResult(); + expect(discover).toBeDefined(); + expect(discover?.supportedVersions).toContain(MODERN_PROTOCOL_VERSION); + }); + + it("negotiates the modern era under a 'modern' pin", async () => { + const started = await startServer(); + const connected = await connectWithEra(started.url, "modern"); + + expect(connected.getProtocolEra()).toBe("modern"); + expect(connected.getProtocolVersion()).toBe(MODERN_PROTOCOL_VERSION); + expect(connected.getDiscoverResult()?.supportedVersions).toContain( + MODERN_PROTOCOL_VERSION, + ); + }); + + it("still connects a legacy client via the stateless fallback (dual-era)", async () => { + const started = await startServer({ legacy: "stateless" }); + const connected = await connectWithEra(started.url, "legacy"); + + // A plain 2025 initialize handshake is routed to the stateless legacy leg, + // so the client connects and reports the legacy era. + expect(connected.getProtocolEra()).toBe("legacy"); + expect(connected.getDiscoverResult()).toBeUndefined(); + expect((await connected.listTools()).tools.length).toBeGreaterThan(0); + }); + + it("exercises the modern surface after an 'auto' connect (tools/call)", async () => { + const started = await startServer(); + const connected = await connectWithEra(started.url, "auto"); + + const { tools } = await connected.listTools(); + const echo = tools.find((t) => t.name === "echo"); + expect(echo).toBeDefined(); + + const result = await connected.callTool(echo!, { message: "hi" }); + expect(result.success).toBe(true); + const content = result.result!.content as ContentBlock[]; + expect(content[0]).toHaveProperty("type", "text"); + expect("text" in content[0] && content[0].text).toContain("hi"); + }); + + it("rejects a legacy client against a strict modern-only server", async () => { + const started = await startServer({ legacy: "reject" }); + const failing = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("legacy"), + }, + ); + client = failing; + await expect(failing.connect()).rejects.toThrow(); + }); +}); diff --git a/test-servers/configs/modern-http.json b/test-servers/configs/modern-http.json new file mode 100644 index 00000000..d33dc2a3 --- /dev/null +++ b/test-servers/configs/modern-http.json @@ -0,0 +1,12 @@ +{ + "serverInfo": { + "name": "composable-modern", + "version": "1.0.0" + }, + "tools": [{ "preset": "echo" }, { "preset": "get_temp" }], + "transport": { + "type": "streamable-http", + "port": 3100, + "modern": true + } +} diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 44ac2fe6..1ad9ceb9 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -522,6 +522,27 @@ export interface ServerConfig { */ supportRefreshTokens?: boolean; }; + /** + * Serve the modern (2026-07-28) protocol era via the SDK's + * `createMcpHandler` instead of the 2025 session-based streamable transport. + * Only meaningful for `serverType: "streamable-http"` (the modern model is + * HTTP-centric: per-request POST + `server/discover`). + * + * When present, the HTTP server mounts `createMcpHandler(factory, { legacy })` + * so an SDK v2 client negotiating `protocolEra: "auto" | "modern"` reaches the + * modern leg end-to-end (populated `server/discover` result, sessionless). + * + * - `legacy: "stateless"` (default) — dual-era: modern envelope requests use + * the modern leg; legacy-classified requests (e.g. a plain `initialize` + * handshake) fall back to per-request stateless 2025 serving, so a + * `protocolEra: "legacy"` client still connects. + * - `legacy: "reject"` — modern-only strict: legacy-classified requests get + * the unsupported-protocol-version error, exercising the pin-failure and + * `server/discover` surfaces. + */ + modern?: { + legacy?: "stateless" | "reject"; + }; /** * Optional server control for orderly shutdown (test HTTP server). * When present, progress-sending tools check isClosing() before sending and skip/break if closing. diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 7ae9f4c4..fd176365 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -66,6 +66,13 @@ export interface ConfigFile { transport: { type: "stdio" | "streamable-http" | "sse"; port?: number; + /** + * Serve the modern (2026-07-28) protocol era via the SDK's + * `createMcpHandler` (only valid with `type: "streamable-http"`). `true` + * is shorthand for dual-era stateless serving; the object form selects the + * legacy-fallback posture. See {@link ServerConfig.modern}. + */ + modern?: boolean | { legacy?: "stateless" | "reject" }; }; } @@ -121,12 +128,20 @@ function validateConfig( `Invalid config in ${filePath}: transport.type is required`, ); } - const transportType = (o.transport as Record).type as string; + const transport = o.transport as Record; + const transportType = transport.type as string; if (!["stdio", "streamable-http", "sse"].includes(transportType)) { throw new Error( `Invalid config in ${filePath}: transport.type must be stdio, streamable-http, or sse`, ); } + // Only reject *enabling* modern on a non-HTTP transport; a falsy `modern` + // (e.g. `false`) is a no-op that `resolveConfig` normalizes away. + if (transport.modern && transportType !== "streamable-http") { + throw new Error( + `Invalid config in ${filePath}: transport.modern requires transport.type "streamable-http"`, + ); + } if (o.oauth && typeof o.oauth === "object") { const oauth = o.oauth as Record; @@ -160,10 +175,7 @@ function validateConfig( } } } - if ( - (transportType === "stdio") && - oauth.enabled === true - ) { + if (transportType === "stdio" && oauth.enabled === true) { throw new Error( `Invalid config in ${filePath}: oauth requires streamable-http or sse transport`, ); diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts index 331bb549..06b6f7a8 100644 --- a/test-servers/src/resolve-config.ts +++ b/test-servers/src/resolve-config.ts @@ -96,6 +96,13 @@ export function resolveConfig(config: ConfigFile): ServerConfig { port: isHttp ? transport.port : undefined, }; + // Normalize the modern flag: `true` is shorthand for the default (dual-era + // stateless) posture; the object form passes through. + if (transport.modern !== undefined) { + serverConfig.modern = + transport.modern === true ? {} : transport.modern || undefined; + } + if (config.oauth) { const { issuerUrl, ...oauthRest } = config.oauth; serverConfig.oauth = { diff --git a/test-servers/src/test-server-http.ts b/test-servers/src/test-server-http.ts index 2bcadbf0..000b5813 100644 --- a/test-servers/src/test-server-http.ts +++ b/test-servers/src/test-server-http.ts @@ -1,8 +1,12 @@ import { McpServer, WebStandardStreamableHTTPServerTransport, + createMcpHandler, +} from "@modelcontextprotocol/server"; +import type { + JSONRPCMessage, + McpHttpHandler, } from "@modelcontextprotocol/server"; -import type { JSONRPCMessage } from "@modelcontextprotocol/server"; import { createMcpServer } from "./test-server-fixtures.js"; import { SSEServerTransport } from "@modelcontextprotocol/server-legacy/sse"; import type { Request, Response } from "express"; @@ -138,6 +142,8 @@ export class TestServerHttp { private currentLogLevel: string | null = null; /** One McpServer per connection (SSE and streamable-http both use this; SDK allows only one transport per server) */ private mcpServersBySession?: Map; + /** Modern (2026-07-28) fetch handler, present only when `config.modern` is set. */ + private modernHandler?: McpHttpHandler; constructor(config: ServerConfig) { this.config = config; @@ -230,6 +236,73 @@ export class TestServerHttp { } } + /** + * Start the underlying HTTP server listening on localhost, resolving with the + * assigned port. Localhost-only to avoid macOS firewall prompts; port 0 lets + * the OS assign an available port. + */ + private listen(port: number): Promise { + return new Promise((resolve, reject) => { + this.httpServer!.listen(port, "127.0.0.1", () => { + const address = this.httpServer!.address(); + const assignedPort = + typeof address === "object" && address !== null ? address.port : port; + this.baseUrl = `http://localhost:${assignedPort}`; + resolve(assignedPort); + }); + this.httpServer!.on("error", reject); + }); + } + + /** + * Mount the modern (2026-07-28) leg via the SDK's `createMcpHandler`. Unlike + * the 2025 session model, serving is per-request and stateless: every method + * routes through one web-standard `fetch` handler, which classifies each + * request (modern envelope vs. legacy) and serves it — no `Mcp-Session-Id` + * bookkeeping. The same factory backs both eras, so `legacy: "stateless"` + * still answers a plain `initialize` handshake. + * + * Message recording (`setupMessageInterception`) is not wired here: the + * modern handler owns per-request transports internally and exposes no + * `onmessage` seam. Tests that need recorded traffic use the 2025 path. + */ + private async startModernHttp( + app: express.Express, + mcpMiddleware: express.RequestHandler[], + port: number, + ): Promise { + const handler = createMcpHandler( + () => createMcpServer(this.configWithCallback), + { legacy: this.config.modern?.legacy ?? "stateless" }, + ); + this.modernHandler = handler; + + const route = async (req: Request, res: Response): Promise => { + if (res.headersSent) { + return; + } + this.currentRequestHeaders = extractHeaders(req); + try { + const webResponse = await handler.fetch(toWebRequest(req), { + parsedBody: req.body, + }); + await writeWebResponse(res, webResponse); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }); + } + } + }; + + app.post("/mcp", ...mcpMiddleware, route); + app.get("/mcp", ...mcpMiddleware, route); + app.delete("/mcp", ...mcpMiddleware, route); + + return this.listen(port); + } + private async startHttp(port: number): Promise { const app = express(); app.use(express.json()); @@ -243,11 +316,6 @@ export class TestServerHttp { setupOAuthRoutes(app, this.config.oauth); } - // Store transports and one McpServer per session (SDK allows only one transport per server) - const transports: Map = - new Map(); - this.mcpServersBySession = new Map(); - // Bearer token middleware for MCP routes if requireAuth const mcpMiddleware: express.RequestHandler[] = []; if (this.config.oauth?.enabled && this.config.oauth.requireAuth) { @@ -258,6 +326,16 @@ export class TestServerHttp { } } + // Modern (2026-07-28) serving replaces the 2025 session model entirely. + if (this.config.modern) { + return this.startModernHttp(app, mcpMiddleware, port); + } + + // Store transports and one McpServer per session (SDK allows only one transport per server) + const transports: Map = + new Map(); + this.mcpServersBySession = new Map(); + // Set up Express route to handle MCP requests app.post("/mcp", ...mcpMiddleware, async (req: Request, res: Response) => { // If middleware already sent a response (401), don't continue @@ -364,18 +442,7 @@ export class TestServerHttp { } }); - // Start listening on localhost only to avoid macOS firewall prompts - // Use port 0 to let the OS assign an available port if no port was specified - return new Promise((resolve, reject) => { - this.httpServer!.listen(port, "127.0.0.1", () => { - const address = this.httpServer!.address(); - const assignedPort = - typeof address === "object" && address !== null ? address.port : port; - this.baseUrl = `http://localhost:${assignedPort}`; - resolve(assignedPort); - }); - this.httpServer!.on("error", reject); - }); + return this.listen(port); } private async startSse(port: number): Promise { @@ -459,18 +526,7 @@ export class TestServerHttp { } }); - // Start listening on localhost only to avoid macOS firewall prompts - // Use port 0 to let the OS assign an available port if no port was specified - return new Promise((resolve, reject) => { - this.httpServer!.listen(port, "127.0.0.1", () => { - const address = this.httpServer!.address(); - const assignedPort = - typeof address === "object" && address !== null ? address.port : port; - this.baseUrl = `http://localhost:${assignedPort}`; - resolve(assignedPort); - }); - this.httpServer!.on("error", reject); - }); + return this.listen(port); } /** @@ -478,6 +534,12 @@ export class TestServerHttp { */ async stop(): Promise { this._closing = true; + // Tear down the modern leg (aborts in-flight modern exchanges and closes + // their per-request instances) when it was mounted. + if (this.modernHandler) { + await this.modernHandler.close(); + this.modernHandler = undefined; + } // Close all per-connection McpServers (SSE and streamable-http both use the map) if (this.mcpServersBySession) { for (const mcp of this.mcpServersBySession.values()) {