* fix: send HTTP DELETE to terminate server sessions after each scenario The conformance test harness opened sessions against servers under test but never issued an HTTP DELETE to terminate them, leaving a dangling session on the server after each scenario. This violates the "hermetic" expectation called out in the Streamable HTTP transport spec. - `connection/sdk-client.connectToServer` now calls `transport.terminateSession()` before `client.close()`. A 405 response (DELETE not supported) is tolerated because the spec allows the server to refuse. - A new `terminateSessionRaw` helper handles scenarios that bypass the SDK client and open sessions via raw `fetch` (`lifecycle.ts`, `http-standard-headers.ts`). - `sse-polling.ts` and `sse-multiple-streams.ts`, which manage their own transport, now terminate the session in their existing `finally` block before closing the client. - `lifecycle.test.ts` gains two cases asserting that DELETE is sent with the issued session ID and that no DELETE is sent when the server did not assign one. Verified manually against `examples/servers/typescript/everything-server.ts` that running a server scenario now causes the server to log `Received session termination request for session <id>`. Fixes #79 Signed-off-by: Satoshi Ito <satoshi.ito.tf@hitachi.com> * Use negotiated protocol version for teardown DELETE and bound it Review follow-ups on the session-termination change: - terminateSessionRaw: abort the DELETE after 5s so a wedged server cannot stall suite teardown. - terminateSessionBestEffort: shared SDK-path teardown helper that delegates to terminateSessionRaw with the transport's negotiated protocol version, so the socket is truly released at the bound and the version-mismatch 400 on strict servers (the failure mode of issue #79) goes away. Used by connectToServer close() and both SSE scenarios, replacing their inline try/catch blocks. - lifecycle.ts: the raw session-id probe and its teardown DELETE now use ctx.specVersion instead of a hardcoded 2025-11-25, matching the negotiated-version convention from #415/#416. - Add sdk-client.test.ts regression tests asserting close() sends a DELETE with the issued session ID (and none without one), so a bad future merge cannot silently drop the fix. --------- Signed-off-by: Satoshi Ito <satoshi.ito.tf@hitachi.com> Co-authored-by: Paul Carleton <paulc@anthropic.com>
186 lines
5.9 KiB
TypeScript
186 lines
5.9 KiB
TypeScript
/**
|
|
* Lifecycle test scenarios for MCP servers
|
|
*/
|
|
|
|
import {
|
|
ClientScenario,
|
|
ConformanceCheck,
|
|
DRAFT_PROTOCOL_VERSION
|
|
} from '../../types';
|
|
import type { RunContext } from '../../connection';
|
|
import {
|
|
connectToServer,
|
|
terminateSessionRaw
|
|
} from '../../connection/sdk-client';
|
|
|
|
const VISIBLE_ASCII_REGEX = /^[\x21-\x7E]+$/;
|
|
|
|
const SESSION_SPEC_REFERENCES = [
|
|
{
|
|
id: 'MCP-Session-Management',
|
|
url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management'
|
|
}
|
|
];
|
|
|
|
export class ServerInitializeScenario implements ClientScenario {
|
|
name = 'server-initialize';
|
|
readonly source = {
|
|
introducedIn: '2025-06-18',
|
|
removedIn: DRAFT_PROTOCOL_VERSION
|
|
} as const;
|
|
description = `Test basic server initialization handshake.
|
|
|
|
**Server Implementation Requirements:**
|
|
|
|
**Endpoint**: \`initialize\`
|
|
|
|
**Requirements**:
|
|
- Accept \`initialize\` request with client info and capabilities
|
|
- Return valid initialize response with server info, protocol version, and capabilities
|
|
- Accept \`initialized\` notification from client after handshake
|
|
- If a session ID is assigned, it MUST only contain visible ASCII characters (0x21 to 0x7E)
|
|
|
|
This test verifies the server can complete the two-phase initialization handshake successfully,
|
|
and validates session ID format if one is assigned.`;
|
|
|
|
async run(ctx: RunContext): Promise<ConformanceCheck[]> {
|
|
const { serverUrl } = ctx;
|
|
const checks: ConformanceCheck[] = [];
|
|
|
|
try {
|
|
const connection = await connectToServer(serverUrl, {}, ctx.specVersion);
|
|
|
|
// The connection process already does initialization
|
|
// Check that we have a connected client
|
|
checks.push({
|
|
id: 'server-initialize',
|
|
name: 'ServerInitialize',
|
|
description:
|
|
'Server responds to initialize request with valid structure',
|
|
status: 'SUCCESS',
|
|
timestamp: new Date().toISOString(),
|
|
specReferences: [
|
|
{
|
|
id: 'MCP-Initialize',
|
|
url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#initialization'
|
|
}
|
|
],
|
|
details: {
|
|
serverUrl,
|
|
connected: true
|
|
}
|
|
});
|
|
|
|
await connection.close();
|
|
} catch (error) {
|
|
checks.push({
|
|
id: 'server-initialize',
|
|
name: 'ServerInitialize',
|
|
description:
|
|
'Server responds to initialize request with valid structure',
|
|
status: 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: `Failed to initialize: ${error instanceof Error ? error.message : String(error)}`,
|
|
specReferences: [
|
|
{
|
|
id: 'MCP-Initialize',
|
|
url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#initialization'
|
|
}
|
|
]
|
|
});
|
|
return checks;
|
|
}
|
|
|
|
// Check: Session ID visible ASCII validation
|
|
// Use a raw fetch to inspect the MCP-Session-Id response header,
|
|
// since the SDK client transport does not expose it.
|
|
let rawSessionId: string | null = null;
|
|
try {
|
|
const response = await fetch(serverUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json, text/event-stream',
|
|
'mcp-protocol-version': ctx.specVersion
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: ctx.specVersion,
|
|
capabilities: {},
|
|
clientInfo: {
|
|
name: 'conformance-session-id-test',
|
|
version: '1.0.0'
|
|
}
|
|
}
|
|
})
|
|
});
|
|
|
|
const sessionId = response.headers.get('mcp-session-id');
|
|
rawSessionId = sessionId;
|
|
|
|
if (!sessionId) {
|
|
checks.push({
|
|
id: 'server-session-id-visible-ascii',
|
|
name: 'ServerSessionIdVisibleAscii',
|
|
description:
|
|
'Server-provided session ID uses only visible ASCII characters',
|
|
status: 'INFO',
|
|
timestamp: new Date().toISOString(),
|
|
specReferences: SESSION_SPEC_REFERENCES,
|
|
details: {
|
|
message:
|
|
'Server did not provide an MCP-Session-Id header (session ID is optional)'
|
|
}
|
|
});
|
|
} else if (VISIBLE_ASCII_REGEX.test(sessionId)) {
|
|
checks.push({
|
|
id: 'server-session-id-visible-ascii',
|
|
name: 'ServerSessionIdVisibleAscii',
|
|
description:
|
|
'Server-provided session ID uses only visible ASCII characters',
|
|
status: 'SUCCESS',
|
|
timestamp: new Date().toISOString(),
|
|
specReferences: SESSION_SPEC_REFERENCES,
|
|
details: {
|
|
sessionId
|
|
}
|
|
});
|
|
} else {
|
|
checks.push({
|
|
id: 'server-session-id-visible-ascii',
|
|
name: 'ServerSessionIdVisibleAscii',
|
|
description:
|
|
'Server-provided session ID uses only visible ASCII characters',
|
|
status: 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage:
|
|
'Session ID contains characters outside visible ASCII range (0x21-0x7E)',
|
|
specReferences: SESSION_SPEC_REFERENCES,
|
|
details: {
|
|
sessionId
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
checks.push({
|
|
id: 'server-session-id-visible-ascii',
|
|
name: 'ServerSessionIdVisibleAscii',
|
|
description:
|
|
'Server-provided session ID uses only visible ASCII characters',
|
|
status: 'FAILURE',
|
|
timestamp: new Date().toISOString(),
|
|
errorMessage: `Failed to send initialize request for session ID check: ${error instanceof Error ? error.message : String(error)}`,
|
|
specReferences: SESSION_SPEC_REFERENCES
|
|
});
|
|
} finally {
|
|
if (rawSessionId) {
|
|
await terminateSessionRaw(serverUrl, rawSessionId, ctx.specVersion);
|
|
}
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
}
|