* 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
1 changed files with 291 additions and 35 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue