* 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:
parent
e026051b47
commit
e06c0a45f7
7 changed files with 291 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
12
test-servers/configs/modern-http.json
Normal file
12
test-servers/configs/modern-http.json
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue