conformance/src/scenarios/server/stateless.ts
Paul Carleton 076b417b23
Make the draft-spec scenarios internally consistent (fixes #311, #312, #315) (#319)
* feat(everything-server): serve resources and caching hints on the stateless draft path

The stateless (SEP-2575) request path now answers resources/list,
resources/templates/list and resources/read — including the SEP-2164
-32602 + data.uri error for unknown URIs — and includes the SEP-2549
ttlMs/cacheScope hints on its cacheable list results, matching what the
draft-spec scenarios exercise.

* feat: make the harness's own draft-spec traffic conformant

Fixes the class of issues where a strictly-conformant SDK rejects the
harness's requests for reasons unrelated to the behaviour under test
(#311, #312, #315).

- add a shared stateless draft request helper (draft-client.ts) that puts
  the cross-cutting SEP-2243 headers (Mcp-Method, Mcp-Name,
  MCP-Protocol-Version, Accept) and SEP-2575 _meta (protocolVersion,
  clientInfo, clientCapabilities) on every request, with overrides for
  negative cases and the SHOULD-level retry on
  UnsupportedProtocolVersionError
- migrate the draft scenarios onto it: the SEP-2322 helpers and
  server-stateless now send Mcp-Method/Mcp-Name (#312); the SEP-2243
  server scenarios drop the legacy initialize handshake and carry _meta on
  every raw request (#311); caching and sep-2164-resource-not-found are
  stateless DRAFT-2026-v1 requests instead of an SDK 2025-11-25 session
  (#315)
- add a draft self-conformance gate (draft-self-conformance.test.ts) that
  points the harness's own draft drivers at the existing client-testing
  judge scenarios (request-metadata, http-standard-headers) and requires
  zero failures or warnings
- align the request-metadata mock's unsupported-version rejection with the
  draft schema (-32004) and teach the everything-client to recognize it
- rewrite the SEP-2549/SEP-2164 negative example servers as stateless
  draft servers so they keep exercising the violation, not the transport

* refactor: rename the stateless request helpers and harden them per review

- rename draft-client.ts to stateless-client.ts and name the helpers by
  feature (sendStatelessRequest, buildStandardHeaders, buildRequestMeta,
  withRequestMeta, CONFORMANCE_CLIENT_INFO); 'draft' now only appears in
  DRAFT_PROTOCOL_VERSION, the one name that tracks the moving target
- retry only genuine UnsupportedProtocolVersionError rejections (-32004,
  tolerating -32001/-32602) and only with recognized versions; record the
  retry on the response
- parse text/event-stream responses incrementally and resolve on the
  matching event instead of blocking until the timeout
- keep the MCP-Protocol-Version header and _meta protocolVersion in sync
  when the version is overridden via meta overrides
- make server-stateless's sendRpc SSE-tolerant
- emit explicit setup failures in the SEP-2243 server scenarios when the
  tools/list discovery fails instead of misleading per-case results
- latch the worst status per check in the request-metadata judge so an
  early violation cannot be masked by a later conformant request
- rename the gate to harness-traffic-conformance.test.ts and derive its
  driver list from the draft suite with documented per-judge exclusions
  (35 pairings)
- everything-server: advertise resources in stateless server/discover and
  use -32004 for unsupported protocol version
- add unit tests for the helper (retry guard, SSE handling, header/_meta
  version sync, default headers)

* refactor: slim the stateless client helper for the initial merge
2026-05-26 21:10:23 +01:00

1161 lines
40 KiB
TypeScript

/**
* Stateless MCP test scenarios for MCP servers (SEP-2575)
*/
import {
ClientScenario,
ConformanceCheck,
DRAFT_PROTOCOL_VERSION
} from '../../types';
import {
buildStandardHeaders,
readSseJsonRpcResponse
} from './stateless-client';
const SPEC_REF = [
{
id: 'SEP-2575',
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575'
}
];
export class ServerStatelessScenario implements ClientScenario {
name = 'server-stateless';
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
description = `Test stateless MCP server architecture (SEP-2575).
**Server Implementation Requirements:**
**Endpoints**:
- \`server/discover\`: Returns supportedVersions, capabilities, and serverInfo metadata.
- \`tools/call\`: Implement structural test tools like \`test_missing_capability\` requiring explicit capabilities in \`_meta\`.
**Grouped Specification Requirements**:
1. **Per-Request _meta Validation (4 Checks)**
- Rejects requests missing \`_meta\` or lacking structural required internal subfields (\`protocolVersion\`, \`clientInfo\`, \`clientCapabilities\`) with a JSON-RPC \`-32602 Invalid params\` error signature.
2. **Discovery & Capabilities (3 Checks)**
- Implements \`server/discover\` mapping exact mandatory protocol elements.
- Dynamically checks prompt capability declaration constraints, validates that active RPC handlers match advertised discovery capacities.
3. **Version Negotiation & Headers (3 Checks)**
- Mismatched or unknown protocol versions must return an \`UnsupportedProtocolVersionError\` (HTTP status code \`400 Bad Request\`) carrying precise version tracking arrays.
- Absent or altered protocol version header metadata must trigger a \`-32001 Header Mismatch\` error with an HTTP 400 boundary state.
4. **Client Capability Constraints (2 Checks)**
- Accessing platform capabilities without explicit declaration drops requests with a \`-32003 MissingRequiredClientCapabilityError\` containing needed capabilities, returning an HTTP status code \`400 Bad Request\`.
5. **Methods & Routing Mechanics (5 Checks)**
- Removed legacy endpoints (\`initialize\`, \`ping\`, \`logging/setLevel\`, etc.) or generic unknown methods must cleanly yield an HTTP status code \`404 Not Found\` alongside a JSON-RPC \`-32601 Method not found\` payload. All error returns must preserve original request ID mappings.
- Validates response streams contain only \`IncompleteResult\` chunks and never independent top-level JSON-RPC requests, while enforcing that no log messages are emitted when \`_meta.../logLevel\` is omitted.
6. **Subscription Streams & Filtering (3 Checks)**
- Mandates that \`notifications/subscriptions/acknowledged\` is the first message on a \`subscriptions/listen\` stream, and that subsequent notifications carry a matching \`_meta.../subscriptionId\`.
- Verifies strict containment where servers do not dispatch notification types that fall outside the client's explicit requested subscription filter list.
7. **Dynamic List Mutations (2 Checks)**
- Evaluates that list-changed capable servers notify active listen streams with \`promptsListChanged: true\` or \`toolsListChanged: true\` upon live configuration or capability modifications. `;
async run(serverUrl: string): Promise<ConformanceCheck[]> {
const checks: ConformanceCheck[] = [];
const timestamp = new Date().toISOString();
// Executes a validation rule and pushes the structural result metadata.
async function runCheck(
id: string,
name: string,
description: string,
fn: () =>
| Promise<{
error?: string;
warning?: boolean;
skipped?: boolean;
details?: any;
} | void>
| ({
error?: string;
warning?: boolean;
skipped?: boolean;
details?: any;
} | void),
fallbackDetails = {}
) {
try {
const result = await fn();
const errorMessage = result?.error;
// SHOULD-level requirements report WARNING rather than FAILURE
// (severity follows the spec keyword).
const status = errorMessage
? result?.warning
? 'WARNING'
: 'FAILURE'
: result?.skipped
? 'SKIPPED'
: 'SUCCESS';
checks.push({
id,
name,
description,
status,
timestamp,
errorMessage: errorMessage || undefined,
specReferences: SPEC_REF,
details: result?.details || fallbackDetails
});
} catch (e) {
checks.push({
id,
name,
description,
status: 'FAILURE',
timestamp,
errorMessage: String(e),
specReferences: SPEC_REF,
details: fallbackDetails
});
}
}
// Helper to send raw RPC requests via fetch
const sendRpc = async (
method: string,
params?: any,
headersOverrides?: Record<string, string>,
id: string | number | null = 1
) => {
// The cross-cutting SEP-2243 headers (Mcp-Method, Mcp-Name, Accept,
// MCP-Protocol-Version) are not what this scenario exercises, so they
// are always sent conformantly; overrides only alter the dimension a
// test case is about (issue #312).
const headers = buildStandardHeaders(method, params, {
headers: headersOverrides
});
const body = JSON.stringify({
jsonrpc: '2.0',
id,
method,
...(params !== undefined ? { params } : {})
});
const res = await fetch(serverUrl, { method: 'POST', headers, body });
let data: any = null;
// Servers may answer single requests over text/event-stream; pick the
// JSON-RPC message matching this request id instead of failing to parse
// the stream as JSON.
const contentType =
typeof res.headers?.get === 'function'
? (res.headers.get('content-type') ?? '')
: '';
if (contentType.includes('text/event-stream')) {
const { body: matched } = await readSseJsonRpcResponse(res, id);
data = matched ?? null;
} else {
try {
data = await res.json();
} catch {
// Response might not be JSON
}
}
return { res, data };
};
// Helper to read multi-frame streaming endpoints (like subscriptions/listen).
// `onFirstFrame` runs once after the first frame arrives (i.e. after the
// server has acknowledged the subscription) so callers can trigger
// side effects on a separate connection while this stream is still open.
const listenToStream = async (
method: string,
params?: any,
maxFrames = 3,
timeoutMs = 1000,
onFirstFrame?: () => Promise<void>
): Promise<any[]> => {
const headers = buildStandardHeaders(method, params);
const body = JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method,
...(params !== undefined ? { params } : {})
});
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const res = await fetch(serverUrl, {
method: 'POST',
headers,
body,
signal: controller.signal
});
if (!res.body) {
clearTimeout(timeoutId);
return [];
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
const frames: any[] = [];
let buffer = '';
let firstFrameCallbackFired = false;
const maybeFireFirstFrameCallback = async () => {
if (firstFrameCallbackFired || frames.length === 0 || !onFirstFrame)
return;
firstFrameCallbackFired = true;
try {
await onFirstFrame();
} catch {
// The trigger failed; the caller inspects its own captured result.
}
};
try {
while (frames.length < maxFrames) {
let value: Uint8Array | undefined;
let done = false;
try {
({ value, done } = await reader.read());
} catch {
// The stream was aborted (timeout) or dropped. A compliant
// server holds a subscriptions/listen stream open indefinitely,
// so hitting the timeout is the normal way these reads end —
// return whatever frames arrived before it fired.
break;
}
if (value) {
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() || '';
for (const line of lines) {
const cleanLine = line.trim();
if (!cleanLine) continue;
const jsonText = cleanLine.startsWith('data:')
? cleanLine.replace(/^data:\s*/, '')
: cleanLine;
try {
frames.push(JSON.parse(jsonText));
} catch {
// Keep segment buffering
}
}
await maybeFireFirstFrameCallback();
}
if (done) {
const finalLine = buffer.trim();
if (finalLine) {
const jsonText = finalLine.startsWith('data:')
? finalLine.replace(/^data:\s*/, '')
: finalLine;
try {
frames.push(JSON.parse(jsonText));
} catch {
// Trailing formatting mismatch
}
}
break;
}
}
} finally {
reader.releaseLock();
}
clearTimeout(timeoutId);
return frames;
} catch {
clearTimeout(timeoutId);
return [];
}
};
const validMeta = {
'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION,
'io.modelcontextprotocol/clientInfo': {
name: 'conformance-client',
version: '1.0.0'
},
'io.modelcontextprotocol/clientCapabilities': {}
};
// Helper to check JSON-RPC ID matching on error responses
const checkErrorId = (data: any, expectedId: string | number) => {
if (data && data.error) {
if (data.id !== expectedId) {
checks.push({
id: 'sep-2575-http-server-error-jsonrpc-id',
name: 'HttpServerErrorJsonrpcId',
description: 'All error responses carry the request JSON-RPC id',
status: 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: `Expected error response id ${expectedId}, got ${data.id}`,
specReferences: SPEC_REF
});
}
}
};
// ==========================================
// 1. Per-request _meta Validation (4 Checks)
// ==========================================
const metaValidationTestCases = [
{
slug: 'missing-meta',
description:
'Rejects request with missing _meta with -32602 Invalid params',
params: {},
rpcId: 101
},
{
slug: 'missing-protocol-version',
description:
'Rejects request with _meta missing io.modelcontextprotocol/protocolVersion',
params: {
_meta: {
'io.modelcontextprotocol/clientInfo':
validMeta['io.modelcontextprotocol/clientInfo'],
'io.modelcontextprotocol/clientCapabilities':
validMeta['io.modelcontextprotocol/clientCapabilities']
}
},
rpcId: 102
},
{
slug: 'missing-client-info',
description:
'Rejects request with _meta missing io.modelcontextprotocol/clientInfo',
params: {
_meta: {
'io.modelcontextprotocol/protocolVersion':
validMeta['io.modelcontextprotocol/protocolVersion'],
'io.modelcontextprotocol/clientCapabilities':
validMeta['io.modelcontextprotocol/clientCapabilities']
}
},
rpcId: 103
},
{
slug: 'missing-client-capabilities',
description:
'Rejects request with _meta missing io.modelcontextprotocol/clientCapabilities',
params: {
_meta: {
'io.modelcontextprotocol/protocolVersion':
validMeta['io.modelcontextprotocol/protocolVersion'],
'io.modelcontextprotocol/clientInfo':
validMeta['io.modelcontextprotocol/clientInfo']
}
},
rpcId: 104
}
];
for (const testCase of metaValidationTestCases) {
await runCheck(
`sep-2575-request-meta-invalid-${testCase.slug}`,
'RequestMetaInvalid',
testCase.description,
async () => {
const { data } = await sendRpc(
'server/discover',
testCase.params,
undefined,
testCase.rpcId
);
checkErrorId(data, testCase.rpcId);
if (data?.error?.code !== -32602) {
return {
error: `Expected error code -32602, got ${data?.error?.code}`,
details: { fieldIssue: testCase.slug, response: data }
};
}
return { details: { fieldIssue: testCase.slug, response: data } };
},
{ fieldIssue: testCase.slug }
);
}
// ==========================================
// 2. Discovery & Capabilities (4 Checks)
// ==========================================
let discoverSupportedVersions: string[] = [];
let discoverCapabilities: any = {};
let discoverResult: any = null;
let discoverRpcError: any = null;
try {
const { data } = await sendRpc(
'server/discover',
{ _meta: validMeta },
undefined,
201
);
discoverResult = data?.result;
if (Array.isArray(discoverResult?.supportedVersions)) {
discoverSupportedVersions = discoverResult.supportedVersions;
}
if (
discoverResult?.capabilities &&
typeof discoverResult.capabilities === 'object'
) {
discoverCapabilities = discoverResult.capabilities;
}
} catch (e) {
discoverRpcError = e;
}
await runCheck(
'sep-2575-server-implements-discover',
'ServerImplementsDiscover',
'Servers MUST implement server/discover.',
() => {
if (discoverRpcError)
return { error: `Discovery failed: ${discoverRpcError.message}` };
if (
!discoverResult?.supportedVersions ||
!discoverResult?.capabilities ||
!discoverResult?.serverInfo
) {
return {
error: 'Missing mandatory fields in discover response setup',
details: { result: discoverResult }
};
}
return { details: { result: discoverResult } };
}
);
await runCheck(
'sep-2575-server-declares-prompts-in-discover',
'ServerDeclaresPromptsInDiscover',
'Servers that support prompts MUST declare the prompts capability in their DiscoverResult.',
async () => {
if (discoverRpcError)
return { error: `Prerequisite missing: ${discoverRpcError.message}` };
const { data: promptsData } = await sendRpc(
'prompts/list',
{ _meta: validMeta },
undefined,
203
);
const methodExists =
promptsData?.result?.prompts || promptsData?.error?.code !== -32601;
if (methodExists && !discoverCapabilities.prompts) {
return {
error:
'Server handles prompts but did not declare prompts capability in discover result',
details: { discoverCapabilities }
};
}
return { details: { discoverCapabilities, response: promptsData } };
}
);
// Dynamic verification helper to check capability consistency against true handlers
await runCheck(
'sep-2575-discover-capabilities-match-handlers',
'DiscoverCapabilitiesMatchHandlers',
'capabilities matches what the server honors on real RPC calls',
async () => {
if (discoverRpcError)
return {
error: `Discovery runtime check failed: ${discoverRpcError.message}`
};
const { data: toolsData } = await sendRpc(
'tools/list',
{ _meta: validMeta },
undefined,
202
);
if (discoverCapabilities.tools) {
const toolsPassed = Array.isArray(toolsData?.result?.tools);
if (!toolsPassed)
return {
error: 'Advertised tools capability but tools/list call failed',
details: { response: toolsData }
};
} else {
if (toolsData?.error?.code !== -32601)
return {
error:
'Did not advertise tools capability but tools/list did not yield -32601',
details: { response: toolsData }
};
}
return { details: { response: toolsData } };
}
);
// ==========================================
// 3. Version Negotiation & Headers (3 Checks)
// ==========================================
const unsupportedMeta = {
...validMeta,
'io.modelcontextprotocol/protocolVersion': 'v999.0.0'
};
const response301 = await sendRpc(
'server/discover',
{ _meta: unsupportedMeta },
{ 'MCP-Protocol-Version': 'v999.0.0' },
301
).catch(() => null);
const res301: any = response301?.res ?? null;
const data301: any = response301?.data ?? null;
if (data301) checkErrorId(data301, 301);
await runCheck(
'sep-2575-server-unsupported-version-error',
'ServerUnsupportedVersionError',
'If the server does not implement the requested version (whether the version is unknown to the server, or is a known version the server has chosen not to support), it MUST respond with an UnsupportedProtocolVersionError listing the versions it does support.',
() => {
if (!data301)
return { error: 'Unsupported version invocation failed completely' };
const errSupportedVersions = data301?.error?.data?.supported;
const hasErrVersions =
Array.isArray(errSupportedVersions) &&
errSupportedVersions.length > 0;
const validMatch =
hasErrVersions &&
errSupportedVersions.every((v: string) =>
discoverSupportedVersions.includes(v)
);
if (!validMatch)
return {
error: `Returned supported versions data layout does not correlate to active server metrics: ${JSON.stringify(errSupportedVersions)}`
};
return { details: { response: data301 } };
}
);
await runCheck(
'sep-2575-http-server-unsupported-version-400',
'HttpServerUnsupportedVersion400',
'If the server does not implement the requested protocol version, it MUST respond with 400 Bad Request and an UnsupportedProtocolVersionError listing its supported versions.',
() => {
if (!res301)
return { error: 'Network transaction context unavailable' };
if (res301.status !== 400)
return {
error: `Expected HTTP 400 Bad Request, got status code ${res301.status}`
};
return { details: { response: data301 } };
}
);
const headerMismatchMeta = {
...validMeta,
'io.modelcontextprotocol/protocolVersion': 'v999.0.0'
};
const responseAbsent = await sendRpc(
'server/discover',
{ _meta: headerMismatchMeta },
{ 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION },
302
).catch(() => null);
const resAbsent: any = responseAbsent?.res ?? null;
const dataAbsent: any = responseAbsent?.data ?? null;
await runCheck(
'sep-2575-http-server-header-mismatch-400',
'HttpServerHeaderMismatch400',
'If the values do not match, the server MUST reject the request with 400 Bad Request and a HeaderMismatch JSON-RPC error.',
() => {
if (!resAbsent)
return { error: 'Header verification endpoint network hit failed' };
if (resAbsent.status !== 400 || dataAbsent?.error?.code !== -32001) {
return {
error: `Expected HTTP 400 and JSON-RPC error -32001, got status ${resAbsent.status} with code ${dataAbsent?.error?.code}`
};
}
return { details: { response: dataAbsent } };
}
);
// ==========================================
// 4. Client Capability Constraints (2 Checks)
// ==========================================
const response401 = await sendRpc(
'tools/call',
{ name: 'test_missing_capability', arguments: {}, _meta: validMeta },
undefined,
401
).catch(() => null);
const res401: any = response401?.res ?? null;
const data401: any = response401?.data ?? null;
if (data401) checkErrorId(data401, 401);
// Determine if this server actively enforces client capabilities
const serverRequiresCapability = data401?.error?.code === -32003;
await runCheck(
'sep-2575-server-rejects-undeclared-capability',
'ServerRejectsUndeclaredCapability',
'A server MUST NOT rely on capabilities the client has not declared. If processing a request requires a capability the client did not include in io.modelcontextprotocol/clientCapabilities, the server MUST return a MissingRequiredClientCapabilityError (-32003).',
() => {
if (!res401)
return {
error:
'Capability checking call sequence timed out or dropped connection'
};
if (!serverRequiresCapability) {
// The server didn't return -32003, so this requirement isn't
// exercised for this method. Report SKIPPED rather than a green PASS.
return {
skipped: true,
details: {
note: 'Skipped requirement tracking: Server returned a non-32003 response, indicating it does not require explicit client capability authorization constraints for this method.',
response: data401
}
};
}
// If it DOES return -32003, strictly validate the requirement payload structure
const reqCaps = data401?.error?.data?.requiredCapabilities;
if (!Array.isArray(reqCaps) || !reqCaps.includes('sampling')) {
return {
error: `Server responded with error code -32003 but failed to provide an array containing the expected 'sampling' capability in error.data.requiredCapabilities`,
details: { response: data401 }
};
}
return { details: { response: data401 } };
}
);
await runCheck(
'sep-2575-missing-capability-http-400',
'MissingCapabilityHttp400',
'On HTTP, the response status MUST be 400 Bad Request [for MissingRequiredClientCapabilityError].',
() => {
if (!res401)
return {
error: 'Network transport layer layer context failed to instantiate'
};
if (!serverRequiresCapability) {
// No -32003 means the HTTP-400 requirement doesn't apply here.
// Report SKIPPED rather than a green PASS.
return {
skipped: true,
details: {
note: 'Skipped status tracking: Server did not return a MissingRequiredClientCapabilityError.',
httpStatus: res401.status
}
};
}
// If it did trigger the capability error, it MUST use HTTP 400
if (res401.status !== 400) {
return {
error: `Expected HTTP status code 400 Bad Request for an undeclared capability error response, got ${res401.status}`,
details: { response: data401 }
};
}
return { details: { response: data401 } };
}
);
// ==========================================
// 5. Methods & Routing Mechanics (5 Checks)
// ==========================================
const expectedSlugs = [
'initialize',
'ping',
'logging/setLevel',
'resources/subscribe',
'resources/unsubscribe'
];
for (const slug of expectedSlugs) {
const cleanMethodParam = slug.toLowerCase().replace('/', '-');
const response500 = await sendRpc(
slug,
{ _meta: validMeta },
undefined,
500
).catch(() => null);
const res500: any = response500?.res ?? null;
const data500: any = response500?.data ?? null;
await runCheck(
`sep-2575-http-server-method-not-found-404-${cleanMethodParam}`,
`HttpServerMethodNotFound404${slug.replace('/', '')}`,
`If the server does not implement the removed RPC method '${slug}', it MUST respond with 404 Not Found and a JSON-RPC error with code -32601 (Method not found).`,
() => {
if (!res500 || !data500)
return {
error:
'Removed method validation hit dropped connections unexpectedly'
};
if (res500.status !== 404 || data500?.error?.code !== -32601) {
return {
error: `Expected HTTP 404 and code -32601 for removed methods, got HTTP ${res500.status} and code ${data500?.error?.code}`
};
}
return { details: { response: data500 } };
}
);
}
// Explicit generic unknown method fallback test
const response601 = await sendRpc(
'unknown/method',
{ _meta: validMeta },
undefined,
601
).catch(() => null);
const res601: any = response601?.res ?? null;
const data601: any = response601?.data ?? null;
await runCheck(
'sep-2575-http-server-method-not-found-404',
'HttpServerMethodNotFound404',
'If the server does not implement the requested RPC method, it MUST respond with 404 Not Found and a JSON-RPC error with code -32601 (Method not found).',
() => {
if (!res601 || !data601)
return {
error: 'Unknown fallback test target returned an invalid layout'
};
if (res601.status !== 404 || data601?.error?.code !== -32601) {
return {
error: `Expected HTTP 404 and JSON-RPC error code -32601, got HTTP ${res601.status} and code ${data601?.error?.code}`
};
}
return { details: { response: data601 } };
}
);
await runCheck(
'sep-2575-http-server-no-independent-requests-on-stream',
'HttpServerNoIndependentRequestsOnStream',
'Request stream contains only IncompleteResult, never independent JSON-RPC requests',
async () => {
const frames = await listenToStream(
'tools/call',
{
name: 'test_streaming_elicitation',
arguments: {},
_meta: validMeta
},
3,
600
);
if (frames.length === 0) {
return {
error:
'Failed to receive progressive stream chunk execution frames from tools/call handler endpoint'
};
}
// If the call was rejected outright (e.g. the diagnostic tool does
// not exist), nothing was streamed and the requirement was not
// exercised - report SKIPPED rather than a vacuous SUCCESS.
if (frames.every((f) => f?.error !== undefined)) {
return {
skipped: true,
details: {
note: 'Server does not expose diagnostic tool test_streaming_elicitation; the response stream could not be exercised.',
response: frames[0]
}
};
}
const hasIndependentRequest = frames.some(
(f) => f.method && !f.method.startsWith('notifications/')
);
if (hasIndependentRequest) {
return {
error:
'Server emitted an independent standard request layout inside a response progress execution block stream context',
details: { frames }
};
}
return { details: { inspectedFramesCount: frames.length } };
}
);
await runCheck(
'sep-2575-server-no-log-without-loglevel',
'ServerNoLogWithoutLogLevel',
"No notifications/message for requests that didn't set _meta.../logLevel",
async () => {
const frames = await listenToStream(
'tools/call',
{ name: 'test_logging_tool', arguments: {}, _meta: validMeta },
3,
500
);
if (frames.length === 0) {
return {
error:
'Logging target endpoint context dropped or failed to yield frame structures'
};
}
// If the call was rejected outright (e.g. the diagnostic tool does
// not exist), the server never had anything to log and the
// requirement was not exercised - report SKIPPED rather than a
// vacuous SUCCESS.
if (frames.every((f) => f?.error !== undefined)) {
return {
skipped: true,
details: {
note: 'Server does not expose diagnostic tool test_logging_tool; the no-log-without-logLevel requirement could not be exercised.',
response: frames[0]
}
};
}
const logFrame = frames.find(
(f) => f.method === 'notifications/message'
);
if (logFrame) {
return {
error:
'Server dispatched a notifications/message payload chunk even though the client request did not authorize a log level context descriptor',
details: { logFrame }
};
}
return { details: { framesFound: frames.length } };
}
);
// ==========================================
// 6. Subscription Streams & Filtering (3 Checks)
// ==========================================
const subscriptionParams = {
_meta: validMeta,
notifications: { toolsListChanged: true }
};
// Open a tools-filtered stream and (best-effort) trigger a tool-list
// change once it is acknowledged, so the stream carries at least one
// post-acknowledgment notification for the subscription-id check.
let streamFrames: any[] = [];
try {
streamFrames = await listenToStream(
'subscriptions/listen',
subscriptionParams,
2,
800,
async () => {
await sendRpc('tools/call', {
name: 'test_trigger_tool_change',
arguments: {},
_meta: validMeta
});
}
);
} catch {
// Stream pipeline tracking failed
}
await runCheck(
'sep-2575-server-sends-subscription-ack',
'ServerSendsSubscriptionAck',
'notifications/subscriptions/acknowledged is the first message on a subscriptions/listen stream',
() => {
if (streamFrames[0]?.error?.code === -32601) {
return {
skipped: true,
details: {
note: 'Server does not support subscriptions/listen (Method not found)'
}
};
}
if (streamFrames.length === 0) {
return {
error:
'Failed to open or receive frames from the subscriptions/listen stream endpoint'
};
}
const firstFrame = streamFrames[0];
if (firstFrame?.method !== 'notifications/subscriptions/acknowledged') {
return {
error: `Expected first frame method to be 'notifications/subscriptions/acknowledged', got '${firstFrame?.method}'`,
details: { firstFrame }
};
}
return { details: { firstFrame } };
}
);
await runCheck(
'sep-2575-server-tags-subscription-id',
'ServerTagsSubscriptionId',
'Listen-stream notifications carry _meta.../subscriptionId',
() => {
if (streamFrames[0]?.error?.code === -32601) {
return {
skipped: true,
details: {
note: 'Server does not support subscriptions/listen (Method not found)'
}
};
}
if (streamFrames.length === 0) {
return {
error:
'Failed to open stream line or tracking frames are missing completely'
};
}
// Every notification delivered on the stream (the acknowledgment and
// anything after it) must carry the subscription id in params._meta.
const notificationFrames = streamFrames.filter(
(f) =>
typeof f?.method === 'string' &&
f.method.startsWith('notifications/')
);
if (notificationFrames.length === 0) {
return {
error:
'subscriptions/listen stream carried no notification frames to inspect',
details: { streamFrames }
};
}
const untaggedFrames = notificationFrames.filter(
(f) => !f?.params?._meta?.['io.modelcontextprotocol/subscriptionId']
);
if (untaggedFrames.length > 0) {
return {
error: `${untaggedFrames.length} of ${notificationFrames.length} listen-stream notification(s) are missing io.modelcontextprotocol/subscriptionId in params._meta`,
details: { untaggedFrames }
};
}
const subId =
notificationFrames[0]?.params?._meta?.[
'io.modelcontextprotocol/subscriptionId'
];
return {
details: {
subscriptionId: subId,
inspectedNotificationCount: notificationFrames.length
}
};
}
);
await runCheck(
'sep-2575-server-honors-notification-filter',
'ServerHonorsNotificationFilter',
"Server doesn't send notification types the client didn't request",
async () => {
const narrowParams = {
_meta: validMeta,
notifications: { promptsListChanged: true }
};
// Open a stream filtered to prompts only, then mutate the *tool* list
// on a separate connection once the subscription is acknowledged. A
// compliant server must not deliver the resulting tools notification
// to this stream.
const narrowFrames = await listenToStream(
'subscriptions/listen',
narrowParams,
3,
1500,
async () => {
await sendRpc('tools/call', {
name: 'test_trigger_tool_change',
arguments: {},
_meta: validMeta
});
}
);
if (narrowFrames[0]?.error?.code === -32601) {
return {
skipped: true,
details: {
note: 'Server does not support subscriptions/listen (Method not found)'
}
};
}
if (narrowFrames.length === 0) {
return {
error:
'Strict subscription filtering line failed to return communication frames'
};
}
const leakedToolFrame = narrowFrames.find(
(f) => f?.method === 'notifications/tools/list_changed'
);
if (leakedToolFrame) {
return {
error:
'Server leaked a tools/list-changed notification over a stream explicitly filtered to prompts only',
details: { leakedToolFrame }
};
}
return { details: { narrowFramesCount: narrowFrames.length } };
}
);
// ==========================================
// 7. Dynamic List Mutations (2 Checks)
// ==========================================
await runCheck(
'sep-2575-server-sends-prompts-list-changed-on-subscription',
'ServerSendsPromptsListChangedOnSubscription',
'List-changed-capable servers notify listen streams with promptsListChanged: true (SHOULD)',
async () => {
// Automatically pass/skip if the server didn't declare this capability during discovery
if (!discoverCapabilities?.prompts?.listChanged) {
return {
skipped: true,
details: {
note: 'Server did not declare prompts.listChanged capability in server/discover'
}
};
}
const promptsParams = {
_meta: validMeta,
notifications: { promptsListChanged: true }
};
// Open the subscription first; mutate the prompt list on a separate
// connection once it is acknowledged. A compliant server only
// notifies streams that are open at the time of the change.
let trigger: any = null;
const frames = await listenToStream(
'subscriptions/listen',
promptsParams,
2,
1500,
async () => {
trigger = await sendRpc('tools/call', {
name: 'test_trigger_prompt_change',
arguments: {},
_meta: validMeta
});
}
);
if (trigger?.data?.error?.code === -32601) {
return {
skipped: true,
details: {
note: 'Server does not expose diagnostic hook test_trigger_prompt_change to mutate lists'
}
};
}
if (frames.length === 0) {
return {
warning: true,
error:
'Failed to open or receive frames from the subscriptions/listen stream endpoint'
};
}
const changeFrame = frames.find(
(f) => f.method === 'notifications/prompts/list_changed'
);
if (!changeFrame) {
return {
warning: true,
error:
'Mutated the prompt list but no notifications/prompts/list_changed arrived on the open subscription stream. This is a SHOULD requirement.'
};
}
return { details: { changeFrame } };
}
);
await runCheck(
'sep-2575-server-sends-tools-list-changed-on-subscription',
'ServerSendsToolsListChangedOnSubscription',
'List-changed-capable servers notify listen streams with toolsListChanged: true (SHOULD)',
async () => {
// Automatically pass/skip if the server didn't declare this capability during discovery
if (!discoverCapabilities?.tools?.listChanged) {
return {
skipped: true,
details: {
note: 'Server did not declare tools.listChanged capability in server/discover'
}
};
}
const toolsParams = {
_meta: validMeta,
notifications: { toolsListChanged: true }
};
// Open the subscription first; mutate the tool list on a separate
// connection once it is acknowledged. A compliant server only
// notifies streams that are open at the time of the change.
let trigger: any = null;
const frames = await listenToStream(
'subscriptions/listen',
toolsParams,
2,
1500,
async () => {
trigger = await sendRpc('tools/call', {
name: 'test_trigger_tool_change',
arguments: {},
_meta: validMeta
});
}
);
if (trigger?.data?.error?.code === -32601) {
return {
skipped: true,
details: {
note: 'Server does not expose diagnostic hook test_trigger_tool_change to mutate lists'
}
};
}
if (frames.length === 0) {
return {
warning: true,
error:
'Failed to open or receive frames from the subscriptions/listen stream endpoint'
};
}
const changeFrame = frames.find(
(f) => f.method === 'notifications/tools/list_changed'
);
if (!changeFrame) {
return {
warning: true,
error:
'Mutated the tool list but no notifications/tools/list_changed arrived on the open subscription stream. This is a SHOULD requirement.'
};
}
return { details: { changeFrame } };
}
);
if (!checks.some((c) => c.id === 'sep-2575-http-server-error-jsonrpc-id')) {
checks.push({
id: 'sep-2575-http-server-error-jsonrpc-id',
name: 'HttpServerErrorJsonrpcId',
description: 'All error responses carry the request JSON-RPC id',
status: 'SUCCESS',
timestamp,
specReferences: SPEC_REF
});
}
return checks;
}
}