Add modern-era (2026-07-28) test-server preset (#1700) (#1703)

* Add modern-era (2026-07-28) test-server preset (#1700)

The era-aware connection model (#1626, PR #1697) added per-server
`protocolEra` and the era-aware Connection Info UI, but no bundled test
server negotiated the modern era — the modern happy path had only
unit/Storybook fixtures, no live coverage.

Serve the modern (2026-07-28) protocol era from the HTTP test server via
the SDK's `createMcpHandler`. A `modern` flag on `ServerConfig` (and a
`transport.modern` field in the JSON config) mounts the handler with a
dual-era `legacy: "stateless"` fallback by default, or `legacy: "reject"`
for modern-only strict serving. The web-standard `fetch` handler reuses
the existing `toWebRequest`/`writeWebResponse` conversion, so no new
`@modelcontextprotocol/node` dependency is needed.

An SDK v2 client negotiating `protocolEra: "auto" | "modern"` now reaches
the modern leg end-to-end: `getProtocolEra() === "modern"`,
`getNegotiatedProtocolVersion() === "2026-07-28"`, and a populated
`getDiscoverResult()`. Verified live in the Inspector — Connection Info
shows Era Modern / Session Sessionless / the Discovery section.

- Modern serving path + `listen`/`startModernHttp` helpers in
  test-server-http.ts; modern-leg teardown in stop().
- `modern` config field threaded through load-config/resolve-config with
  a streamable-http-only validation guard; modern-http.json demo config.
- Integration test covering auto/modern negotiation, the dual-era legacy
  fallback, a modern tools/call, and the strict `legacy: "reject"`
  rejection.

Closes #1700

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Review nit: gate transport.modern guard on truthiness

A falsy `transport.modern` (e.g. `false`) is a no-op that `resolveConfig`
normalizes to `undefined`, so it shouldn't trip the streamable-http-only
guard. Gate on truthiness instead of `!== undefined` so only *enabling*
modern on a non-HTTP transport is rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-17 11:12:40 -04:00 • committed by GitHub
commit e06c0a45f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 291 additions and 35 deletions

View file

@ -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.

View file

@ -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<string, unknown>).type as string;
const transport = o.transport as Record<string, unknown>;
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<string, unknown>;
@ -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`,
);

View file

@ -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 = {

View file

@ -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<string, McpServer>;
/** 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<number> {
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<number> {
const handler = createMcpHandler(
() => createMcpServer(this.configWithCallback),
{ legacy: this.config.modern?.legacy ?? "stateless" },
);
this.modernHandler = handler;
const route = async (req: Request, res: Response): Promise<void> => {
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<number> {
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<string, WebStandardStreamableHTTPServerTransport> =
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<string, WebStandardStreamableHTTPServerTransport> =
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<number> {
@ -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<void> {
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()) {