conformance/src/scenarios/server/lifecycle.ts
Satoshi Ito c5af5673ba
fix: send HTTP DELETE to terminate server sessions after each scenario (#316)
* 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>
2026-07-31 17:24:13 +01:00

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;
}
}