diff --git a/src/scenarios/server/lifecycle.test.ts b/src/scenarios/server/lifecycle.test.ts new file mode 100644 index 0000000..15ee6d8 --- /dev/null +++ b/src/scenarios/server/lifecycle.test.ts @@ -0,0 +1,94 @@ +import { ServerInitializeScenario } from './lifecycle'; +import { connectToServer } from './client-helper'; + +vi.mock('./client-helper', () => ({ + connectToServer: vi.fn() +})); + +describe('ServerInitializeScenario', () => { + const serverUrl = 'http://localhost:3000/mcp'; + const closeMock = vi.fn().mockResolvedValue(undefined); + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + vi.mocked(connectToServer).mockResolvedValue({ + client: {} as any, + close: closeMock + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + 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); + + expect(connectToServer).toHaveBeenCalledWith(serverUrl); + expect(closeMock).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith( + serverUrl, + expect.objectContaining({ + method: 'POST' + }) + ); + + expect(checks).toHaveLength(2); + expect(checks[0]?.id).toBe('server-initialize'); + expect(checks[0]?.status).toBe('SUCCESS'); + expect(checks[1]).toMatchObject({ + id: 'server-session-id-visible-ascii', + status: 'INFO', + details: { + message: + 'Server did not provide an MCP-Session-Id header (session ID is optional)' + } + }); + }); + + it('returns SUCCESS when the server provides a visible ASCII session ID', async () => { + fetchMock.mockResolvedValue( + new Response(null, { + headers: { + 'mcp-session-id': 'session-123_ABC' + } + }) + ); + + const checks = await new ServerInitializeScenario().run(serverUrl); + + expect(checks[1]).toMatchObject({ + id: 'server-session-id-visible-ascii', + status: 'SUCCESS', + details: { + sessionId: 'session-123_ABC' + } + }); + }); + + it('returns FAILURE when the server provides a non-ASCII session ID', async () => { + fetchMock.mockResolvedValue( + new Response(null, { + headers: { + 'mcp-session-id': 'session-123-é' + } + }) + ); + + const checks = await new ServerInitializeScenario().run(serverUrl); + + expect(checks[1]).toMatchObject({ + id: 'server-session-id-visible-ascii', + status: 'FAILURE', + errorMessage: + 'Session ID contains characters outside visible ASCII range (0x21-0x7E)', + details: { + sessionId: 'session-123-é' + } + }); + }); +}); diff --git a/src/scenarios/server/lifecycle.ts b/src/scenarios/server/lifecycle.ts index 392a932..3758615 100644 --- a/src/scenarios/server/lifecycle.ts +++ b/src/scenarios/server/lifecycle.ts @@ -5,6 +5,15 @@ import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; import { connectToServer } from './client-helper'; +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'; specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; @@ -18,8 +27,10 @@ export class ServerInitializeScenario implements ClientScenario { - 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.`; +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 { const checks: ConformanceCheck[] = []; @@ -65,6 +76,91 @@ This test verifies the server can complete the two-phase initialization handshak } ] }); + 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. + try { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2025-11-25' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { + name: 'conformance-session-id-test', + version: '1.0.0' + } + } + }) + }); + + const sessionId = response.headers.get('mcp-session-id'); + + 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 + }); } return checks;