feat: Session ID ASCII validation check (#206)
* Checks for session ASCII correctness * tests: Session ID ASCII checks
This commit is contained in:
parent
050d9cf93a
commit
8d9f80a258
2 changed files with 191 additions and 1 deletions
94
src/scenarios/server/lifecycle.test.ts
Normal file
94
src/scenarios/server/lifecycle.test.ts
Normal file
|
|
@ -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-é'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,6 +5,15 @@
|
||||||
import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types';
|
import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types';
|
||||||
import { connectToServer } from './client-helper';
|
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 {
|
export class ServerInitializeScenario implements ClientScenario {
|
||||||
name = 'server-initialize';
|
name = 'server-initialize';
|
||||||
specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25'];
|
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
|
- Accept \`initialize\` request with client info and capabilities
|
||||||
- Return valid initialize response with server info, protocol version, and capabilities
|
- Return valid initialize response with server info, protocol version, and capabilities
|
||||||
- Accept \`initialized\` notification from client after handshake
|
- 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<ConformanceCheck[]> {
|
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||||
const checks: ConformanceCheck[] = [];
|
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;
|
return checks;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue