feat: version-aware Connection abstraction for server scenarios (#318)
* feat: vendor spec schema types per-version
Copies schema/{version}/schema.ts from the modelcontextprotocol spec
repo into src/spec-types/{version}.ts so the conformance suite can type
against draft spec versions before any SDK ships them.
npm run sync-schema -- <ref> refreshes the copies and records the spec
commit in src/spec-types/SOURCE.
* feat: add Connection abstraction and RunContext
Connection encapsulates how the conformance suite talks to a
server-under-test for a given spec version:
- connectStateful: 2025-x lifecycle. Thin adapter over the SDK Client
(initialize handshake, session id, SSE handled by the SDK).
- connectStateless: 2026-x lifecycle (SEP-2575). Raw fetch with
per-request _meta + MCP-Protocol-Version header. Decoupled from the
SDK so the suite can test draft spec versions before the SDK
implements them.
connectFor(specVersion) picks the implementation. RunContext bundles
serverUrl, specVersion and a bound connect() for the runner to hand to
each scenario.
Nothing uses this yet; wiring follows in the next commit.
* refactor: thread RunContext through ClientScenario.run
ClientScenario.run(serverUrl) becomes run(ctx: RunContext). The runner
builds the context from --spec-version and the server URL; scenarios
destructure ctx.serverUrl and otherwise behave identically.
No scenario uses ctx.connect() yet, so behaviour is unchanged: 214/214
tests pass, all-scenarios.test.ts still drives the everything-server
fixture exactly as before.
Test files use a testContext(url) helper to construct a RunContext.
The authorization-server scenario list is retyped to
ClientScenarioForAuthorizationServer since those scenarios test an
OAuth server, not an MCP server, and keep run(serverUrl).
* refactor: migrate server scenarios to ctx.connect() + conn.request()
22 carry-forward and lifecycle scenarios now go through the Connection
abstraction instead of connectToServer + SDK Client:
- tools.ts (8): list, call x6, with-progress
- prompts.ts (5)
- resources.ts (7): list, read x3, subscribe x2, not-found
- utils.ts (3): completion, ping, set-level
- json-schema-2020-12.ts, caching.ts, http-standard-headers.ts
Result types come from spec-types/{introducedIn}. Same scenario code
now passes under both --spec-version 2025-11-25 (SDK-backed stateful)
and --spec-version draft (raw stateless).
ToolsCallSampling/Elicitation/WithLogging and elicitation-* keep
connectToServer (need setRequestHandler/setLoggingLevel SDK surface);
they are tagged removedIn: DRAFT in the next commit. stateless.ts and
input-required-result.ts keep their sendRpc helper; migrating those is
deferred to the DRAFT-scenario coherence pass.
* fix: normalize Connection error to JsonRpcError; clean up RunContext import
connectStateful now catches the SDK's McpError and rethrows as
JsonRpcError so scenarios always see the same error class regardless
of which Connection impl ran. ResourcesNotFoundError uses
instanceof JsonRpcError instead of duck-typing.
types.ts uses a normal top-level import for RunContext instead of an
inline import() type.
* fix(dns-rebinding): use version-appropriate probe body
The 'valid Host accepted' check was sending an initialize body, which
a 2026 server returns 404 for. Probe body is now picked from
ctx.specVersion: initialize for the stateful lifecycle, server/discover
with _meta for the stateless lifecycle. The Host/Origin rejection check
is unchanged since rejection happens before body parsing.
* feat(everything-server): route stateless carry-forward methods to McpServer
Adds an in-memory dispatch client connected to the same McpServer the
stateful path uses. Stateless requests for tools/call, resources/*,
prompts/get and completion/complete that fall through the MRTR-specific
handlers are routed to it, so the fixture serves the carry-forward
scenarios under --spec-version draft without duplicating ~500 lines of
tool/resource/prompt registrations.
tools/list now merges the McpServer's tool list with the MRTR-only
stubs so json-schema-2020-12 finds its tool.
draft suite against the fixture: 36/39 (was 13/39). Remaining 3 are
fixture-side SEP gaps (no SSE forwarding for progress, no SEP-2549
ttlMs, no SEP-2243 Mcp-Method validation in the stateless path).
* refactor(connection): drop unused RequestOptions; move sdk-client; add tests
Addresses self-review findings on the new connection module:
- RequestOptions.handlers/.meta and ServerRequestHandler removed: zero
callers (the scenarios that motivated them are deferred). They can be
reintroduced when something actually uses them.
- scenarios/server/client-helper.ts moved to connection/sdk-client.ts so
the connection module no longer depends on the scenarios module.
- connectStateless: handle CRLF SSE event separators; throw a useful
error for non-JSON/non-SSE responses instead of a JSON parse error.
- JSONRPCNotification consistently imported from spec-types/2025-11-25.
- New connection.test.ts (8 tests) covering connectFor selection,
_meta injection, error mapping, SSE LF/CRLF parsing, and the
server-request-on-stream rejection.
* fix: address bughunt findings (response.ok check; targetVersion naming)
- connectStateless: throw on non-2xx responses that lack a JSON-RPC
error envelope (e.g. gateway/framework error JSON), matching the
stateful path's behavior. Previously such a response would return
undefined as the result.
- json-schema-2020-12: rename negotiatedVersion to targetVersion and
reword the skip message and details field. The value is ctx.specVersion
(the run's --spec-version), not a negotiated version; the old wording
was misleading. Drop dead 'unknown' fallback (specVersion is required)
and the corresponding undefined test cases.
* fix(sse-multiple-streams): keep scenario in draft; version-aware request scaffolding
Concurrent POSTs each answered with JSON or their own SSE stream are
core transport behavior in the draft spec too, so removedIn was wrong
for this scenario. Only the request scaffolding is version-specific:
stateful runs keep the initialize/session-id setup, draft runs send
_meta + the MCP-Protocol-Version header on each request instead. The
two checks are unchanged.
* refactor: fold stateless-client helpers into connection/stateless
The two stateless request paths were ~90% duplicated:
- scenarios/server/stateless-client.ts (main #319): buildStandardHeaders,
withRequestMeta, readSseJsonRpcResponse, sendStatelessRequest -> raw
{status, body}; sends Mcp-Method/Mcp-Name (SEP-2243); has timeout
- connection/stateless.ts (this PR): connectStateless -> Connection.request
-> throws JsonRpcError; missing Mcp-Method/Mcp-Name; no timeout
connection/stateless.ts now exports both layers:
- low-level sendStatelessRequest() (and the building blocks) moved verbatim
from stateless-client.ts
- connectStateless() rebuilt as a thin wrapper over sendStatelessRequest():
classifies SSE events into the notification sink, surfaces server->client
requests on the response stream as a spec violation, throws JsonRpcError
on error responses
So connectStateless() now also picks up the SEP-2243 headers and the 10s
timeout that the previous impl lacked.
stateless-client.ts deleted; its 4 importers + test file repointed at
'../../connection'. Test file moved to connection/stateless.test.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(everything-server): make stateless resources reach the carry-forward dispatch
The merge of main left two competing handler sets for resources/read,
resources/list and resources/templates/list: the inline stateless handlers
(test://stateless-static-text + a catch-all -32602) and the carry-forward
dispatch to the McpServer-registered resources. The catch-all made the
carry-forward unreachable, so test://static-text, test://static-binary and
the templates errored under --spec-version draft.
Now resources/list and resources/templates/list dispatch to the McpServer and
merge with the inline entries (same pattern as tools/list and prompts/list),
and resources/read keeps the inline static-text response but falls through to
the carry-forward for unknown URIs (preserving SEP-2164 data.uri on errors).
Also wrap the tools/list and prompts/list dispatch in try/finally so
dispatch.close() always runs, matching tools/call.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(scenarios): send SEP-2243 Mcp-Method header on hand-rolled stateless requests
sse-multiple-streams and dns-rebinding bypass sendStatelessRequest() because
they need raw fetch/undici for low-level inspection, but their hand-built
draft-path headers omitted Mcp-Method, so a strictly-conformant draft server
would 400 them before the behaviour under test runs. Build the headers via
buildStandardHeaders() instead, layering the scenario-specific overrides
(Accept ordering, Host/Origin) on top.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runner): infer draft spec version for draft-only scenarios
When --spec-version is omitted, the runner defaulted to LATEST_SPEC_VERSION,
so draft-only scenarios (caching, resources-not-found, ...) silently ran over
the stateful connection instead of the stateless draft transport they were
written for; all-scenarios.test.ts had the same problem via testContext().
Infer the spec version from scenario.source.introducedIn (as negative.test.ts
already does) when no explicit version is given.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(connection): only throw JsonRpcError for properly-shaped error objects
connectStateless() destructured response.body.error unconditionally, so a
proxy returning {"error": "upstream timeout"} produced a JsonRpcError with
undefined code/message and lost the HTTP status. Require an object with a
numeric code before treating it as a JSON-RPC error; otherwise surface the
HTTP status and raw body in the thrown Error. Adds a unit test for the
non-conformant 502 case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(connection): align connectStateful normalization with connectStateless
Two consistency nits from automated review:
- Collected notifications were missing the jsonrpc field (the SDK's Zod
parsing strips it), violating the JSONRPCNotification type contract.
- McpError messages kept the SDK's "MCP error <code>: " prefix, so
JsonRpcError.message differed between the stateful and stateless impls.
Also restore progressToken filtering in tools-call-with-progress, which was
lost when the scenario migrated from the SDK onprogress callback to
conn.notifications: the server must echo the request's token back, and
notifications for other tokens are now ignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(runner): skip inapplicable scenario/spec-version combos; add --force; plumb spec version to the wire
Running a draft-only scenario with an explicit dated --spec-version (e.g.
server-stateless --spec-version 2025-11-25) used to silently pass: the flag
only affected ctx.connect(), which hand-rolling scenarios never call, so
they sent draft requests regardless and the run looked green while testing
nothing the flag claimed.
Three changes:
- buildStandardHeaders / withRequestMeta / sendStatelessRequest /
connectStateless accept an optional specVersion (default: draft), and
hand-rolling scenarios pass ctx.specVersion through, so requests declare
the version the run was invoked with.
- The runner skips (exit 0) when an explicit --spec-version falls outside
the scenario's introducedIn/removedIn window, with a message instead of
a misleading green run.
- A new --force flag overrides the skip for deliberate mismatch probing;
combined with the plumbing, a forced server-stateless run at 2025-11-25
now genuinely sends that version (8/20 checks pass against the
everything-server instead of a vacuous 26/26).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Paul Carleton <paulc@anthropic.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7d3b897618
commit
b1bb7298e8
24 changed files with 10012 additions and 494 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import { testContext } from '../../connection/testing';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { createServer } from 'net';
|
||||
import {
|
||||
|
|
@ -6,6 +7,7 @@ import {
|
|||
listDraftClientScenarios,
|
||||
listPendingClientScenarios
|
||||
} from '../index';
|
||||
import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../../types';
|
||||
import path from 'path';
|
||||
|
||||
function getFreePort(): Promise<number> {
|
||||
|
|
@ -139,7 +141,15 @@ describe('Server Scenarios', () => {
|
|||
throw new Error(`Scenario ${scenarioName} not found`);
|
||||
}
|
||||
|
||||
const checks = await scenario.run(serverUrl);
|
||||
// Draft-only scenarios expect the draft (stateless) connection, so
|
||||
// derive the spec version from the scenario's declared source.
|
||||
const specVersion =
|
||||
'introducedIn' in scenario.source &&
|
||||
scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION
|
||||
? DRAFT_PROTOCOL_VERSION
|
||||
: LATEST_SPEC_VERSION;
|
||||
|
||||
const checks = await scenario.run(testContext(serverUrl, specVersion));
|
||||
|
||||
// Verify checks were returned
|
||||
expect(checks.length).toBeGreaterThan(0);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@ import {
|
|||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types';
|
||||
import { sendStatelessRequest } from './stateless-client';
|
||||
import {
|
||||
JsonRpcError,
|
||||
type Connection,
|
||||
type RunContext
|
||||
} from '../../connection';
|
||||
import type { CacheableResult } from '../../spec-types/draft';
|
||||
|
||||
const SPEC_REFS = [
|
||||
{
|
||||
|
|
@ -30,12 +35,12 @@ interface CachingFields {
|
|||
hasCacheScope: boolean;
|
||||
}
|
||||
|
||||
function extractCachingFields(result: Record<string, unknown>): CachingFields {
|
||||
function extractCachingFields(result: Partial<CacheableResult>): CachingFields {
|
||||
const hasTtlMs = 'ttlMs' in result;
|
||||
const hasCacheScope = 'cacheScope' in result;
|
||||
return {
|
||||
ttlMs: hasTtlMs ? result.ttlMs : undefined,
|
||||
cacheScope: hasCacheScope ? result.cacheScope : undefined,
|
||||
ttlMs: result.ttlMs,
|
||||
cacheScope: result.cacheScope,
|
||||
hasTtlMs,
|
||||
hasCacheScope
|
||||
};
|
||||
|
|
@ -87,56 +92,61 @@ Servers MUST include \`ttlMs\` (integer >= 0) and \`cacheScope\` ("public" or "p
|
|||
- \`resources/templates/list\`
|
||||
- \`resources/read\``;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
const allFields: Array<{ endpoint: string; fields: CachingFields }> = [];
|
||||
|
||||
// SEP-2549 only exists in the draft spec, so each cacheable endpoint is
|
||||
// queried over the stateless path (SEP-2575): protocolVersion DRAFT-2026-v1
|
||||
// plus the cross-cutting _meta and standard headers (issue #315).
|
||||
// queried over the version-appropriate connection. Under --spec-version
|
||||
// draft that resolves to the stateless impl (SEP-2575): protocolVersion
|
||||
// DRAFT-2026-v1 plus the cross-cutting _meta and standard headers
|
||||
// (issue #315).
|
||||
let conn: Connection;
|
||||
try {
|
||||
conn = await ctx.connect();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'sep-2549-caching-connection',
|
||||
name: 'CachingConnection',
|
||||
description: 'Caching hints scenario failed to connect',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `Connection failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: SPEC_REFS
|
||||
});
|
||||
return checks;
|
||||
}
|
||||
|
||||
const queryEndpoint = async (
|
||||
checkId: string,
|
||||
checkName: string,
|
||||
endpoint: string,
|
||||
params?: Record<string, unknown>
|
||||
): Promise<Record<string, unknown> | undefined> => {
|
||||
): Promise<(CacheableResult & Record<string, unknown>) | undefined> => {
|
||||
const description = `${endpoint} response includes ttlMs and cacheScope caching hints`;
|
||||
try {
|
||||
const response = await sendStatelessRequest(
|
||||
serverUrl,
|
||||
endpoint,
|
||||
params
|
||||
);
|
||||
const result = response.body?.result;
|
||||
if (!result) {
|
||||
const error = response.body?.error;
|
||||
checks.push({
|
||||
id: checkId,
|
||||
name: checkName,
|
||||
description,
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: error
|
||||
? `${endpoint} returned JSON-RPC error ${error.code}: ${error.message}`
|
||||
: `${endpoint} returned HTTP ${response.status} with no result`,
|
||||
specReferences: SPEC_REFS,
|
||||
details: { httpStatus: response.status, error }
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
const result = await conn.request<
|
||||
CacheableResult & Record<string, unknown>
|
||||
>(endpoint, params);
|
||||
const fields = extractCachingFields(result);
|
||||
allFields.push({ endpoint, fields });
|
||||
checks.push(buildPresenceCheck(checkId, checkName, endpoint, fields));
|
||||
return result;
|
||||
} catch (error) {
|
||||
const rpcError = error instanceof JsonRpcError ? error : undefined;
|
||||
checks.push({
|
||||
id: checkId,
|
||||
name: checkName,
|
||||
description,
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `${endpoint} request failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: SPEC_REFS
|
||||
errorMessage: rpcError
|
||||
? `${endpoint} returned JSON-RPC error ${rpcError.code}: ${rpcError.message}`
|
||||
: `${endpoint} request failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: SPEC_REFS,
|
||||
details: rpcError
|
||||
? { error: { code: rpcError.code, message: rpcError.message } }
|
||||
: undefined
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -198,6 +208,8 @@ Servers MUST include \`ttlMs\` (integer >= 0) and \`cacheScope\` ("public" or "p
|
|||
});
|
||||
}
|
||||
|
||||
await conn.close();
|
||||
|
||||
// 6. Aggregate: ttlMs must be a non-negative integer
|
||||
const ttlErrors: string[] = [];
|
||||
const endpointsWithTtl = allFields.filter((f) => f.fields.hasTtlMs);
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
/**
|
||||
* Helper utilities for creating MCP clients to test servers
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import {
|
||||
LoggingMessageNotificationSchema,
|
||||
ProgressNotificationSchema
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export interface MCPClientConnection {
|
||||
client: Client;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and connect an MCP client to a server
|
||||
*/
|
||||
export async function connectToServer(
|
||||
serverUrl: string
|
||||
): Promise<MCPClientConnection> {
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'conformance-test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
// Client capabilities
|
||||
sampling: {},
|
||||
elicitation: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
return {
|
||||
client,
|
||||
close: async () => {
|
||||
await client.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to collect notifications (logging and progress)
|
||||
*/
|
||||
export class NotificationCollector {
|
||||
private loggingNotifications: any[] = [];
|
||||
private progressNotifications: any[] = [];
|
||||
|
||||
constructor(client: Client) {
|
||||
// Set up notification handler for logging messages
|
||||
client.setNotificationHandler(
|
||||
LoggingMessageNotificationSchema,
|
||||
(notification) => {
|
||||
this.loggingNotifications.push(notification);
|
||||
}
|
||||
);
|
||||
|
||||
// Set up notification handler for progress notifications
|
||||
client.setNotificationHandler(
|
||||
ProgressNotificationSchema,
|
||||
(notification) => {
|
||||
this.progressNotifications.push(notification);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all collected logging notifications
|
||||
*/
|
||||
getLoggingNotifications(): any[] {
|
||||
return this.loggingNotifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all collected progress notifications
|
||||
*/
|
||||
getProgressNotifications(): any[] {
|
||||
return this.progressNotifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notifications (for backward compatibility)
|
||||
*/
|
||||
getNotifications(): any[] {
|
||||
return this.loggingNotifications;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,12 @@
|
|||
* to prevent DNS rebinding attacks. See GHSA-w48q-cv73-mx4w for details.
|
||||
*/
|
||||
|
||||
import { ClientScenario, ConformanceCheck } from '../../types';
|
||||
import {
|
||||
ClientScenario,
|
||||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types';
|
||||
import { buildStandardHeaders, type RunContext } from '../../connection';
|
||||
import { request } from 'undici';
|
||||
|
||||
const SPEC_REFERENCES = [
|
||||
|
|
@ -42,32 +47,71 @@ function getHostFromUrl(serverUrl: string): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Send an MCP initialize request with custom Host and Origin headers.
|
||||
* Build a request body that any server for the given spec version should
|
||||
* accept without prior setup (initialize for the stateful lifecycle,
|
||||
* server/discover with _meta for the stateless lifecycle).
|
||||
*/
|
||||
function probeBody(specVersion: string): {
|
||||
jsonrpc: '2.0';
|
||||
id: number;
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
} {
|
||||
const clientInfo = {
|
||||
name: 'conformance-dns-rebinding-test',
|
||||
version: '1.0.0'
|
||||
};
|
||||
if (specVersion === DRAFT_PROTOCOL_VERSION) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'server/discover',
|
||||
params: {
|
||||
_meta: {
|
||||
'io.modelcontextprotocol/protocolVersion': specVersion,
|
||||
'io.modelcontextprotocol/clientInfo': clientInfo,
|
||||
'io.modelcontextprotocol/clientCapabilities': {}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: specVersion,
|
||||
capabilities: {},
|
||||
clientInfo
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an MCP request with custom Host and Origin headers.
|
||||
* Both headers are set to the same value so that servers checking either
|
||||
* Host or Origin will properly detect the rebinding attempt.
|
||||
*/
|
||||
async function sendRequestWithHostAndOrigin(
|
||||
serverUrl: string,
|
||||
hostOrOrigin: string
|
||||
hostOrOrigin: string,
|
||||
specVersion: string
|
||||
): Promise<{ statusCode: number; body: unknown }> {
|
||||
// Build the SEP-2243 standard headers (Mcp-Method, Accept, ...) for the
|
||||
// probe's JSON-RPC method so a strictly-conformant server only rejects the
|
||||
// request for the Host/Origin values under test, then layer the
|
||||
// scenario-specific headers on top.
|
||||
const probe = probeBody(specVersion);
|
||||
const response = await request(serverUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Host: hostOrOrigin,
|
||||
Origin: `http://${hostOrOrigin}`,
|
||||
Accept: 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-11-25',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'conformance-dns-rebinding-test', version: '1.0.0' }
|
||||
headers: buildStandardHeaders(probe.method, probe.params, {
|
||||
headers: {
|
||||
Host: hostOrOrigin,
|
||||
Origin: `http://${hostOrOrigin}`,
|
||||
'MCP-Protocol-Version': specVersion
|
||||
}
|
||||
})
|
||||
}),
|
||||
body: JSON.stringify(probe)
|
||||
});
|
||||
|
||||
let body: unknown;
|
||||
|
|
@ -109,7 +153,8 @@ website tricks a user's browser into making requests to the local server.
|
|||
|
||||
See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-w48q-cv73-mx4w`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl, specVersion } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
|
|
@ -160,7 +205,8 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/
|
|||
try {
|
||||
const response = await sendRequestWithHostAndOrigin(
|
||||
serverUrl,
|
||||
attackerHost
|
||||
attackerHost,
|
||||
specVersion
|
||||
);
|
||||
const isRejected =
|
||||
response.statusCode >= 400 && response.statusCode < 500;
|
||||
|
|
@ -200,7 +246,11 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/
|
|||
|
||||
// Check 2: Valid localhost Host/Origin headers should be accepted (2xx response)
|
||||
try {
|
||||
const response = await sendRequestWithHostAndOrigin(serverUrl, validHost);
|
||||
const response = await sendRequestWithHostAndOrigin(
|
||||
serverUrl,
|
||||
validHost,
|
||||
specVersion
|
||||
);
|
||||
const isAccepted =
|
||||
response.statusCode >= 200 && response.statusCode < 300;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,21 @@
|
|||
* SEP-1034: Elicitation default values test scenarios for MCP servers
|
||||
*/
|
||||
|
||||
import { ClientScenario, ConformanceCheck } from '../../types';
|
||||
import { connectToServer } from './client-helper';
|
||||
import {
|
||||
ClientScenario,
|
||||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types';
|
||||
import type { RunContext } from '../../connection';
|
||||
import { connectToServer } from '../../connection/sdk-client';
|
||||
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export class ElicitationDefaultsScenario implements ClientScenario {
|
||||
name = 'elicitation-sep1034-defaults';
|
||||
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||
readonly source = {
|
||||
introducedIn: '2025-11-25',
|
||||
removedIn: DRAFT_PROTOCOL_VERSION
|
||||
} as const;
|
||||
description = `Test elicitation with default values for all primitive types (SEP-1034).
|
||||
|
||||
**Server Implementation Requirements:**
|
||||
|
|
@ -33,7 +41,8 @@ Implement a tool named \`test_elicitation_sep1034_defaults\` (no arguments) that
|
|||
}
|
||||
\`\`\``;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,21 @@
|
|||
* SEP-1330: Elicitation enum schema improvements test scenarios for MCP servers
|
||||
*/
|
||||
|
||||
import { ClientScenario, ConformanceCheck } from '../../types';
|
||||
import { connectToServer } from './client-helper';
|
||||
import {
|
||||
ClientScenario,
|
||||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types';
|
||||
import type { RunContext } from '../../connection';
|
||||
import { connectToServer } from '../../connection/sdk-client';
|
||||
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export class ElicitationEnumsScenario implements ClientScenario {
|
||||
name = 'elicitation-sep1330-enums';
|
||||
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||
readonly source = {
|
||||
introducedIn: '2025-11-25',
|
||||
removedIn: DRAFT_PROTOCOL_VERSION
|
||||
} as const;
|
||||
description = `Test elicitation with enum schema improvements (SEP-1330).
|
||||
|
||||
**Server Implementation Requirements:**
|
||||
|
|
@ -34,7 +42,8 @@ Implement a tool named \`test_elicitation_sep1330_enums\` (no arguments) that re
|
|||
}
|
||||
\`\`\``;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ import {
|
|||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types';
|
||||
import { withRequestMeta, sendStatelessRequest } from './stateless-client';
|
||||
import {
|
||||
withRequestMeta,
|
||||
sendStatelessRequest,
|
||||
type RunContext
|
||||
} from '../../connection';
|
||||
|
||||
const SPEC_REFERENCE = {
|
||||
id: 'SEP-2243-Server-Validation',
|
||||
|
|
@ -262,7 +266,8 @@ export class HttpHeaderValidationScenario implements ClientScenario {
|
|||
- Server MUST return HTTP 400 Bad Request for validation failures
|
||||
- Server MUST return JSON-RPC error with code -32001 (HeaderMismatch)`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -557,7 +562,8 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
|||
- Server MUST treat values without =?base64?...?= wrapper as literal
|
||||
- Server MUST reject requests where custom header is omitted but value is in body`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@
|
|||
|
||||
// ─── JSON-RPC Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export type { JsonRpcResponse } from './stateless-client';
|
||||
export type { JsonRpcResponse } from '../../connection';
|
||||
|
||||
// ─── Stateless RPC Helper ────────────────────────────────────────────────────
|
||||
|
||||
import { sendStatelessRequest, JsonRpcResponse } from './stateless-client';
|
||||
import { sendStatelessRequest, JsonRpcResponse } from '../../connection';
|
||||
|
||||
/**
|
||||
* Send a stateless JSON-RPC request (SEP-2575 pattern).
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
DRAFT_PROTOCOL_VERSION,
|
||||
SpecVersion
|
||||
} from '../../types';
|
||||
import type { RunContext } from '../../connection';
|
||||
import {
|
||||
sendRpc,
|
||||
isInputRequiredResult,
|
||||
|
|
@ -65,7 +66,8 @@ Implement a tool named \`test_input_required_result_elicitation\` (no arguments
|
|||
}
|
||||
\`\`\``;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -210,7 +212,8 @@ Implement a tool named \`test_input_required_result_sampling\` (no arguments req
|
|||
|
||||
**Behavior (Round 2):** When called with \`inputResponses\` containing the key \`"capital_question"\`, return a complete result with the sampling response text.`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -345,7 +348,8 @@ Implement a tool named \`test_input_required_result_list_roots\` (no arguments r
|
|||
|
||||
**Behavior (Round 2):** When called with \`inputResponses\` containing the key \`"client_roots"\` (a ListRootsResult with a \`roots\` array), return a complete result that references the provided roots.`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -488,7 +492,8 @@ Implement a tool named \`test_input_required_result_request_state\` (no argument
|
|||
|
||||
**Behavior (Round 2):** When called with \`inputResponses\` AND the echoed \`requestState\`, validate the state and return a complete result. The text content MUST include the word "state-ok" to confirm the server received and validated the requestState.`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -630,7 +635,8 @@ Implement a tool named \`test_input_required_result_multiple_inputs\` (no argume
|
|||
|
||||
**Behavior (Round 2):** When called with \`inputResponses\` containing ALL keys and the echoed \`requestState\`, return a complete result.`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -815,7 +821,8 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments
|
|||
|
||||
**Behavior (Round 3):** When called with \`inputResponses\` for step2 + updated requestState, return a complete result.`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -955,7 +962,8 @@ Use the same tool as A1: \`test_input_required_result_elicitation\`.
|
|||
|
||||
**Behavior:** When the client retries with \`inputResponses\` that are missing required keys or contain wrong keys, the server SHOULD respond with a new \`InputRequiredResult\` re-requesting the missing information (NOT a JSON-RPC error).`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -1049,7 +1057,8 @@ Implement a prompt named \`test_input_required_result_prompt\` that requires eli
|
|||
|
||||
**Behavior (Round 2):** When called with \`inputResponses\`, return a complete \`GetPromptResult\`.`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -1152,7 +1161,8 @@ Uses the same tool as A1: \`test_input_required_result_elicitation\`.
|
|||
|
||||
This scenario verifies that the resultType field is explicitly present in the response (not just inferred).`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -1216,7 +1226,8 @@ export class InputRequiredResultUnsupportedMethodsScenario implements ClientScen
|
|||
|
||||
Servers MUST NOT send InputRequiredResult responses on any client requests other than the supported ones (prompts/get, resources/read, tools/call, tasks/result).`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
|
|
@ -1281,7 +1292,8 @@ integrity-protected requestState (e.g. HMAC-signed).
|
|||
**Behavior (Round 2 - tampered):** When called with a modified/tampered requestState, return a
|
||||
JSON-RPC error (code -32602 or similar) indicating integrity check failure.`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -1393,7 +1405,8 @@ Implement a tool named \`test_input_required_result_capabilities\` (no arguments
|
|||
Only include inputRequests for methods the client supports. For example, if the client declares
|
||||
\`sampling: {}\` but NOT \`elicitation\`, only include \`sampling/createMessage\` inputRequests.`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -1475,7 +1488,8 @@ Uses the same tool as A1: \`test_input_required_result_elicitation\`.
|
|||
This scenario sends correct inputResponses PLUS extra unrecognized keys. The server SHOULD ignore
|
||||
the extra keys and complete normally.`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -1548,7 +1562,8 @@ Uses the same tool as A1: \`test_input_required_result_elicitation\`.
|
|||
This scenario sends completely invalid inputResponses structures. The server SHOULD validate them
|
||||
and return a JSON-RPC error or a new InputRequiredResult.`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ import {
|
|||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types.js';
|
||||
import { connectToServer } from './client-helper.js';
|
||||
import type { RunContext } from '../../connection';
|
||||
import type { ListToolsResult } from '../../spec-types/2025-11-25';
|
||||
|
||||
const EXPECTED_TOOL_NAME = 'json_schema_2020_12_tool';
|
||||
const EXPECTED_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema';
|
||||
|
|
@ -27,19 +28,19 @@ const EXPECTED_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema';
|
|||
*
|
||||
* Preserving the broader JSON Schema 2020-12 vocabulary is good behavior at
|
||||
* any protocol version, so a server that preserves the keywords gets SUCCESS
|
||||
* regardless of what it negotiated. Stripping them is only a conformance
|
||||
* FAILURE once the server claims a protocol version that includes SEP-2106
|
||||
* (the draft); a server that only negotiated an earlier dated version is not
|
||||
* yet subject to the requirement, so the check is SKIPPED rather than failed.
|
||||
* regardless. Stripping them is only a conformance FAILURE when the run
|
||||
* targets a protocol version that includes SEP-2106 (the draft); when
|
||||
* targeting an earlier dated version the requirement does not apply, so the
|
||||
* check is SKIPPED rather than failed.
|
||||
*/
|
||||
export function sep2106KeywordCheckStatus(
|
||||
preserved: boolean,
|
||||
negotiatedProtocolVersion: string | undefined
|
||||
targetProtocolVersion: string
|
||||
): CheckStatus {
|
||||
if (preserved) {
|
||||
return 'SUCCESS';
|
||||
}
|
||||
return negotiatedProtocolVersion === DRAFT_PROTOCOL_VERSION
|
||||
return targetProtocolVersion === DRAFT_PROTOCOL_VERSION
|
||||
? 'FAILURE'
|
||||
: 'SKIPPED';
|
||||
}
|
||||
|
|
@ -93,7 +94,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema
|
|||
|
||||
**Verification**: The test verifies that \`$schema\`, \`$defs\`, and \`additionalProperties\` are preserved (SEP-1613), and that the composition (\`allOf\`/\`anyOf\`), conditional (\`if\`/\`then\`/\`else\`), and \`$anchor\` keywords are preserved (SEP-2106), in the tool listing response.`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
const specReferences = [
|
||||
{
|
||||
|
|
@ -109,13 +110,11 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema
|
|||
];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
// Negotiated wire protocolVersion from the initialize handshake; used to
|
||||
// soft-gate the SEP-2106 checks below.
|
||||
const negotiatedVersion = (
|
||||
connection.client.transport as { protocolVersion?: string } | undefined
|
||||
)?.protocolVersion;
|
||||
const result = await connection.client.listTools();
|
||||
const conn = await ctx.connect();
|
||||
// Spec version this run is targeting; used to soft-gate the SEP-2106
|
||||
// checks below.
|
||||
const targetVersion = ctx.specVersion;
|
||||
const result = await conn.request<ListToolsResult>('tools/list');
|
||||
|
||||
// Find the test tool
|
||||
const tool = result.tools?.find((t) => t.name === EXPECTED_TOOL_NAME);
|
||||
|
|
@ -138,7 +137,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema
|
|||
});
|
||||
|
||||
if (!tool) {
|
||||
await connection.close();
|
||||
await conn.close();
|
||||
return checks;
|
||||
}
|
||||
|
||||
|
|
@ -222,11 +221,11 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema
|
|||
|
||||
// SEP-2106: the full JSON Schema 2020-12 vocabulary is permitted in
|
||||
// inputSchema and must survive tools/list rather than being stripped to
|
||||
// properties/required. These checks are soft-gated on the negotiated
|
||||
// protocol version (see sep2106KeywordCheckStatus): stripping the
|
||||
// keywords is only a FAILURE for servers that negotiated the draft
|
||||
// protocol version, and SKIPPED otherwise.
|
||||
const skippedSuffix = ` (server negotiated protocol version ${negotiatedVersion ?? 'unknown'}; SEP-2106 applies from ${DRAFT_PROTOCOL_VERSION})`;
|
||||
// properties/required. These checks are soft-gated on the protocol
|
||||
// version this run targets (see sep2106KeywordCheckStatus): stripping
|
||||
// the keywords is only a FAILURE when targeting the draft version, and
|
||||
// SKIPPED otherwise.
|
||||
const skippedSuffix = ` (run targets protocol version ${targetVersion}; SEP-2106 applies from ${DRAFT_PROTOCOL_VERSION})`;
|
||||
|
||||
// Check 5: composition keywords (allOf / anyOf) preserved
|
||||
const allOf = inputSchema['allOf'];
|
||||
|
|
@ -242,7 +241,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema
|
|||
const compositionPreserved = hasAllOf && hasNestedAnyOf;
|
||||
const compositionStatus = sep2106KeywordCheckStatus(
|
||||
compositionPreserved,
|
||||
negotiatedVersion
|
||||
targetVersion
|
||||
);
|
||||
|
||||
checks.push({
|
||||
|
|
@ -262,7 +261,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema
|
|||
details: {
|
||||
hasAllOf,
|
||||
hasNestedAnyOf,
|
||||
negotiatedProtocolVersion: negotiatedVersion
|
||||
targetProtocolVersion: targetVersion
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -273,7 +272,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema
|
|||
const conditionalPreserved = hasIf && hasThen && hasElse;
|
||||
const conditionalStatus = sep2106KeywordCheckStatus(
|
||||
conditionalPreserved,
|
||||
negotiatedVersion
|
||||
targetVersion
|
||||
);
|
||||
|
||||
checks.push({
|
||||
|
|
@ -292,7 +291,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema
|
|||
hasIf,
|
||||
hasThen,
|
||||
hasElse,
|
||||
negotiatedProtocolVersion: negotiatedVersion
|
||||
targetProtocolVersion: targetVersion
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -301,10 +300,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema
|
|||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const hasAnchor = !!addressDef && '$anchor' in addressDef;
|
||||
const anchorStatus = sep2106KeywordCheckStatus(
|
||||
hasAnchor,
|
||||
negotiatedVersion
|
||||
);
|
||||
const anchorStatus = sep2106KeywordCheckStatus(hasAnchor, targetVersion);
|
||||
|
||||
checks.push({
|
||||
id: 'sep-2106-anchor-keyword-preserved',
|
||||
|
|
@ -324,7 +320,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema
|
|||
}
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
await conn.close();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'json-schema-2020-12-error',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { testContext } from '../../connection/testing';
|
||||
import { ServerInitializeScenario } from './lifecycle';
|
||||
import { connectToServer } from './client-helper';
|
||||
import { connectToServer } from '../../connection/sdk-client';
|
||||
|
||||
vi.mock('./client-helper', () => ({
|
||||
vi.mock('../../connection/sdk-client', () => ({
|
||||
connectToServer: vi.fn()
|
||||
}));
|
||||
|
||||
|
|
@ -26,7 +27,9 @@ describe('ServerInitializeScenario', () => {
|
|||
it('returns INFO when the server does not provide an MCP-Session-Id header', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(null));
|
||||
|
||||
const checks = await new ServerInitializeScenario().run(serverUrl);
|
||||
const checks = await new ServerInitializeScenario().run(
|
||||
testContext(serverUrl)
|
||||
);
|
||||
|
||||
expect(connectToServer).toHaveBeenCalledWith(serverUrl);
|
||||
expect(closeMock).toHaveBeenCalled();
|
||||
|
|
@ -59,7 +62,9 @@ describe('ServerInitializeScenario', () => {
|
|||
})
|
||||
);
|
||||
|
||||
const checks = await new ServerInitializeScenario().run(serverUrl);
|
||||
const checks = await new ServerInitializeScenario().run(
|
||||
testContext(serverUrl)
|
||||
);
|
||||
|
||||
expect(checks[1]).toMatchObject({
|
||||
id: 'server-session-id-visible-ascii',
|
||||
|
|
@ -79,7 +84,9 @@ describe('ServerInitializeScenario', () => {
|
|||
})
|
||||
);
|
||||
|
||||
const checks = await new ServerInitializeScenario().run(serverUrl);
|
||||
const checks = await new ServerInitializeScenario().run(
|
||||
testContext(serverUrl)
|
||||
);
|
||||
|
||||
expect(checks[1]).toMatchObject({
|
||||
id: 'server-session-id-visible-ascii',
|
||||
|
|
|
|||
|
|
@ -2,8 +2,13 @@
|
|||
* Lifecycle test scenarios for MCP servers
|
||||
*/
|
||||
|
||||
import { ClientScenario, ConformanceCheck } from '../../types';
|
||||
import { connectToServer } from './client-helper';
|
||||
import {
|
||||
ClientScenario,
|
||||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types';
|
||||
import type { RunContext } from '../../connection';
|
||||
import { connectToServer } from '../../connection/sdk-client';
|
||||
|
||||
const VISIBLE_ASCII_REGEX = /^[\x21-\x7E]+$/;
|
||||
|
||||
|
|
@ -16,7 +21,10 @@ const SESSION_SPEC_REFERENCES = [
|
|||
|
||||
export class ServerInitializeScenario implements ClientScenario {
|
||||
name = 'server-initialize';
|
||||
readonly source = { introducedIn: '2025-06-18' } as const;
|
||||
readonly source = {
|
||||
introducedIn: '2025-06-18',
|
||||
removedIn: DRAFT_PROTOCOL_VERSION
|
||||
} as const;
|
||||
description = `Test basic server initialization handshake.
|
||||
|
||||
**Server Implementation Requirements:**
|
||||
|
|
@ -32,7 +40,8 @@ export class ServerInitializeScenario implements ClientScenario {
|
|||
This test verifies the server can complete the two-phase initialization handshake successfully,
|
||||
and validates session ID format if one is assigned.`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { testContext } from '../../connection/testing';
|
||||
/**
|
||||
* SEP-2322 MRTR negative tests.
|
||||
*
|
||||
|
|
@ -92,7 +93,7 @@ describe('SEP-2322 MRTR negative tests', () => {
|
|||
|
||||
it('emits FAILURE for sep-2322-result-type-included against server that omits resultType', async () => {
|
||||
const scenario = new InputRequiredResultResultTypeScenario();
|
||||
const checks = await scenario.run(SERVER_URL);
|
||||
const checks = await scenario.run(testContext(SERVER_URL));
|
||||
|
||||
const resultTypeCheck = checks.find(
|
||||
(c) => c.id === 'sep-2322-result-type-included'
|
||||
|
|
@ -103,7 +104,7 @@ describe('SEP-2322 MRTR negative tests', () => {
|
|||
|
||||
it('emits FAILURE for sep-2322-not-on-unsupported-requests against server returning InputRequiredResult on tools/list', async () => {
|
||||
const scenario = new InputRequiredResultUnsupportedMethodsScenario();
|
||||
const checks = await scenario.run(SERVER_URL);
|
||||
const checks = await scenario.run(testContext(SERVER_URL));
|
||||
|
||||
const unsupportedCheck = checks.find(
|
||||
(c) => c.id === 'sep-2322-not-on-unsupported-requests'
|
||||
|
|
@ -114,7 +115,7 @@ describe('SEP-2322 MRTR negative tests', () => {
|
|||
|
||||
it('emits FAILURE for sep-2322-reject-tampered-state against server that accepts tampered state', async () => {
|
||||
const scenario = new InputRequiredResultTamperedStateScenario();
|
||||
const checks = await scenario.run(SERVER_URL);
|
||||
const checks = await scenario.run(testContext(SERVER_URL));
|
||||
|
||||
const tamperedCheck = checks.find(
|
||||
(c) => c.id === 'sep-2322-reject-tampered-state'
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { testContext } from '../../connection/testing';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import path from 'path';
|
||||
import { DNSRebindingProtectionScenario } from './dns-rebinding';
|
||||
|
|
@ -74,7 +75,9 @@ describe('Server scenario negative tests', () => {
|
|||
|
||||
it('emits FAILURE against a server without rebinding protection', async () => {
|
||||
const scenario = new DNSRebindingProtectionScenario();
|
||||
const checks = await scenario.run(`http://localhost:${PORT}/mcp`);
|
||||
const checks = await scenario.run(
|
||||
testContext(`http://localhost:${PORT}/mcp`)
|
||||
);
|
||||
|
||||
const rebindingCheck = checks.find(
|
||||
(c) => c.id === 'localhost-host-rebinding-rejected'
|
||||
|
|
@ -103,7 +106,9 @@ describe('Server scenario negative tests', () => {
|
|||
|
||||
it('emits FAILURE for no-empty-contents and WARNING for error-code against a server returning empty contents', async () => {
|
||||
const scenario = new ResourcesNotFoundErrorScenario();
|
||||
const checks = await scenario.run(`http://localhost:${PORT}/mcp`);
|
||||
const checks = await scenario.run(
|
||||
testContext(`http://localhost:${PORT}/mcp`, DRAFT_PROTOCOL_VERSION)
|
||||
);
|
||||
|
||||
const noEmpty = checks.find((c) => c.id === 'sep-2164-no-empty-contents');
|
||||
expect(noEmpty?.status).toBe('FAILURE');
|
||||
|
|
@ -133,7 +138,9 @@ describe('Server scenario negative tests', () => {
|
|||
|
||||
it('emits FAILURE for presence checks against a server without caching hints', async () => {
|
||||
const scenario = new CachingScenario();
|
||||
const checks = await scenario.run(`http://localhost:${PORT}/mcp`);
|
||||
const checks = await scenario.run(
|
||||
testContext(`http://localhost:${PORT}/mcp`, DRAFT_PROTOCOL_VERSION)
|
||||
);
|
||||
|
||||
// Should have at least 7 checks (5 presence + 2 aggregate)
|
||||
expect(checks.length).toBeGreaterThanOrEqual(7);
|
||||
|
|
@ -174,7 +181,9 @@ describe('Server scenario negative tests', () => {
|
|||
|
||||
it('flags SEP-2106 keyword-preservation checks against a server that strips the 2020-12 vocabulary', async () => {
|
||||
const scenario = new JsonSchema2020_12Scenario();
|
||||
const checks = await scenario.run(`http://localhost:${PORT}/mcp`);
|
||||
const checks = await scenario.run(
|
||||
testContext(`http://localhost:${PORT}/mcp`)
|
||||
);
|
||||
|
||||
// The tool is still advertised, so it must be found...
|
||||
const found = checks.find(
|
||||
|
|
@ -182,10 +191,9 @@ describe('Server scenario negative tests', () => {
|
|||
);
|
||||
expect(found?.status).toBe('SUCCESS');
|
||||
|
||||
// ...but the stripped 2020-12 keywords must be flagged. The stripped
|
||||
// server negotiates 2025-11-25 (the published SDK does not advertise the
|
||||
// draft protocol version), so the soft version gate reports SKIPPED
|
||||
// rather than FAILURE — see sep2106KeywordCheckStatus.
|
||||
// ...but the stripped 2020-12 keywords must be flagged. testContext()
|
||||
// defaults to LATEST_SPEC_VERSION (2025-11-25), so the soft version gate
|
||||
// reports SKIPPED rather than FAILURE; see sep2106KeywordCheckStatus.
|
||||
const composition = checks.find(
|
||||
(c) => c.id === 'sep-2106-composition-keywords-preserved'
|
||||
);
|
||||
|
|
@ -204,24 +212,22 @@ describe('Server scenario negative tests', () => {
|
|||
});
|
||||
|
||||
describe('sep2106KeywordCheckStatus (soft version gate)', () => {
|
||||
it('passes preserved keywords at any negotiated version', () => {
|
||||
it('passes preserved keywords at any target version', () => {
|
||||
expect(sep2106KeywordCheckStatus(true, DRAFT_PROTOCOL_VERSION)).toBe(
|
||||
'SUCCESS'
|
||||
);
|
||||
expect(sep2106KeywordCheckStatus(true, LATEST_SPEC_VERSION)).toBe(
|
||||
'SUCCESS'
|
||||
);
|
||||
expect(sep2106KeywordCheckStatus(true, undefined)).toBe('SUCCESS');
|
||||
});
|
||||
|
||||
it('fails stripped keywords only when the server negotiated the draft version', () => {
|
||||
it('fails stripped keywords only when targeting the draft version', () => {
|
||||
expect(sep2106KeywordCheckStatus(false, DRAFT_PROTOCOL_VERSION)).toBe(
|
||||
'FAILURE'
|
||||
);
|
||||
expect(sep2106KeywordCheckStatus(false, LATEST_SPEC_VERSION)).toBe(
|
||||
'SKIPPED'
|
||||
);
|
||||
expect(sep2106KeywordCheckStatus(false, undefined)).toBe('SKIPPED');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
*/
|
||||
|
||||
import { ClientScenario, ConformanceCheck } from '../../types';
|
||||
import { connectToServer } from './client-helper';
|
||||
import type { RunContext } from '../../connection';
|
||||
import type {
|
||||
ListPromptsResult,
|
||||
GetPromptResult
|
||||
} from '../../spec-types/2025-06-18';
|
||||
|
||||
export class PromptsListScenario implements ClientScenario {
|
||||
name = 'prompts-list';
|
||||
|
|
@ -21,13 +25,13 @@ export class PromptsListScenario implements ClientScenario {
|
|||
- \`description\` (string)
|
||||
- \`arguments\` (array, optional) - list of required arguments`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const conn = await ctx.connect();
|
||||
|
||||
const result = await connection.client.listPrompts();
|
||||
const result = await conn.request<ListPromptsResult>('prompts/list');
|
||||
|
||||
// Validate response structure
|
||||
const errors: string[] = [];
|
||||
|
|
@ -64,7 +68,7 @@ export class PromptsListScenario implements ClientScenario {
|
|||
}
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
await conn.close();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'prompts-list',
|
||||
|
|
@ -109,13 +113,13 @@ Implement a prompt named \`test_simple_prompt\` with no arguments that returns:
|
|||
}
|
||||
\`\`\``;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const conn = await ctx.connect();
|
||||
|
||||
const result = await connection.client.getPrompt({
|
||||
const result = await conn.request<GetPromptResult>('prompts/get', {
|
||||
name: 'test_simple_prompt'
|
||||
});
|
||||
|
||||
|
|
@ -149,7 +153,7 @@ Implement a prompt named \`test_simple_prompt\` with no arguments that returns:
|
|||
}
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
await conn.close();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'prompts-get-simple',
|
||||
|
|
@ -198,13 +202,13 @@ Returns (with args \`{arg1: "hello", arg2: "world"}\`):
|
|||
}
|
||||
\`\`\``;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const conn = await ctx.connect();
|
||||
|
||||
const result = await connection.client.getPrompt({
|
||||
const result = await conn.request<GetPromptResult>('prompts/get', {
|
||||
name: 'test_prompt_with_arguments',
|
||||
arguments: {
|
||||
arg1: 'testValue1',
|
||||
|
|
@ -245,7 +249,7 @@ Returns (with args \`{arg1: "hello", arg2: "world"}\`):
|
|||
}
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
await conn.close();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'prompts-get-with-args',
|
||||
|
|
@ -304,13 +308,13 @@ Returns:
|
|||
}
|
||||
\`\`\``;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const conn = await ctx.connect();
|
||||
|
||||
const result = await connection.client.getPrompt({
|
||||
const result = await conn.request<GetPromptResult>('prompts/get', {
|
||||
name: 'test_prompt_with_embedded_resource',
|
||||
arguments: {
|
||||
resourceUri: 'test://example-resource'
|
||||
|
|
@ -351,7 +355,7 @@ Returns:
|
|||
}
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
await conn.close();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'prompts-get-embedded-resource',
|
||||
|
|
@ -404,13 +408,13 @@ Implement a prompt named \`test_prompt_with_image\` with no arguments that retur
|
|||
}
|
||||
\`\`\``;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const conn = await ctx.connect();
|
||||
|
||||
const result = await connection.client.getPrompt({
|
||||
const result = await conn.request<GetPromptResult>('prompts/get', {
|
||||
name: 'test_prompt_with_image'
|
||||
});
|
||||
|
||||
|
|
@ -448,7 +452,7 @@ Implement a prompt named \`test_prompt_with_image\` with no arguments that retur
|
|||
}
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
await conn.close();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'prompts-get-with-image',
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ import {
|
|||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types';
|
||||
import { connectToServer } from './client-helper';
|
||||
import { sendStatelessRequest } from './stateless-client';
|
||||
import {
|
||||
import { JsonRpcError, type RunContext } from '../../connection';
|
||||
import type {
|
||||
ListResourcesResult,
|
||||
ReadResourceResult,
|
||||
TextResourceContents,
|
||||
BlobResourceContents
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
BlobResourceContents,
|
||||
EmptyResult
|
||||
} from '../../spec-types/2025-06-18';
|
||||
|
||||
export class ResourcesListScenario implements ClientScenario {
|
||||
name = 'resources-list';
|
||||
|
|
@ -31,13 +33,13 @@ export class ResourcesListScenario implements ClientScenario {
|
|||
- \`description\` (string)
|
||||
- \`mimeType\` (string, optional)`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const conn = await ctx.connect();
|
||||
|
||||
const result = await connection.client.listResources();
|
||||
const result = await conn.request<ListResourcesResult>('resources/list');
|
||||
|
||||
// Validate response structure
|
||||
const errors: string[] = [];
|
||||
|
|
@ -73,7 +75,7 @@ export class ResourcesListScenario implements ClientScenario {
|
|||
}
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
await conn.close();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'resources-list',
|
||||
|
|
@ -116,13 +118,13 @@ Implement resource \`test://static-text\` that returns:
|
|||
}
|
||||
\`\`\``;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const conn = await ctx.connect();
|
||||
|
||||
const result = await connection.client.readResource({
|
||||
const result = await conn.request<ReadResourceResult>('resources/read', {
|
||||
uri: 'test://static-text'
|
||||
});
|
||||
|
||||
|
|
@ -160,7 +162,7 @@ Implement resource \`test://static-text\` that returns:
|
|||
}
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
await conn.close();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'resources-read-text',
|
||||
|
|
@ -203,13 +205,13 @@ Implement resource \`test://static-binary\` that returns:
|
|||
}
|
||||
\`\`\``;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const conn = await ctx.connect();
|
||||
|
||||
const result = await connection.client.readResource({
|
||||
const result = await conn.request<ReadResourceResult>('resources/read', {
|
||||
uri: 'test://static-binary'
|
||||
});
|
||||
|
||||
|
|
@ -245,7 +247,7 @@ Implement resource \`test://static-binary\` that returns:
|
|||
}
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
await conn.close();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'resources-read-binary',
|
||||
|
|
@ -292,13 +294,13 @@ Returns (for \`uri: "test://template/123/data"\`):
|
|||
}
|
||||
\`\`\``;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const conn = await ctx.connect();
|
||||
|
||||
const result = await connection.client.readResource({
|
||||
const result = await conn.request<ReadResourceResult>('resources/read', {
|
||||
uri: 'test://template/123/data'
|
||||
});
|
||||
|
||||
|
|
@ -347,7 +349,7 @@ Returns (for \`uri: "test://template/123/data"\`):
|
|||
}
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
await conn.close();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'resources-templates-read',
|
||||
|
|
@ -371,7 +373,10 @@ Returns (for \`uri: "test://template/123/data"\`):
|
|||
|
||||
export class ResourcesSubscribeScenario implements ClientScenario {
|
||||
name = 'resources-subscribe';
|
||||
readonly source = { introducedIn: '2025-06-18' } as const;
|
||||
readonly source = {
|
||||
introducedIn: '2025-06-18',
|
||||
removedIn: DRAFT_PROTOCOL_VERSION
|
||||
} as const;
|
||||
description = `Test subscribing to resource updates.
|
||||
|
||||
**Server Implementation Requirements:**
|
||||
|
|
@ -394,13 +399,13 @@ Example request:
|
|||
}
|
||||
\`\`\``;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const conn = await ctx.connect();
|
||||
|
||||
await connection.client.subscribeResource({
|
||||
await conn.request<EmptyResult>('resources/subscribe', {
|
||||
uri: 'test://watched-resource'
|
||||
});
|
||||
|
||||
|
|
@ -418,7 +423,7 @@ Example request:
|
|||
]
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
await conn.close();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'resources-subscribe',
|
||||
|
|
@ -473,7 +478,7 @@ Example error response:
|
|||
|
||||
This scenario does not require the server to register any specific resource — it tests behavior when reading a URI the server does not recognize.`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
const nonexistentUri =
|
||||
'test://nonexistent-resource-for-conformance-testing';
|
||||
|
|
@ -484,13 +489,12 @@ This scenario does not require the server to register any specific resource —
|
|||
}
|
||||
];
|
||||
|
||||
// SEP-2164 is a draft-spec requirement, so the request is sent statelessly
|
||||
// with the draft protocol version and the cross-cutting _meta/headers.
|
||||
let response;
|
||||
// SEP-2164 is a draft-spec requirement; under --spec-version draft the
|
||||
// RunContext-provided connect() resolves to the stateless impl, so the
|
||||
// request goes out with the draft protocol version and _meta/headers.
|
||||
let conn;
|
||||
try {
|
||||
response = await sendStatelessRequest(serverUrl, 'resources/read', {
|
||||
uri: nonexistentUri
|
||||
});
|
||||
conn = await ctx.connect();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'sep-2164-error-code',
|
||||
|
|
@ -505,10 +509,15 @@ This scenario does not require the server to register any specific resource —
|
|||
return checks;
|
||||
}
|
||||
|
||||
const result = response.body?.result as
|
||||
| { contents?: unknown[] }
|
||||
| undefined;
|
||||
const rpcError = response.body?.error;
|
||||
let caughtError: unknown;
|
||||
let result: { contents: unknown[] } | undefined;
|
||||
try {
|
||||
result = await conn.request<ReadResourceResult>('resources/read', {
|
||||
uri: nonexistentUri
|
||||
});
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
}
|
||||
|
||||
// Check 1: MUST NOT return an empty contents array
|
||||
const returnedEmptyContents =
|
||||
|
|
@ -532,12 +541,14 @@ This scenario does not require the server to register any specific resource —
|
|||
});
|
||||
|
||||
// Check 2: SHOULD return JSON-RPC error with code -32602
|
||||
const rpcError =
|
||||
caughtError instanceof JsonRpcError ? caughtError : undefined;
|
||||
const errorCode = rpcError?.code;
|
||||
let errorCodeMessage: string | undefined;
|
||||
if (result !== undefined) {
|
||||
errorCodeMessage = `Server returned a result instead of an error (contents length: ${result.contents?.length ?? 'undefined'}). Servers SHOULD return a JSON-RPC error for non-existent resources.`;
|
||||
} else if (!rpcError) {
|
||||
errorCodeMessage = `Expected a JSON-RPC error, got HTTP ${response.status} with no JSON-RPC error in the body.`;
|
||||
} else if (rpcError === undefined) {
|
||||
errorCodeMessage = `Expected a JSON-RPC error, got: ${caughtError instanceof Error ? caughtError.message : String(caughtError)}`;
|
||||
} else if (errorCode !== -32602) {
|
||||
errorCodeMessage =
|
||||
`Expected error code -32602 (Invalid Params), got ${errorCode}. ` +
|
||||
|
|
@ -585,13 +596,17 @@ This scenario does not require the server to register any specific resource —
|
|||
}
|
||||
});
|
||||
|
||||
await conn.close();
|
||||
return checks;
|
||||
}
|
||||
}
|
||||
|
||||
export class ResourcesUnsubscribeScenario implements ClientScenario {
|
||||
name = 'resources-unsubscribe';
|
||||
readonly source = { introducedIn: '2025-06-18' } as const;
|
||||
readonly source = {
|
||||
introducedIn: '2025-06-18',
|
||||
removedIn: DRAFT_PROTOCOL_VERSION
|
||||
} as const;
|
||||
description = `Test unsubscribing from resource.
|
||||
|
||||
**Server Implementation Requirements:**
|
||||
|
|
@ -604,19 +619,19 @@ export class ResourcesUnsubscribeScenario implements ClientScenario {
|
|||
- Stop sending update notifications for that URI
|
||||
- Return empty object \`{}\``;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const conn = await ctx.connect();
|
||||
|
||||
// First subscribe
|
||||
await connection.client.subscribeResource({
|
||||
await conn.request<EmptyResult>('resources/subscribe', {
|
||||
uri: 'test://watched-resource'
|
||||
});
|
||||
|
||||
// Then unsubscribe
|
||||
await connection.client.unsubscribeResource({
|
||||
await conn.request<EmptyResult>('resources/unsubscribe', {
|
||||
uri: 'test://watched-resource'
|
||||
});
|
||||
|
||||
|
|
@ -634,7 +649,7 @@ export class ResourcesUnsubscribeScenario implements ClientScenario {
|
|||
]
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
await conn.close();
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'resources-unsubscribe',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@
|
|||
* Multiple concurrent streams are achieved via POST requests, each getting their own stream.
|
||||
*/
|
||||
|
||||
import { ClientScenario, ConformanceCheck } from '../../types.js';
|
||||
import {
|
||||
ClientScenario,
|
||||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types.js';
|
||||
import { buildStandardHeaders, type RunContext } from '../../connection';
|
||||
import { EventSourceParserStream } from 'eventsource-parser/stream';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
|
|
@ -20,55 +25,92 @@ export class ServerSSEMultipleStreamsScenario implements ClientScenario {
|
|||
description =
|
||||
'Test server supports multiple concurrent POST SSE streams (SEP-1699)';
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl, specVersion } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
// Concurrent POSTs (each answered with JSON or its own SSE stream) are
|
||||
// core transport behavior in every spec version. Only the request
|
||||
// scaffolding differs: the stateful lifecycle needs a session id from an
|
||||
// initialize handshake, the stateless lifecycle carries _meta + the
|
||||
// MCP-Protocol-Version header on each request instead.
|
||||
const stateless = specVersion === DRAFT_PROTOCOL_VERSION;
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let client: Client | undefined;
|
||||
let transport: StreamableHTTPClientTransport | undefined;
|
||||
|
||||
try {
|
||||
// Step 1: Initialize session with the server
|
||||
client = new Client(
|
||||
{
|
||||
name: 'conformance-test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
sampling: {},
|
||||
elicitation: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
await client.connect(transport);
|
||||
|
||||
// Extract session ID from transport
|
||||
sessionId = (transport as unknown as { sessionId?: string }).sessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
checks.push({
|
||||
id: 'server-sse-multiple-streams-session',
|
||||
name: 'ServerSSEMultipleStreamsSession',
|
||||
description: 'Server provides session ID for multiple streams test',
|
||||
status: 'WARNING',
|
||||
timestamp: new Date().toISOString(),
|
||||
specReferences: [
|
||||
{
|
||||
id: 'SEP-1699',
|
||||
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
|
||||
if (!stateless) {
|
||||
// Step 1 (stateful): Initialize session with the server
|
||||
client = new Client(
|
||||
{
|
||||
name: 'conformance-test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
sampling: {},
|
||||
elicitation: {}
|
||||
}
|
||||
],
|
||||
details: {
|
||||
message:
|
||||
'Server did not provide session ID - multiple streams test may not work correctly'
|
||||
}
|
||||
});
|
||||
return checks;
|
||||
);
|
||||
|
||||
transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
await client.connect(transport);
|
||||
|
||||
// Extract session ID from transport
|
||||
sessionId = (transport as unknown as { sessionId?: string }).sessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
checks.push({
|
||||
id: 'server-sse-multiple-streams-session',
|
||||
name: 'ServerSSEMultipleStreamsSession',
|
||||
description: 'Server provides session ID for multiple streams test',
|
||||
status: 'WARNING',
|
||||
timestamp: new Date().toISOString(),
|
||||
specReferences: [
|
||||
{
|
||||
id: 'SEP-1699',
|
||||
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699'
|
||||
}
|
||||
],
|
||||
details: {
|
||||
message:
|
||||
'Server did not provide session ID - multiple streams test may not work correctly'
|
||||
}
|
||||
});
|
||||
return checks;
|
||||
}
|
||||
}
|
||||
|
||||
// Stateless requests carry the full SEP-2243 header set (Mcp-Method,
|
||||
// MCP-Protocol-Version, ...) so a strictly-conformant server doesn't
|
||||
// reject them before the multi-stream behaviour under test is exercised.
|
||||
const requestHeaders: Record<string, string> = stateless
|
||||
? buildStandardHeaders('tools/list', undefined, {
|
||||
headers: { Accept: 'text/event-stream, application/json' },
|
||||
specVersion
|
||||
})
|
||||
: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream, application/json',
|
||||
'mcp-session-id': sessionId!,
|
||||
'mcp-protocol-version': '2025-03-26'
|
||||
};
|
||||
const requestParams = stateless
|
||||
? {
|
||||
_meta: {
|
||||
'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION,
|
||||
'io.modelcontextprotocol/clientInfo': {
|
||||
name: 'conformance-test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
'io.modelcontextprotocol/clientCapabilities': {}
|
||||
}
|
||||
}
|
||||
: {};
|
||||
|
||||
// Step 2: Open multiple POST SSE streams concurrently
|
||||
// Each POST request gets its own stream with unique streamId
|
||||
// Spec says: "The client MAY remain connected to multiple SSE streams simultaneously"
|
||||
|
|
@ -80,17 +122,12 @@ export class ServerSSEMultipleStreamsScenario implements ClientScenario {
|
|||
for (let i = 0; i < numStreams; i++) {
|
||||
const promise = fetch(serverUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream, application/json',
|
||||
'mcp-session-id': sessionId,
|
||||
'mcp-protocol-version': '2025-03-26'
|
||||
},
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1000 + i, // Different request IDs for each stream
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
params: requestParams
|
||||
})
|
||||
});
|
||||
postPromises.push(promise);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@
|
|||
* - Replaying events when client reconnects with Last-Event-ID
|
||||
*/
|
||||
|
||||
import { ClientScenario, ConformanceCheck } from '../../types.js';
|
||||
import {
|
||||
ClientScenario,
|
||||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types.js';
|
||||
import type { RunContext } from '../../connection';
|
||||
import { EventSourceParserStream } from 'eventsource-parser/stream';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
|
|
@ -67,11 +72,15 @@ function createLoggingFetch(checks: ConformanceCheck[]) {
|
|||
|
||||
export class ServerSSEPollingScenario implements ClientScenario {
|
||||
name = 'server-sse-polling';
|
||||
readonly source = { introducedIn: '2025-11-25' } as const;
|
||||
readonly source = {
|
||||
introducedIn: '2025-11-25',
|
||||
removedIn: DRAFT_PROTOCOL_VERSION
|
||||
} as const;
|
||||
description =
|
||||
'Test server SSE polling via test_reconnection tool that closes stream mid-call (SEP-1699)';
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
||||
const { serverUrl } = ctx;
|
||||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
let sessionId: string | undefined;
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
/**
|
||||
* Unit tests for the shared stateless request helper (SEP-2575 + SEP-2243):
|
||||
* standard header defaults/overrides, `_meta` injection, and JSON parsing.
|
||||
*/
|
||||
import http from 'http';
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import {
|
||||
buildStandardHeaders,
|
||||
withRequestMeta,
|
||||
sendStatelessRequest,
|
||||
CONFORMANCE_CLIENT_INFO,
|
||||
DEFAULT_CLIENT_CAPABILITIES
|
||||
} from './stateless-client';
|
||||
import { DRAFT_PROTOCOL_VERSION } from '../../types';
|
||||
|
||||
describe('buildStandardHeaders', () => {
|
||||
test('sets the standard headers pinned to the draft protocol version', () => {
|
||||
const headers = buildStandardHeaders('tools/list');
|
||||
expect(headers['Mcp-Method']).toBe('tools/list');
|
||||
expect(headers['MCP-Protocol-Version']).toBe(DRAFT_PROTOCOL_VERSION);
|
||||
expect(headers['Content-Type']).toBe('application/json');
|
||||
expect(headers.Accept).toContain('application/json');
|
||||
expect(headers.Accept).toContain('text/event-stream');
|
||||
expect(headers['Mcp-Name']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('sets Mcp-Name from params.name (tools/call) and params.uri (resources/read)', () => {
|
||||
expect(
|
||||
buildStandardHeaders('tools/call', { name: 'echo' })['Mcp-Name']
|
||||
).toBe('echo');
|
||||
expect(
|
||||
buildStandardHeaders('resources/read', { uri: 'file:///a.txt' })[
|
||||
'Mcp-Name'
|
||||
]
|
||||
).toBe('file:///a.txt');
|
||||
});
|
||||
|
||||
test('overrides replace defaults case-insensitively', () => {
|
||||
const headers = buildStandardHeaders('tools/list', undefined, {
|
||||
headers: { 'mcp-protocol-version': '2025-06-18' }
|
||||
});
|
||||
expect(headers['MCP-Protocol-Version']).toBeUndefined();
|
||||
expect(headers['mcp-protocol-version']).toBe('2025-06-18');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withRequestMeta', () => {
|
||||
test('injects the required _meta fields', () => {
|
||||
const params = withRequestMeta({ name: 'echo' });
|
||||
const meta = params._meta as Record<string, unknown>;
|
||||
expect(meta['io.modelcontextprotocol/protocolVersion']).toBe(
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
);
|
||||
expect(meta['io.modelcontextprotocol/clientInfo']).toEqual(
|
||||
CONFORMANCE_CLIENT_INFO
|
||||
);
|
||||
expect(meta['io.modelcontextprotocol/clientCapabilities']).toEqual(
|
||||
DEFAULT_CLIENT_CAPABILITIES
|
||||
);
|
||||
expect(params.name).toBe('echo');
|
||||
});
|
||||
|
||||
test('keys already present in params._meta win over the defaults', () => {
|
||||
const params = withRequestMeta({
|
||||
_meta: { 'io.modelcontextprotocol/protocolVersion': '2025-06-18' }
|
||||
});
|
||||
const meta = params._meta as Record<string, unknown>;
|
||||
expect(meta['io.modelcontextprotocol/protocolVersion']).toBe('2025-06-18');
|
||||
expect(meta['io.modelcontextprotocol/clientInfo']).toEqual(
|
||||
CONFORMANCE_CLIENT_INFO
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendStatelessRequest', () => {
|
||||
test('parses a plain JSON response', async () => {
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
const request = JSON.parse(body);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: { tools: [] }
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
try {
|
||||
const response = await sendStatelessRequest(
|
||||
`http://localhost:${port}/`,
|
||||
'tools/list'
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body?.result).toEqual({ tools: [] });
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
/**
|
||||
* Stateless request helpers for server scenarios (SEP-2575 + SEP-2243).
|
||||
*
|
||||
* Every request the harness sends to a server under test carries the
|
||||
* cross-cutting obligations regardless of what a scenario actually tests:
|
||||
* the standard headers (MCP-Protocol-Version, Mcp-Method, Mcp-Name, Accept)
|
||||
* and the `_meta` fields (protocolVersion, clientInfo, clientCapabilities),
|
||||
* pinned to `DRAFT_PROTOCOL_VERSION`. Scenarios exercising these SEPs MUST
|
||||
* build their requests through these helpers so a strictly-conformant server
|
||||
* never rejects harness traffic for reasons unrelated to the behaviour under
|
||||
* test (issues #311, #312, #315).
|
||||
*/
|
||||
|
||||
import { DRAFT_PROTOCOL_VERSION } from '../../types';
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: number | string | null;
|
||||
result?: Record<string, unknown>;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
export const CONFORMANCE_CLIENT_INFO = {
|
||||
name: 'conformance-test-client',
|
||||
version: '1.0.0'
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||
sampling: {},
|
||||
elicitation: {},
|
||||
roots: { listChanged: true }
|
||||
} as const;
|
||||
|
||||
export interface StatelessResponse {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
contentType?: string;
|
||||
/** The parsed JSON-RPC message (for SSE: the event matching the request id). */
|
||||
body?: JsonRpcResponse;
|
||||
/** All parsed events when the response was an SSE / chunked stream. */
|
||||
events?: unknown[];
|
||||
/** Raw response text when it could not be parsed as JSON. */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
let nextRequestId = 1;
|
||||
|
||||
/**
|
||||
* The `Mcp-Name` source field per SEP-2243: `params.name` for tools/call and
|
||||
* prompts/get, `params.uri` for resources/read; absent otherwise.
|
||||
*/
|
||||
export function mcpNameForRequest(
|
||||
method: string,
|
||||
params?: Record<string, unknown>
|
||||
): string | undefined {
|
||||
if (method === 'tools/call' || method === 'prompts/get') {
|
||||
return typeof params?.name === 'string' ? params.name : undefined;
|
||||
}
|
||||
if (method === 'resources/read') {
|
||||
return typeof params?.uri === 'string' ? params.uri : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the conformant header set for a stateless request: Content-Type,
|
||||
* Accept (both content types), MCP-Protocol-Version, Mcp-Method and (when the
|
||||
* method carries one) Mcp-Name. `options.headers` overrides or extends the
|
||||
* defaults, replacing any default whose name matches case-insensitively.
|
||||
*/
|
||||
export function buildStandardHeaders(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
options: { headers?: Record<string, string> } = {}
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION,
|
||||
'Mcp-Method': method
|
||||
};
|
||||
const name = mcpNameForRequest(method, params);
|
||||
if (name !== undefined) {
|
||||
headers['Mcp-Name'] = name;
|
||||
}
|
||||
|
||||
if (options.headers) {
|
||||
for (const [key, value] of Object.entries(options.headers)) {
|
||||
// Replace any default that differs only by case, then set the override.
|
||||
for (const existing of Object.keys(headers)) {
|
||||
if (existing.toLowerCase() === key.toLowerCase()) {
|
||||
delete headers[existing];
|
||||
}
|
||||
}
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge params with the conformant `_meta` required on every stateless
|
||||
* request. Keys already present in `params._meta` win over the defaults.
|
||||
*/
|
||||
export function withRequestMeta(
|
||||
params?: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...params,
|
||||
_meta: {
|
||||
'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION,
|
||||
'io.modelcontextprotocol/clientInfo': CONFORMANCE_CLIENT_INFO,
|
||||
'io.modelcontextprotocol/clientCapabilities': DEFAULT_CLIENT_CAPABILITIES,
|
||||
...(params?._meta as Record<string, unknown> | undefined)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isJsonRpcResponseShaped(event: unknown): event is JsonRpcResponse {
|
||||
return (
|
||||
typeof event === 'object' &&
|
||||
event !== null &&
|
||||
('result' in event || 'error' in event)
|
||||
);
|
||||
}
|
||||
|
||||
function parseSseLineInto(events: unknown[], rawLine: string): void {
|
||||
const line = rawLine.trim();
|
||||
if (!line) return;
|
||||
const jsonText = line.startsWith('data:')
|
||||
? line.replace(/^data:\s*/, '')
|
||||
: line;
|
||||
try {
|
||||
events.push(JSON.parse(jsonText));
|
||||
} catch {
|
||||
// Non-JSON line (comments, partial frames) — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an SSE / chunked-stream response incrementally and resolve as soon as
|
||||
* the JSON-RPC response matching `requestId` arrives, without waiting for the
|
||||
* stream to close. If the stream ends (or is aborted) first, returns whatever
|
||||
* events were parsed, with `body` set to the last response-shaped event.
|
||||
*/
|
||||
export async function readSseJsonRpcResponse(
|
||||
res: Response,
|
||||
requestId: number | string | null
|
||||
): Promise<{ events: unknown[]; body?: JsonRpcResponse }> {
|
||||
const events: unknown[] = [];
|
||||
const matchesRequest = (event: unknown): event is JsonRpcResponse =>
|
||||
isJsonRpcResponseShaped(event) && event.id === requestId;
|
||||
const finish = (): { events: unknown[]; body?: JsonRpcResponse } => {
|
||||
const match = events.find(matchesRequest);
|
||||
const lastResponseShaped = [...events]
|
||||
.reverse()
|
||||
.find(isJsonRpcResponseShaped);
|
||||
return { events, body: match ?? lastResponseShaped };
|
||||
};
|
||||
|
||||
if (!res.body) return finish();
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
let value: Uint8Array | undefined;
|
||||
let done = false;
|
||||
try {
|
||||
({ value, done } = await reader.read());
|
||||
} catch {
|
||||
// The stream was aborted (timeout) or dropped — return what arrived.
|
||||
break;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) parseSseLineInto(events, line);
|
||||
|
||||
if (events.some(matchesRequest)) {
|
||||
// The response we were waiting for arrived; stop reading the stream.
|
||||
await reader.cancel().catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
parseSseLineInto(events, buffer);
|
||||
buffer = '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Lock already released (e.g. after cancel) — nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
return finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single stateless JSON-RPC request with the full set of cross-cutting
|
||||
* headers and `_meta`. Handles both JSON and SSE responses.
|
||||
*/
|
||||
export async function sendStatelessRequest(
|
||||
serverUrl: string,
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
options: { headers?: Record<string, string>; timeoutMs?: number } = {}
|
||||
): Promise<StatelessResponse> {
|
||||
const id = nextRequestId++;
|
||||
const headers = buildStandardHeaders(method, params, {
|
||||
headers: options.headers
|
||||
});
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
params: withRequestMeta(params)
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.timeoutMs ?? 10000
|
||||
);
|
||||
try {
|
||||
const res = await fetch(serverUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
const contentType = res.headers.get('content-type') ?? undefined;
|
||||
|
||||
if (contentType?.includes('text/event-stream')) {
|
||||
// Read the stream incrementally and resolve on the matching response —
|
||||
// a server that keeps the stream open must not stall the harness.
|
||||
const { events, body: matched } = await readSseJsonRpcResponse(res, id);
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
contentType,
|
||||
events,
|
||||
body: matched
|
||||
};
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
try {
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
contentType,
|
||||
body: text ? (JSON.parse(text) as JsonRpcResponse) : undefined
|
||||
};
|
||||
} catch {
|
||||
return { status: res.status, headers: res.headers, contentType, text };
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
// Tear down any still-open SSE stream so sockets don't linger.
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue