Skip to content

Commit 8d9f80a

Browse files
authored
feat: Session ID ASCII validation check (#206)
* Checks for session ASCII correctness * tests: Session ID ASCII checks
1 parent 050d9cf commit 8d9f80a

2 files changed

Lines changed: 191 additions & 1 deletion

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { ServerInitializeScenario } from './lifecycle';
2+
import { connectToServer } from './client-helper';
3+
4+
vi.mock('./client-helper', () => ({
5+
connectToServer: vi.fn()
6+
}));
7+
8+
describe('ServerInitializeScenario', () => {
9+
const serverUrl = 'http://localhost:3000/mcp';
10+
const closeMock = vi.fn().mockResolvedValue(undefined);
11+
const fetchMock = vi.fn();
12+
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
vi.stubGlobal('fetch', fetchMock);
16+
vi.mocked(connectToServer).mockResolvedValue({
17+
client: {} as any,
18+
close: closeMock
19+
});
20+
});
21+
22+
afterEach(() => {
23+
vi.unstubAllGlobals();
24+
});
25+
26+
it('returns INFO when the server does not provide an MCP-Session-Id header', async () => {
27+
fetchMock.mockResolvedValue(new Response(null));
28+
29+
const checks = await new ServerInitializeScenario().run(serverUrl);
30+
31+
expect(connectToServer).toHaveBeenCalledWith(serverUrl);
32+
expect(closeMock).toHaveBeenCalled();
33+
expect(fetchMock).toHaveBeenCalledWith(
34+
serverUrl,
35+
expect.objectContaining({
36+
method: 'POST'
37+
})
38+
);
39+
40+
expect(checks).toHaveLength(2);
41+
expect(checks[0]?.id).toBe('server-initialize');
42+
expect(checks[0]?.status).toBe('SUCCESS');
43+
expect(checks[1]).toMatchObject({
44+
id: 'server-session-id-visible-ascii',
45+
status: 'INFO',
46+
details: {
47+
message:
48+
'Server did not provide an MCP-Session-Id header (session ID is optional)'
49+
}
50+
});
51+
});
52+
53+
it('returns SUCCESS when the server provides a visible ASCII session ID', async () => {
54+
fetchMock.mockResolvedValue(
55+
new Response(null, {
56+
headers: {
57+
'mcp-session-id': 'session-123_ABC'
58+
}
59+
})
60+
);
61+
62+
const checks = await new ServerInitializeScenario().run(serverUrl);
63+
64+
expect(checks[1]).toMatchObject({
65+
id: 'server-session-id-visible-ascii',
66+
status: 'SUCCESS',
67+
details: {
68+
sessionId: 'session-123_ABC'
69+
}
70+
});
71+
});
72+
73+
it('returns FAILURE when the server provides a non-ASCII session ID', async () => {
74+
fetchMock.mockResolvedValue(
75+
new Response(null, {
76+
headers: {
77+
'mcp-session-id': 'session-123-é'
78+
}
79+
})
80+
);
81+
82+
const checks = await new ServerInitializeScenario().run(serverUrl);
83+
84+
expect(checks[1]).toMatchObject({
85+
id: 'server-session-id-visible-ascii',
86+
status: 'FAILURE',
87+
errorMessage:
88+
'Session ID contains characters outside visible ASCII range (0x21-0x7E)',
89+
details: {
90+
sessionId: 'session-123-é'
91+
}
92+
});
93+
});
94+
});

src/scenarios/server/lifecycle.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@
55
import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types';
66
import { connectToServer } from './client-helper';
77

8+
const VISIBLE_ASCII_REGEX = /^[\x21-\x7E]+$/;
9+
10+
const SESSION_SPEC_REFERENCES = [
11+
{
12+
id: 'MCP-Session-Management',
13+
url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management'
14+
}
15+
];
16+
817
export class ServerInitializeScenario implements ClientScenario {
918
name = 'server-initialize';
1019
specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25'];
@@ -18,8 +27,10 @@ export class ServerInitializeScenario implements ClientScenario {
1827
- Accept \`initialize\` request with client info and capabilities
1928
- Return valid initialize response with server info, protocol version, and capabilities
2029
- Accept \`initialized\` notification from client after handshake
30+
- If a session ID is assigned, it MUST only contain visible ASCII characters (0x21 to 0x7E)
2131
22-
This test verifies the server can complete the two-phase initialization handshake successfully.`;
32+
This test verifies the server can complete the two-phase initialization handshake successfully,
33+
and validates session ID format if one is assigned.`;
2334

2435
async run(serverUrl: string): Promise<ConformanceCheck[]> {
2536
const checks: ConformanceCheck[] = [];
@@ -65,6 +76,91 @@ This test verifies the server can complete the two-phase initialization handshak
6576
}
6677
]
6778
});
79+
return checks;
80+
}
81+
82+
// Check: Session ID visible ASCII validation
83+
// Use a raw fetch to inspect the MCP-Session-Id response header,
84+
// since the SDK client transport does not expose it.
85+
try {
86+
const response = await fetch(serverUrl, {
87+
method: 'POST',
88+
headers: {
89+
'Content-Type': 'application/json',
90+
Accept: 'application/json, text/event-stream',
91+
'mcp-protocol-version': '2025-11-25'
92+
},
93+
body: JSON.stringify({
94+
jsonrpc: '2.0',
95+
id: 1,
96+
method: 'initialize',
97+
params: {
98+
protocolVersion: '2025-11-25',
99+
capabilities: {},
100+
clientInfo: {
101+
name: 'conformance-session-id-test',
102+
version: '1.0.0'
103+
}
104+
}
105+
})
106+
});
107+
108+
const sessionId = response.headers.get('mcp-session-id');
109+
110+
if (!sessionId) {
111+
checks.push({
112+
id: 'server-session-id-visible-ascii',
113+
name: 'ServerSessionIdVisibleAscii',
114+
description:
115+
'Server-provided session ID uses only visible ASCII characters',
116+
status: 'INFO',
117+
timestamp: new Date().toISOString(),
118+
specReferences: SESSION_SPEC_REFERENCES,
119+
details: {
120+
message:
121+
'Server did not provide an MCP-Session-Id header (session ID is optional)'
122+
}
123+
});
124+
} else if (VISIBLE_ASCII_REGEX.test(sessionId)) {
125+
checks.push({
126+
id: 'server-session-id-visible-ascii',
127+
name: 'ServerSessionIdVisibleAscii',
128+
description:
129+
'Server-provided session ID uses only visible ASCII characters',
130+
status: 'SUCCESS',
131+
timestamp: new Date().toISOString(),
132+
specReferences: SESSION_SPEC_REFERENCES,
133+
details: {
134+
sessionId
135+
}
136+
});
137+
} else {
138+
checks.push({
139+
id: 'server-session-id-visible-ascii',
140+
name: 'ServerSessionIdVisibleAscii',
141+
description:
142+
'Server-provided session ID uses only visible ASCII characters',
143+
status: 'FAILURE',
144+
timestamp: new Date().toISOString(),
145+
errorMessage:
146+
'Session ID contains characters outside visible ASCII range (0x21-0x7E)',
147+
specReferences: SESSION_SPEC_REFERENCES,
148+
details: {
149+
sessionId
150+
}
151+
});
152+
}
153+
} catch (error) {
154+
checks.push({
155+
id: 'server-session-id-visible-ascii',
156+
name: 'ServerSessionIdVisibleAscii',
157+
description:
158+
'Server-provided session ID uses only visible ASCII characters',
159+
status: 'FAILURE',
160+
timestamp: new Date().toISOString(),
161+
errorMessage: `Failed to send initialize request for session ID check: ${error instanceof Error ? error.message : String(error)}`,
162+
specReferences: SESSION_SPEC_REFERENCES
163+
});
68164
}
69165

70166
return checks;

0 commit comments

Comments
 (0)