@plosson/agentio 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,364 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ _getSessionCount,
5
+ _resetSessionsForTests,
6
+ handleMcpRequest,
7
+ parseServicesQuery,
8
+ } from './mcp-http';
9
+
10
+ /**
11
+ * Pure-ish unit tests for the session manager. `parseServicesQuery` is
12
+ * a pure function — tested exhaustively. `handleMcpRequest` touches the
13
+ * module-level `sessions` map, so tests wipe it in afterEach.
14
+ */
15
+
16
+ afterEach(() => {
17
+ _resetSessionsForTests();
18
+ });
19
+
20
+ /* ------------------------------------------------------------------ */
21
+ /* parseServicesQuery */
22
+ /* ------------------------------------------------------------------ */
23
+
24
+ describe('parseServicesQuery — happy path', () => {
25
+ test('null → empty services (valid)', () => {
26
+ const r = parseServicesQuery(null);
27
+ expect(r.ok).toBe(true);
28
+ if (r.ok) expect(r.services).toEqual([]);
29
+ });
30
+
31
+ test('empty string → empty services', () => {
32
+ const r = parseServicesQuery('');
33
+ expect(r.ok).toBe(true);
34
+ if (r.ok) expect(r.services).toEqual([]);
35
+ });
36
+
37
+ test('whitespace-only → empty services', () => {
38
+ const r = parseServicesQuery(' , , ');
39
+ expect(r.ok).toBe(true);
40
+ if (r.ok) expect(r.services).toEqual([]);
41
+ });
42
+
43
+ test('single service without profile', () => {
44
+ const r = parseServicesQuery('rss');
45
+ expect(r.ok).toBe(true);
46
+ if (r.ok)
47
+ expect(r.services).toEqual([{ service: 'rss', profile: undefined }]);
48
+ });
49
+
50
+ test('single service:profile pair', () => {
51
+ const r = parseServicesQuery('gchat:default');
52
+ expect(r.ok).toBe(true);
53
+ if (r.ok)
54
+ expect(r.services).toEqual([{ service: 'gchat', profile: 'default' }]);
55
+ });
56
+
57
+ test('multiple services with mixed profile/no-profile', () => {
58
+ const r = parseServicesQuery('rss,gchat:default,gmail:work');
59
+ expect(r.ok).toBe(true);
60
+ if (r.ok)
61
+ expect(r.services).toEqual([
62
+ { service: 'rss', profile: undefined },
63
+ { service: 'gchat', profile: 'default' },
64
+ { service: 'gmail', profile: 'work' },
65
+ ]);
66
+ });
67
+
68
+ test('trims whitespace around each entry', () => {
69
+ const r = parseServicesQuery(' rss , gchat:default ');
70
+ expect(r.ok).toBe(true);
71
+ if (r.ok)
72
+ expect(r.services).toEqual([
73
+ { service: 'rss', profile: undefined },
74
+ { service: 'gchat', profile: 'default' },
75
+ ]);
76
+ });
77
+
78
+ test('accepts all valid registered services', () => {
79
+ const all =
80
+ 'discourse,gcal,gchat,gdocs,gdrive,github,gmail,gsheets,gtasks,jira,rss,slack,sql,telegram,whatsapp';
81
+ const r = parseServicesQuery(all);
82
+ expect(r.ok).toBe(true);
83
+ if (r.ok) expect(r.services.length).toBe(15);
84
+ });
85
+ });
86
+
87
+ describe('parseServicesQuery — adversarial', () => {
88
+ test('unknown service name → 400 with known-services hint', () => {
89
+ const r = parseServicesQuery('nope');
90
+ expect(r.ok).toBe(false);
91
+ if (!r.ok) {
92
+ expect(r.status).toBe(400);
93
+ expect(r.message).toContain('unknown service');
94
+ expect(r.message).toContain('"nope"');
95
+ expect(r.message).toContain('rss'); // sample of known services
96
+ }
97
+ });
98
+
99
+ test('one unknown among valid → still rejected', () => {
100
+ const r = parseServicesQuery('rss,nope,gmail:x');
101
+ expect(r.ok).toBe(false);
102
+ if (!r.ok) expect(r.message).toContain('nope');
103
+ });
104
+
105
+ test('service name with empty profile (trailing colon) → 400', () => {
106
+ const r = parseServicesQuery('gmail:');
107
+ expect(r.ok).toBe(false);
108
+ if (!r.ok) {
109
+ expect(r.status).toBe(400);
110
+ expect(r.message).toContain('profile name is empty');
111
+ }
112
+ });
113
+
114
+ test('leading colon (empty service name) → 400', () => {
115
+ const r = parseServicesQuery(':foo');
116
+ expect(r.ok).toBe(false);
117
+ if (!r.ok) expect(r.message).toContain('service name is empty');
118
+ });
119
+
120
+ test('bare colon → 400', () => {
121
+ const r = parseServicesQuery(':');
122
+ expect(r.ok).toBe(false);
123
+ });
124
+
125
+ test('case-sensitive: RSS (uppercase) → unknown', () => {
126
+ const r = parseServicesQuery('RSS');
127
+ expect(r.ok).toBe(false);
128
+ });
129
+
130
+ test('service name with unexpected characters → unknown', () => {
131
+ const r = parseServicesQuery('rss-feed');
132
+ expect(r.ok).toBe(false);
133
+ });
134
+ });
135
+
136
+ /* ------------------------------------------------------------------ */
137
+ /* handleMcpRequest — routing (no real MCP protocol yet) */
138
+ /* ------------------------------------------------------------------ */
139
+
140
+ describe('handleMcpRequest — routing', () => {
141
+ test('unknown mcp-session-id → 404 JSON-RPC error', async () => {
142
+ const req = new Request('http://localhost:9999/mcp', {
143
+ method: 'POST',
144
+ headers: {
145
+ 'mcp-session-id': 'does-not-exist',
146
+ 'content-type': 'application/json',
147
+ accept: 'application/json, text/event-stream',
148
+ },
149
+ body: JSON.stringify({
150
+ jsonrpc: '2.0',
151
+ id: 1,
152
+ method: 'tools/list',
153
+ params: {},
154
+ }),
155
+ });
156
+ const res = await handleMcpRequest(req);
157
+ expect(res.status).toBe(404);
158
+ const body = (await res.json()) as Record<string, unknown>;
159
+ expect(body.jsonrpc).toBe('2.0');
160
+ const error = body.error as Record<string, unknown>;
161
+ expect(error.message).toContain('does-not-exist');
162
+ });
163
+
164
+ test('no session id + invalid services → 400 BEFORE transport runs', async () => {
165
+ const req = new Request(
166
+ 'http://localhost:9999/mcp?services=nope',
167
+ {
168
+ method: 'POST',
169
+ headers: {
170
+ 'content-type': 'application/json',
171
+ accept: 'application/json, text/event-stream',
172
+ },
173
+ body: JSON.stringify({
174
+ jsonrpc: '2.0',
175
+ id: 1,
176
+ method: 'initialize',
177
+ params: {
178
+ protocolVersion: '2024-11-05',
179
+ capabilities: {},
180
+ clientInfo: { name: 'test', version: '0.0.0' },
181
+ },
182
+ }),
183
+ }
184
+ );
185
+ const res = await handleMcpRequest(req);
186
+ expect(res.status).toBe(400);
187
+ const body = (await res.json()) as Record<string, unknown>;
188
+ expect(body.error).toBe('invalid_request');
189
+ expect(body.error_description).toContain('unknown service');
190
+ // And no session was leaked into the map.
191
+ expect(_getSessionCount()).toBe(0);
192
+ });
193
+
194
+ test('no session id + empty services → transport runs, session created on initialize', async () => {
195
+ const req = new Request('http://localhost:9999/mcp', {
196
+ method: 'POST',
197
+ headers: {
198
+ 'content-type': 'application/json',
199
+ accept: 'application/json, text/event-stream',
200
+ },
201
+ body: JSON.stringify({
202
+ jsonrpc: '2.0',
203
+ id: 1,
204
+ method: 'initialize',
205
+ params: {
206
+ protocolVersion: '2024-11-05',
207
+ capabilities: {},
208
+ clientInfo: { name: 'test', version: '0.0.0' },
209
+ },
210
+ }),
211
+ });
212
+ const res = await handleMcpRequest(req);
213
+ // The SDK accepts the initialize and assigns a session id.
214
+ expect(res.status).toBe(200);
215
+ expect(res.headers.get('mcp-session-id')).toBeDefined();
216
+ expect(_getSessionCount()).toBe(1);
217
+ });
218
+
219
+ test('session survives across multiple requests routed by mcp-session-id', async () => {
220
+ // First: initialize (creates session).
221
+ const initReq = new Request('http://localhost:9999/mcp?services=rss', {
222
+ method: 'POST',
223
+ headers: {
224
+ 'content-type': 'application/json',
225
+ accept: 'application/json, text/event-stream',
226
+ },
227
+ body: JSON.stringify({
228
+ jsonrpc: '2.0',
229
+ id: 1,
230
+ method: 'initialize',
231
+ params: {
232
+ protocolVersion: '2024-11-05',
233
+ capabilities: {},
234
+ clientInfo: { name: 'test', version: '0.0.0' },
235
+ },
236
+ }),
237
+ });
238
+ const initRes = await handleMcpRequest(initReq);
239
+ const sid = initRes.headers.get('mcp-session-id');
240
+ expect(sid).toBeTruthy();
241
+ // Drain the body so the stream doesn't leak.
242
+ await initRes.text();
243
+
244
+ // Per MCP spec: client sends notifications/initialized after the
245
+ // initialize response before using other methods.
246
+ const notifReq = new Request('http://localhost:9999/mcp?services=rss', {
247
+ method: 'POST',
248
+ headers: {
249
+ 'mcp-session-id': sid!,
250
+ 'content-type': 'application/json',
251
+ accept: 'application/json, text/event-stream',
252
+ 'mcp-protocol-version': '2024-11-05',
253
+ },
254
+ body: JSON.stringify({
255
+ jsonrpc: '2.0',
256
+ method: 'notifications/initialized',
257
+ }),
258
+ });
259
+ const notifRes = await handleMcpRequest(notifReq);
260
+ expect(notifRes.status).toBeLessThan(500);
261
+ await notifRes.text();
262
+
263
+ // Second request: tools/list against the same session.
264
+ const listReq = new Request('http://localhost:9999/mcp?services=rss', {
265
+ method: 'POST',
266
+ headers: {
267
+ 'mcp-session-id': sid!,
268
+ 'content-type': 'application/json',
269
+ accept: 'application/json, text/event-stream',
270
+ 'mcp-protocol-version': '2024-11-05',
271
+ },
272
+ body: JSON.stringify({
273
+ jsonrpc: '2.0',
274
+ id: 2,
275
+ method: 'tools/list',
276
+ params: {},
277
+ }),
278
+ });
279
+ const listRes = await handleMcpRequest(listReq);
280
+ expect(listRes.status).toBe(200);
281
+ const listBody = (await listRes.json()) as Record<string, unknown>;
282
+ expect(listBody.jsonrpc).toBe('2.0');
283
+ const result = listBody.result as Record<string, unknown>;
284
+ expect(Array.isArray(result.tools)).toBe(true);
285
+ // rss has at least one tool (rss_articles).
286
+ const tools = result.tools as Array<{ name: string }>;
287
+ expect(tools.some((t) => t.name.startsWith('rss_'))).toBe(true);
288
+ });
289
+
290
+ test('DELETE on a live session removes it from the map', async () => {
291
+ // Create a session first.
292
+ const init = await handleMcpRequest(
293
+ new Request('http://localhost:9999/mcp?services=rss', {
294
+ method: 'POST',
295
+ headers: {
296
+ 'content-type': 'application/json',
297
+ accept: 'application/json, text/event-stream',
298
+ },
299
+ body: JSON.stringify({
300
+ jsonrpc: '2.0',
301
+ id: 1,
302
+ method: 'initialize',
303
+ params: {
304
+ protocolVersion: '2024-11-05',
305
+ capabilities: {},
306
+ clientInfo: { name: 'test', version: '0.0.0' },
307
+ },
308
+ }),
309
+ })
310
+ );
311
+ const sid = init.headers.get('mcp-session-id')!;
312
+ await init.text();
313
+ expect(_getSessionCount()).toBe(1);
314
+
315
+ const del = await handleMcpRequest(
316
+ new Request('http://localhost:9999/mcp', {
317
+ method: 'DELETE',
318
+ headers: {
319
+ 'mcp-session-id': sid,
320
+ 'mcp-protocol-version': '2024-11-05',
321
+ },
322
+ })
323
+ );
324
+ // DELETE is handled by the transport; either 200 or 204 is fine.
325
+ expect([200, 204]).toContain(del.status);
326
+ expect(_getSessionCount()).toBe(0);
327
+ });
328
+
329
+ test('two concurrent initialize requests create two independent sessions', async () => {
330
+ const mkInitReq = () =>
331
+ new Request('http://localhost:9999/mcp?services=rss', {
332
+ method: 'POST',
333
+ headers: {
334
+ 'content-type': 'application/json',
335
+ accept: 'application/json, text/event-stream',
336
+ },
337
+ body: JSON.stringify({
338
+ jsonrpc: '2.0',
339
+ id: 1,
340
+ method: 'initialize',
341
+ params: {
342
+ protocolVersion: '2024-11-05',
343
+ capabilities: {},
344
+ clientInfo: { name: 'test', version: '0.0.0' },
345
+ },
346
+ }),
347
+ });
348
+
349
+ const [a, b] = await Promise.all([
350
+ handleMcpRequest(mkInitReq()),
351
+ handleMcpRequest(mkInitReq()),
352
+ ]);
353
+ expect(a.status).toBe(200);
354
+ expect(b.status).toBe(200);
355
+ const sidA = a.headers.get('mcp-session-id');
356
+ const sidB = b.headers.get('mcp-session-id');
357
+ expect(sidA).toBeTruthy();
358
+ expect(sidB).toBeTruthy();
359
+ expect(sidA).not.toBe(sidB);
360
+ expect(_getSessionCount()).toBe(2);
361
+ await a.text();
362
+ await b.text();
363
+ });
364
+ });
@@ -0,0 +1,339 @@
1
+ import { randomUUID } from 'crypto';
2
+
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
5
+ import {
6
+ CallToolRequestSchema,
7
+ ListToolsRequestSchema,
8
+ } from '@modelcontextprotocol/sdk/types.js';
9
+
10
+ import {
11
+ buildProgram,
12
+ executeCommand,
13
+ parseServiceProfiles,
14
+ SERVICE_REGISTRATIONS,
15
+ type ServiceProfilePair,
16
+ } from '../mcp/server';
17
+ import { collectMcpTools, type McpToolDefinition } from '../mcp/tools';
18
+
19
+ /**
20
+ * Per-session bookkeeping for the Streamable HTTP MCP transport.
21
+ *
22
+ * The SDK transport is session-oriented: each `WebStandardStreamableHTTPServerTransport`
23
+ * instance has exactly one `sessionId` set during the `initialize` request.
24
+ * That means we need one Server + one Transport pair per active MCP
25
+ * session, not one shared instance for the whole daemon.
26
+ *
27
+ * The session is keyed by the SDK-generated `mcp-session-id` header value.
28
+ * On a new connection (no session id header), we mint a fresh
29
+ * Server+Transport pair, parse the URL's `?services=` to determine which
30
+ * tools to expose, and let the SDK assign a session id during initialize
31
+ * via our `onsessioninitialized` callback.
32
+ *
33
+ * The service set is FROZEN for the session's lifetime — once initialize
34
+ * has run, we ignore any new `?services=` on subsequent requests because
35
+ * Claude Code (and any sane MCP client) won't send them anyway, and
36
+ * letting them mutate the tool surface mid-session would break the
37
+ * client's cached `tools/list`.
38
+ */
39
+
40
+ interface McpSession {
41
+ server: Server;
42
+ transport: WebStandardStreamableHTTPServerTransport;
43
+ services: ServiceProfilePair[];
44
+ toolNames: Set<string>;
45
+ /**
46
+ * Per-session "previously checked" tracking for gchat_list. The plan
47
+ * keys this by `sessionId:service:space`, but since we already have one
48
+ * Map *per session*, the key inside the Map only needs `service:space`.
49
+ */
50
+ lastChecked: Map<string, Date>;
51
+ }
52
+
53
+ const sessions = new Map<string, McpSession>();
54
+
55
+ /**
56
+ * For tests only — drop all in-memory session state. Tests that exercise
57
+ * the session manager should call this in afterEach so leaked state from
58
+ * one test never bleeds into another.
59
+ */
60
+ export function _resetSessionsForTests(): void {
61
+ for (const session of sessions.values()) {
62
+ try {
63
+ session.transport.close();
64
+ } catch {
65
+ /* ignore */
66
+ }
67
+ }
68
+ sessions.clear();
69
+ }
70
+
71
+ /* ------------------------------------------------------------------ */
72
+ /* services parsing + validation */
73
+ /* ------------------------------------------------------------------ */
74
+
75
+ export interface ParseServicesResult {
76
+ ok: true;
77
+ services: ServiceProfilePair[];
78
+ }
79
+ export interface ParseServicesError {
80
+ ok: false;
81
+ status: number;
82
+ message: string;
83
+ }
84
+
85
+ /**
86
+ * Parse the `?services=gmail:work,slack:team` query string into
87
+ * ServiceProfilePair[]. Returns a structured result so the caller can
88
+ * decide how to surface failures (HTTP 400, JSON-RPC error, etc.).
89
+ *
90
+ * Empty input → empty array (valid; the session just exposes no tools).
91
+ * Unknown service name → error with the offending service.
92
+ * Empty profile after `:` → error.
93
+ */
94
+ export function parseServicesQuery(
95
+ servicesParam: string | null
96
+ ): ParseServicesResult | ParseServicesError {
97
+ if (!servicesParam) {
98
+ return { ok: true, services: [] };
99
+ }
100
+
101
+ const parts = servicesParam
102
+ .split(',')
103
+ .map((p) => p.trim())
104
+ .filter((p) => p.length > 0);
105
+
106
+ if (parts.length === 0) {
107
+ return { ok: true, services: [] };
108
+ }
109
+
110
+ // Reject anything with an empty service name (e.g. ":foo" or "foo::bar").
111
+ for (const part of parts) {
112
+ if (part.startsWith(':') || part === ':') {
113
+ return {
114
+ ok: false,
115
+ status: 400,
116
+ message: `invalid services entry "${part}": service name is empty`,
117
+ };
118
+ }
119
+ const colonIdx = part.indexOf(':');
120
+ if (colonIdx !== -1 && colonIdx === part.length - 1) {
121
+ return {
122
+ ok: false,
123
+ status: 400,
124
+ message: `invalid services entry "${part}": profile name is empty after ":"`,
125
+ };
126
+ }
127
+ }
128
+
129
+ const pairs = parseServiceProfiles(parts);
130
+
131
+ // Validate every service exists in the registry.
132
+ for (const pair of pairs) {
133
+ if (!(pair.service in SERVICE_REGISTRATIONS)) {
134
+ const known = Object.keys(SERVICE_REGISTRATIONS).sort().join(', ');
135
+ return {
136
+ ok: false,
137
+ status: 400,
138
+ message: `unknown service "${pair.service}". known services: ${known}`,
139
+ };
140
+ }
141
+ }
142
+
143
+ return { ok: true, services: pairs };
144
+ }
145
+
146
+ /* ------------------------------------------------------------------ */
147
+ /* session creation */
148
+ /* ------------------------------------------------------------------ */
149
+
150
+ /**
151
+ * Build the Server + Transport pair for a new MCP session, register
152
+ * tool handlers, and connect them. The session is added to the global
153
+ * `sessions` map by the `onsessioninitialized` callback (fired during
154
+ * the first `transport.handleRequest()` call from the initialize
155
+ * request).
156
+ */
157
+ async function createSession(
158
+ pairs: ServiceProfilePair[]
159
+ ): Promise<McpSession> {
160
+ const serviceNames = [...new Set(pairs.map((p) => p.service))];
161
+ const profileMap = new Map<string, string | undefined>();
162
+ for (const pair of pairs) {
163
+ profileMap.set(pair.service, pair.profile);
164
+ }
165
+
166
+ // Build the program once to collect the tool list. The same program is
167
+ // NOT reused for executeCommand — we build a fresh one per call to
168
+ // avoid Commander state leaks across invocations.
169
+ const toolProgram = buildProgram(serviceNames);
170
+ const allTools: McpToolDefinition[] = [];
171
+ for (const service of serviceNames) {
172
+ allTools.push(...collectMcpTools(toolProgram, service));
173
+ }
174
+ const toolNames = new Set(allTools.map((t) => t.name));
175
+
176
+ const server = new Server(
177
+ { name: 'agentio', version: '1.0.0' },
178
+ { capabilities: { tools: {} } }
179
+ );
180
+
181
+ // Allocated up front so the closures below + onsessioninitialized can
182
+ // close over the same object.
183
+ const session: McpSession = {
184
+ server,
185
+ // transport is filled in below; the field is non-null after the
186
+ // assignment but TypeScript doesn't know that mid-construction.
187
+ transport: undefined as unknown as WebStandardStreamableHTTPServerTransport,
188
+ services: pairs,
189
+ toolNames,
190
+ lastChecked: new Map(),
191
+ };
192
+
193
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
194
+ return {
195
+ tools: allTools.map((tool) => ({
196
+ name: tool.name,
197
+ description: tool.description,
198
+ inputSchema: tool.inputSchema,
199
+ })),
200
+ };
201
+ });
202
+
203
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
204
+ const { name, arguments: args } = request.params;
205
+ const tool = allTools.find((t) => t.name === name);
206
+
207
+ if (!tool) {
208
+ return {
209
+ content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }],
210
+ isError: true,
211
+ };
212
+ }
213
+
214
+ const service = tool.commandPath[0];
215
+ const profile = profileMap.get(service);
216
+ const input = (args as Record<string, unknown>) || {};
217
+
218
+ // Fresh program per call — Commander mutates parser state, and two
219
+ // overlapping calls in the same session would otherwise stomp on
220
+ // each other.
221
+ const execProgram = buildProgram(serviceNames);
222
+
223
+ try {
224
+ const output = await executeCommand(execProgram, tool, input, profile);
225
+
226
+ let result = output || '(no output)';
227
+ if (name === 'gchat_list' && typeof input.space === 'string') {
228
+ const key = `gchat:${input.space}`;
229
+ const last = session.lastChecked.get(key);
230
+ if (last) {
231
+ result += `\n\nPreviously checked: ${last.toISOString()}`;
232
+ }
233
+ session.lastChecked.set(key, new Date());
234
+ }
235
+
236
+ return {
237
+ content: [{ type: 'text' as const, text: result }],
238
+ };
239
+ } catch (error: unknown) {
240
+ const message =
241
+ error instanceof Error ? error.message : String(error);
242
+ return {
243
+ content: [{ type: 'text' as const, text: `Error: ${message}` }],
244
+ isError: true,
245
+ };
246
+ }
247
+ });
248
+
249
+ const transport = new WebStandardStreamableHTTPServerTransport({
250
+ sessionIdGenerator: () => randomUUID(),
251
+ enableJsonResponse: true, // simpler for hand-rolled HTTP testing
252
+ onsessioninitialized: (sid) => {
253
+ sessions.set(sid, session);
254
+ },
255
+ onsessionclosed: (sid) => {
256
+ sessions.delete(sid);
257
+ },
258
+ });
259
+
260
+ session.transport = transport;
261
+ await server.connect(transport);
262
+
263
+ return session;
264
+ }
265
+
266
+ /* ------------------------------------------------------------------ */
267
+ /* request entry point */
268
+ /* ------------------------------------------------------------------ */
269
+
270
+ /**
271
+ * Top-level handler for /mcp requests, called from `http.ts` after the
272
+ * bearer middleware has run.
273
+ *
274
+ * - Existing session id in `mcp-session-id` header → look up the session
275
+ * and forward to its transport. Unknown id → 404.
276
+ * - No session id → create a new session, validate `?services=`, then
277
+ * forward to the freshly-built transport. The SDK will assign a
278
+ * session id during initialize and our `onsessioninitialized` callback
279
+ * will register it in the map.
280
+ */
281
+ export async function handleMcpRequest(req: Request): Promise<Response> {
282
+ const sessionId = req.headers.get('mcp-session-id');
283
+
284
+ if (sessionId) {
285
+ const session = sessions.get(sessionId);
286
+ if (!session) {
287
+ return new Response(
288
+ JSON.stringify({
289
+ jsonrpc: '2.0',
290
+ error: {
291
+ code: -32001,
292
+ message: `unknown session id "${sessionId}"`,
293
+ },
294
+ id: null,
295
+ }),
296
+ {
297
+ status: 404,
298
+ headers: { 'content-type': 'application/json' },
299
+ }
300
+ );
301
+ }
302
+ return session.transport.handleRequest(req);
303
+ }
304
+
305
+ // No session id — this should be an initialize request. Validate
306
+ // services BEFORE building the session so a typo in ?services= gives
307
+ // a clean 400 instead of an opaque MCP error.
308
+ const url = new URL(req.url);
309
+ const parsed = parseServicesQuery(url.searchParams.get('services'));
310
+ if (!parsed.ok) {
311
+ return new Response(
312
+ JSON.stringify({
313
+ error: 'invalid_request',
314
+ error_description: parsed.message,
315
+ }),
316
+ {
317
+ status: parsed.status,
318
+ headers: { 'content-type': 'application/json' },
319
+ }
320
+ );
321
+ }
322
+
323
+ const session = await createSession(parsed.services);
324
+ return session.transport.handleRequest(req);
325
+ }
326
+
327
+ /**
328
+ * Test/diagnostic helper: how many sessions are currently active?
329
+ */
330
+ export function _getSessionCount(): number {
331
+ return sessions.size;
332
+ }
333
+
334
+ /**
335
+ * Test helper: peek at the live sessions map. Do not mutate.
336
+ */
337
+ export function _peekSessionIds(): string[] {
338
+ return [...sessions.keys()];
339
+ }