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
7 changed files with 291 additions and 35 deletions

View file

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

View file

@ -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<TestServerHttp> {
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<InspectorClient> {
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();
});
});

View file

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

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()) {