fix(client): accept the draft protocol version the json-schema-ref-no-deref mock advertises (#398)

The scenario's hand-rolled server/discover handler advertises
supportedVersions: ['2026-07-28'], but every other method is delegated
to StreamableHTTPServerTransport from the pinned SDK (^1.29.0), whose
version whitelist tops out at 2025-11-25. A SEP-2575 client that honors
the negotiated version then gets HTTP 400 on tools/list, and the
sep-2106-no-network-ref-deref check can never run.

Extend the existing server/discover workaround to the version check:
rewrite an incoming MCP-Protocol-Version: 2026-07-28 header to the
SDK's LATEST_PROTOCOL_VERSION before delegating (patching rawHeaders
too, since the SDK's Node adapter rebuilds its web-standard Request
from rawHeaders). Add a stateless-draft-client regression test.

Fixes #397


Claude-Session: https://claude.ai/code/session_01N4TdyLB13XSsPwNScYHf4C

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
claude[bot] 2026-07-08 14:56:28 +01:00 • committed by GitHub
commit 5ad96dbfbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 58 additions and 1 deletions

View file

@ -8,6 +8,8 @@ import {
} from './auth/test_helpers/testClient';
import { JsonSchemaRefDerefScenario } from './json-schema-ref-deref';
import { getScenario } from '../index';
import { sendStatelessRequest } from '../../connection/stateless';
import { DRAFT_PROTOCOL_VERSION } from '../../types';
/**
* SEP-2106: implementations MUST NOT automatically dereference $ref values
@ -71,6 +73,36 @@ async function dereferencingClient(serverUrl: string): Promise<void> {
await transport.close();
}
/**
* SEP-2575 stateless client: probe `server/discover` first, then honor the
* negotiated protocol version on subsequent requests. Regression client for
* issue #397 — the scenario's hand-rolled `server/discover` advertises the
* draft version, so the pinned SDK transport must not reject that version's
* MCP-Protocol-Version header on the follow-up tools/list.
*/
async function statelessDraftClient(serverUrl: string): Promise<void> {
const discover = await sendStatelessRequest(serverUrl, 'server/discover');
const supportedVersions = (
discover.body?.result as { supportedVersions?: string[] } | undefined
)?.supportedVersions;
if (!supportedVersions?.includes(DRAFT_PROTOCOL_VERSION)) {
throw new Error(
`server/discover did not advertise ${DRAFT_PROTOCOL_VERSION}: ` +
JSON.stringify(supportedVersions)
);
}
// sendStatelessRequest sends MCP-Protocol-Version: <draft> by default —
// exactly what a client that honors the negotiated version would do.
const tools = await sendStatelessRequest(serverUrl, 'tools/list');
if (tools.status !== 200 || tools.body?.result === undefined) {
throw new Error(
`tools/list with the negotiated draft version failed: HTTP ${tools.status} ` +
JSON.stringify(tools.body ?? tools.text)
);
}
}
describe('json-schema-ref-no-deref (SEP-2106)', () => {
test('scenario is registered', () => {
expect(getScenario('json-schema-ref-no-deref')).toBeDefined();
@ -83,6 +115,13 @@ describe('json-schema-ref-no-deref (SEP-2106)', () => {
);
});
test('stateless draft client passes: negotiated version reaches tools/list (issue #397)', async () => {
await runClientAgainstScenario(
new InlineClientRunner(statelessDraftClient),
'json-schema-ref-no-deref'
);
});
test('dereferencing client fails: canary fetch is detected', async () => {
await runClientAgainstScenario(
new InlineClientRunner(dereferencingClient),

View file

@ -1,7 +1,10 @@
import type { ScenarioContext } from '../../mock-server';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import {
ListToolsRequestSchema,
LATEST_PROTOCOL_VERSION as SDK_LATEST_PROTOCOL_VERSION
} from '@modelcontextprotocol/sdk/types.js';
import type { Scenario, ConformanceCheck } from '../../types';
import express, { Request, Response } from 'express';
import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../types';
@ -132,6 +135,21 @@ The scenario advertises a tool whose inputSchema contains a \`$ref\` pointing at
}
});
}
// Second half of the same workaround: the pinned SDK transport
// whitelists MCP-Protocol-Version headers and would reject the draft
// version that the server/discover response above advertises with an
// HTTP 400. Rewrite it to the newest version the SDK understands so a
// client that honors the negotiated version can reach tools/list.
if (req.headers['mcp-protocol-version'] === DRAFT_PROTOCOL_VERSION) {
req.headers['mcp-protocol-version'] = SDK_LATEST_PROTOCOL_VERSION;
// The SDK's Node adapter rebuilds its web-standard Request from
// rawHeaders, not the parsed headers object, so patch those too.
for (let i = 0; i < req.rawHeaders.length; i += 2) {
if (req.rawHeaders[i].toLowerCase() === 'mcp-protocol-version') {
req.rawHeaders[i + 1] = SDK_LATEST_PROTOCOL_VERSION;
}
}
}
try {
// Stateless: fresh server and transport per request
const server = createMcpServer(this.canaryUrl(), () => {