@mcp-web/bridge 0.1.0

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.
Files changed (72) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +311 -0
  3. package/dist/adapters/bun.d.ts +95 -0
  4. package/dist/adapters/bun.d.ts.map +1 -0
  5. package/dist/adapters/bun.js +286 -0
  6. package/dist/adapters/bun.js.map +1 -0
  7. package/dist/adapters/deno.d.ts +89 -0
  8. package/dist/adapters/deno.d.ts.map +1 -0
  9. package/dist/adapters/deno.js +249 -0
  10. package/dist/adapters/deno.js.map +1 -0
  11. package/dist/adapters/index.d.ts +21 -0
  12. package/dist/adapters/index.d.ts.map +1 -0
  13. package/dist/adapters/index.js +21 -0
  14. package/dist/adapters/index.js.map +1 -0
  15. package/dist/adapters/node.d.ts +112 -0
  16. package/dist/adapters/node.d.ts.map +1 -0
  17. package/dist/adapters/node.js +309 -0
  18. package/dist/adapters/node.js.map +1 -0
  19. package/dist/adapters/partykit.d.ts +153 -0
  20. package/dist/adapters/partykit.d.ts.map +1 -0
  21. package/dist/adapters/partykit.js +372 -0
  22. package/dist/adapters/partykit.js.map +1 -0
  23. package/dist/bridge.d.ts +38 -0
  24. package/dist/bridge.d.ts.map +1 -0
  25. package/dist/bridge.js +1004 -0
  26. package/dist/bridge.js.map +1 -0
  27. package/dist/core.d.ts +75 -0
  28. package/dist/core.d.ts.map +1 -0
  29. package/dist/core.js +1508 -0
  30. package/dist/core.js.map +1 -0
  31. package/dist/index.d.ts +38 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +42 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/runtime/index.d.ts +11 -0
  36. package/dist/runtime/index.d.ts.map +1 -0
  37. package/dist/runtime/index.js +9 -0
  38. package/dist/runtime/index.js.map +1 -0
  39. package/dist/runtime/scheduler.d.ts +69 -0
  40. package/dist/runtime/scheduler.d.ts.map +1 -0
  41. package/dist/runtime/scheduler.js +88 -0
  42. package/dist/runtime/scheduler.js.map +1 -0
  43. package/dist/runtime/types.d.ts +144 -0
  44. package/dist/runtime/types.d.ts.map +1 -0
  45. package/dist/runtime/types.js +82 -0
  46. package/dist/runtime/types.js.map +1 -0
  47. package/dist/schemas.d.ts +6 -0
  48. package/dist/schemas.d.ts.map +1 -0
  49. package/dist/schemas.js +6 -0
  50. package/dist/schemas.js.map +1 -0
  51. package/dist/types.d.ts +130 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +2 -0
  54. package/dist/types.js.map +1 -0
  55. package/package.json +28 -0
  56. package/src/adapters/bun.ts +354 -0
  57. package/src/adapters/deno.ts +282 -0
  58. package/src/adapters/index.ts +28 -0
  59. package/src/adapters/node.ts +385 -0
  60. package/src/adapters/partykit.ts +482 -0
  61. package/src/bridge.test.ts +64 -0
  62. package/src/core.ts +2176 -0
  63. package/src/index.ts +90 -0
  64. package/src/limits.test.ts +436 -0
  65. package/src/remote-mcp.test.ts +770 -0
  66. package/src/runtime/index.ts +24 -0
  67. package/src/runtime/scheduler.ts +130 -0
  68. package/src/runtime/types.ts +229 -0
  69. package/src/schemas.ts +6 -0
  70. package/src/session-naming.test.ts +443 -0
  71. package/src/types.ts +180 -0
  72. package/tsconfig.json +12 -0
package/src/core.ts ADDED
@@ -0,0 +1,2176 @@
1
+ /**
2
+ * @fileoverview MCPWebBridge - Runtime-agnostic core for the MCP Web Bridge.
3
+ *
4
+ * This module provides the core bridge functionality that connects web frontends
5
+ * to AI agents via the Model Context Protocol (MCP). The bridge acts as an
6
+ * intermediary, handling WebSocket connections from frontends and HTTP requests
7
+ * from MCP clients.
8
+ * @module @mcp-web/bridge
9
+ */
10
+
11
+ import crypto from 'node:crypto';
12
+ import { readFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath, URL } from 'node:url';
15
+ import {
16
+ type AvailableSession,
17
+ type ErroredListPromptsResult,
18
+ type ErroredListResourcesResult,
19
+ type ErroredListToolsResult,
20
+ type FatalError,
21
+ InternalErrorCode,
22
+ InvalidSessionErrorCode,
23
+ type MCPWebConfig,
24
+ type MCPWebConfigOutput,
25
+ McpWebConfigSchema,
26
+ MissingAuthenticationErrorCode,
27
+ NoSessionsFoundErrorCode,
28
+ QueryAcceptedMessageSchema,
29
+ type QueryCancelMessage,
30
+ QueryCancelMessageSchema,
31
+ QueryCompleteBridgeMessageSchema,
32
+ QueryCompleteClientMessageSchema,
33
+ QueryFailureMessageSchema,
34
+ QueryLimitExceededErrorCode,
35
+ type QueryMessage,
36
+ QueryMessageSchema,
37
+ QueryNotActiveErrorCode,
38
+ QueryNotFoundErrorCode,
39
+ QueryProgressMessageSchema,
40
+ type ResourceMetadata,
41
+ SessionExpiredErrorCode,
42
+ SessionLimitExceededErrorCode,
43
+ SessionNameAlreadyInUseErrorCode,
44
+ SessionNotFoundErrorCode,
45
+ SessionNotSpecifiedErrorCode,
46
+ type ToolMetadata,
47
+ ToolNameRequiredErrorCode,
48
+ ToolNotAllowedErrorCode,
49
+ ToolNotFoundErrorCode,
50
+ ToolSchemaConflictErrorCode,
51
+ UnknownMethodErrorCode,
52
+ } from '@mcp-web/types';
53
+ import type {
54
+ ListPromptsResult,
55
+ ListResourcesResult,
56
+ ListToolsResult,
57
+ Resource,
58
+ Tool,
59
+ } from '@modelcontextprotocol/sdk/types.js';
60
+ import type { z } from 'zod';
61
+ import type { Scheduler } from './runtime/scheduler.js';
62
+ import { NoopScheduler } from './runtime/scheduler.js';
63
+ import type {
64
+ BridgeHandlers,
65
+ HttpRequest,
66
+ HttpResponse,
67
+ SSEResponse,
68
+ SSEWriter,
69
+ WebSocketConnection,
70
+ } from './runtime/types.js';
71
+ import { jsonResponse, sseResponse } from './runtime/types.js';
72
+
73
+ const SessionNotSpecifiedErrorDetails =
74
+ 'Multiple sessions available. See `available_sessions` or call the `list_sessions` tool to discover available sessions and specify the session using `_meta.sessionId`.';
75
+
76
+ // ============================================
77
+ // Internal Types
78
+ // ============================================
79
+
80
+ interface AuthenticateMessage {
81
+ type: 'authenticate';
82
+ sessionId: string;
83
+ authToken: string;
84
+ origin: string;
85
+ pageTitle?: string;
86
+ sessionName?: string;
87
+ userAgent?: string;
88
+ timestamp: number;
89
+ }
90
+
91
+ interface RegisterToolMessage {
92
+ type: 'register-tool';
93
+ tool: {
94
+ name: string;
95
+ description: string;
96
+ inputSchema?: z.core.JSONSchema.JSONSchema;
97
+ outputSchema?: z.core.JSONSchema.JSONSchema;
98
+ _meta?: Record<string, unknown>;
99
+ };
100
+ }
101
+
102
+ interface RegisterResourceMessage {
103
+ type: 'register-resource';
104
+ resource: {
105
+ uri: string;
106
+ name: string;
107
+ description?: string;
108
+ mimeType?: string;
109
+ };
110
+ }
111
+
112
+ interface ResourceReadMessage {
113
+ type: 'resource-read';
114
+ requestId: string;
115
+ uri: string;
116
+ }
117
+
118
+ interface ResourceResponseMessage {
119
+ type: 'resource-response';
120
+ requestId: string;
121
+ content?: string;
122
+ blob?: string;
123
+ mimeType: string;
124
+ error?: string;
125
+ }
126
+
127
+ interface ActivityMessage {
128
+ type: 'activity';
129
+ timestamp: number;
130
+ }
131
+
132
+ interface ToolCallMessage {
133
+ type: 'tool-call';
134
+ requestId: string;
135
+ toolName: string;
136
+ toolInput?: Record<string, unknown>;
137
+ queryId?: string;
138
+ }
139
+
140
+ interface ToolResponseMessage {
141
+ type: 'tool-response';
142
+ requestId: string;
143
+ result: unknown;
144
+ }
145
+
146
+ type FrontendMessage =
147
+ | AuthenticateMessage
148
+ | RegisterToolMessage
149
+ | RegisterResourceMessage
150
+ | ActivityMessage
151
+ | ToolResponseMessage
152
+ | ResourceResponseMessage
153
+ | QueryMessage
154
+ | QueryCancelMessage;
155
+
156
+ interface ToolDefinition {
157
+ name: string;
158
+ description: string;
159
+ inputSchema?: z.core.JSONSchema.JSONSchema;
160
+ outputSchema?: z.core.JSONSchema.JSONSchema;
161
+ handler?: string;
162
+ _meta?: Record<string, unknown>;
163
+ }
164
+
165
+ interface SessionData {
166
+ ws: WebSocketConnection;
167
+ authToken: string;
168
+ origin: string;
169
+ pageTitle?: string;
170
+ sessionName?: string;
171
+ userAgent?: string;
172
+ connectedAt: number;
173
+ lastActivity: number;
174
+ tools: Map<string, ToolDefinition>;
175
+ resources: Map<string, ResourceMetadata>;
176
+ }
177
+
178
+ interface TrackedToolCall {
179
+ tool: string;
180
+ arguments: unknown;
181
+ result: unknown;
182
+ }
183
+
184
+ type QueryState = 'active' | 'completed' | 'failed' | 'cancelled';
185
+
186
+ interface QueryTracking {
187
+ sessionId: string;
188
+ responseTool?: string;
189
+ toolCalls: TrackedToolCall[];
190
+ ws: WebSocketConnection;
191
+ state: QueryState;
192
+ tools?: ToolMetadata[];
193
+ restrictTools?: boolean;
194
+ }
195
+
196
+ interface McpRequest {
197
+ jsonrpc: string;
198
+ id: string | number;
199
+ method: string;
200
+ params?: {
201
+ name?: string;
202
+ uri?: string;
203
+ arguments?: Record<string, unknown>;
204
+ _meta?: {
205
+ sessionId?: string;
206
+ queryId?: string;
207
+ };
208
+ };
209
+ }
210
+
211
+ interface McpResponse {
212
+ jsonrpc: string;
213
+ id: string | number;
214
+ result?: unknown;
215
+ error?: {
216
+ code: number;
217
+ message: string;
218
+ data?: unknown;
219
+ };
220
+ }
221
+
222
+ /**
223
+ * MCP protocol session for Remote MCP (Streamable HTTP) connections.
224
+ * Tracks Claude Desktop connections and enables server-initiated notifications.
225
+ */
226
+ interface McpSession {
227
+ /** Unique session identifier (returned in Mcp-Session-Id header) */
228
+ id: string;
229
+ /** Auth token associated with this MCP session */
230
+ authToken?: string;
231
+ /** When the session was created */
232
+ createdAt: number;
233
+ /** Last activity timestamp for idle timeout */
234
+ lastActivity: number;
235
+ /** SSE writer for pushing notifications (if GET stream is open) */
236
+ sseWriter?: SSEWriter;
237
+ /** Cleanup function to call when SSE stream closes */
238
+ sseCleanup?: () => void;
239
+ }
240
+
241
+ // ============================================
242
+ // Helper Functions
243
+ // ============================================
244
+
245
+ /**
246
+ * Builds the query URL by appending the UUID to the agent URL.
247
+ * If no protocol is specified, defaults to http://.
248
+ */
249
+ const buildQueryUrl = (agentUrl: string, uuid: string): string => {
250
+ // Add http:// if no protocol specified
251
+ const urlWithProtocol = agentUrl.includes('://') ? agentUrl : `http://${agentUrl}`;
252
+ const url = new URL(urlWithProtocol);
253
+
254
+ if (url.pathname === '/' || url.pathname === '') {
255
+ url.pathname = '/query';
256
+ }
257
+
258
+ if (url.pathname.endsWith('/')) {
259
+ url.pathname = url.pathname.slice(0, -1);
260
+ }
261
+
262
+ url.pathname = `${url.pathname}/${uuid}`;
263
+
264
+ return url.toString();
265
+ };
266
+
267
+ // ============================================
268
+ // MCPWebBridge Core Class
269
+ // ============================================
270
+
271
+ /**
272
+ * Core bridge server that connects web frontends to AI agents via MCP.
273
+ *
274
+ * MCPWebBridge manages WebSocket connections from frontends, routes tool calls,
275
+ * handles queries, and exposes an HTTP API for MCP clients. It is runtime-agnostic
276
+ * and delegates I/O operations to adapters.
277
+ *
278
+ * @example Using with Node.js adapter (recommended)
279
+ * ```typescript
280
+ * import { MCPWebBridgeNode } from '@mcp-web/bridge';
281
+ *
282
+ * const bridge = new MCPWebBridgeNode({
283
+ * name: 'My App Bridge',
284
+ * description: 'Bridge for my web application',
285
+ * });
286
+ * ```
287
+ *
288
+ * @example Using core class with custom adapter
289
+ * ```typescript
290
+ * import { MCPWebBridge } from '@mcp-web/bridge';
291
+ *
292
+ * const bridge = new MCPWebBridge(config);
293
+ * const handlers = bridge.getHandlers();
294
+ * // Wire handlers to your runtime's WebSocket/HTTP servers
295
+ * ```
296
+ */
297
+ export class MCPWebBridge {
298
+ #sessions = new Map<string, SessionData>();
299
+ #queries = new Map<string, QueryTracking>();
300
+ #config: MCPWebConfigOutput;
301
+ #scheduler: Scheduler;
302
+
303
+ // Session & Query limit tracking
304
+ #tokenSessionIds = new Map<string, Set<string>>();
305
+ #tokenQueryCounts = new Map<string, number>();
306
+ #sessionTimeoutIntervalId?: string;
307
+
308
+ // Message handlers for tool responses (keyed by requestId)
309
+ #toolResponseHandlers = new Map<string, (data: string) => void>();
310
+ // Message handlers for resource responses (keyed by requestId)
311
+ #resourceResponseHandlers = new Map<string, (data: string) => void>();
312
+
313
+ // MCP protocol sessions (Remote MCP / Streamable HTTP)
314
+ #mcpSessions = new Map<string, McpSession>();
315
+ #mcpSessionTimeoutIntervalId?: string;
316
+ static readonly MCP_SESSION_IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
317
+
318
+ // Resolved icon (data URI), populated asynchronously if icon is a URL
319
+ #resolvedIcon: string | undefined;
320
+ #iconReady: Promise<void>;
321
+
322
+ /**
323
+ * Creates a new MCPWebBridge instance.
324
+ *
325
+ * @param config - Bridge configuration options
326
+ * @param scheduler - Optional scheduler for timing operations (used for testing)
327
+ * @throws {Error} If configuration validation fails
328
+ */
329
+ constructor(config: MCPWebConfig, scheduler?: Scheduler) {
330
+ const parsedConfig = McpWebConfigSchema.safeParse(config);
331
+ if (!parsedConfig.success) {
332
+ throw new Error(
333
+ `Invalid bridge server configuration: ${parsedConfig.error.message}`
334
+ );
335
+ }
336
+
337
+ this.#config = parsedConfig.data;
338
+ this.#scheduler = scheduler ?? new NoopScheduler();
339
+
340
+ // Resolve icon: if it's a URL, fetch and convert to base64 data URI
341
+ this.#iconReady = this.#resolveIcon();
342
+
343
+ // Start session timeout checker if configured
344
+ if (this.#config.sessionMaxDurationMs) {
345
+ this.#startSessionTimeoutChecker();
346
+ }
347
+
348
+ // Start MCP session idle timeout checker
349
+ this.#startMcpSessionTimeoutChecker();
350
+ }
351
+
352
+ /**
353
+ * The validated bridge configuration.
354
+ * @returns The complete configuration object with defaults applied
355
+ */
356
+ get config(): MCPWebConfigOutput {
357
+ return this.#config;
358
+ }
359
+
360
+ /**
361
+ * Returns handlers for wiring to runtime-specific I/O.
362
+ *
363
+ * Use these handlers to connect the bridge to your runtime's WebSocket
364
+ * and HTTP servers. Pre-built adapters (Node, Bun, Deno, PartyKit) handle
365
+ * this automatically.
366
+ *
367
+ * @returns Object containing WebSocket and HTTP handlers
368
+ */
369
+ getHandlers(): BridgeHandlers {
370
+ return {
371
+ onWebSocketConnect: (sessionId, ws, url) =>
372
+ this.#handleWebSocketConnect(sessionId, ws, url),
373
+ onWebSocketMessage: (sessionId, ws, data) =>
374
+ this.#handleWebSocketMessage(sessionId, ws, data),
375
+ onWebSocketClose: (sessionId) => this.#handleWebSocketClose(sessionId),
376
+ onHttpRequest: (req) => this.#handleHttpRequest(req),
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Gracefully shuts down the bridge.
382
+ *
383
+ * Closes all WebSocket connections, cancels scheduled tasks, and clears
384
+ * all internal state. Call this when shutting down your server.
385
+ *
386
+ * @returns Promise that resolves when shutdown is complete
387
+ */
388
+ async close(): Promise<void> {
389
+ // Stop session timeout checker
390
+ if (this.#sessionTimeoutIntervalId) {
391
+ this.#scheduler.cancelInterval(this.#sessionTimeoutIntervalId);
392
+ this.#sessionTimeoutIntervalId = undefined;
393
+ }
394
+
395
+ // Stop MCP session timeout checker
396
+ if (this.#mcpSessionTimeoutIntervalId) {
397
+ this.#scheduler.cancelInterval(this.#mcpSessionTimeoutIntervalId);
398
+ this.#mcpSessionTimeoutIntervalId = undefined;
399
+ }
400
+
401
+ // Close all WebSocket connections
402
+ for (const session of this.#sessions.values()) {
403
+ if (session.ws.readyState === 'OPEN') {
404
+ session.ws.close(1000, 'Server shutting down');
405
+ }
406
+ }
407
+ this.#sessions.clear();
408
+
409
+ // Clean up MCP sessions (close SSE streams)
410
+ for (const mcpSession of this.#mcpSessions.values()) {
411
+ if (mcpSession.sseCleanup) {
412
+ mcpSession.sseCleanup();
413
+ }
414
+ }
415
+ this.#mcpSessions.clear();
416
+
417
+ // Clear queries and tracking maps
418
+ this.#queries.clear();
419
+ this.#tokenSessionIds.clear();
420
+ this.#tokenQueryCounts.clear();
421
+ this.#toolResponseHandlers.clear();
422
+
423
+ // Dispose scheduler
424
+ this.#scheduler.dispose();
425
+ }
426
+
427
+ // ============================================
428
+ // WebSocket Handlers
429
+ // ============================================
430
+
431
+ #handleWebSocketConnect(
432
+ _sessionId: string,
433
+ _ws: WebSocketConnection,
434
+ url: URL
435
+ ): boolean {
436
+ const session = url.searchParams.get('session');
437
+ if (!session) {
438
+ return false;
439
+ }
440
+ return true;
441
+ }
442
+
443
+ #handleWebSocketMessage(
444
+ sessionId: string,
445
+ ws: WebSocketConnection,
446
+ data: string
447
+ ): void {
448
+ try {
449
+ const message = JSON.parse(data) as FrontendMessage;
450
+
451
+ // Check if this is a tool response
452
+ if (message.type === 'tool-response') {
453
+ const handler = this.#toolResponseHandlers.get(
454
+ (message as ToolResponseMessage).requestId
455
+ );
456
+ if (handler) {
457
+ handler(data);
458
+ return;
459
+ }
460
+ }
461
+
462
+ // Check if this is a resource response
463
+ if (message.type === 'resource-response') {
464
+ const handler = this.#resourceResponseHandlers.get(
465
+ (message as ResourceResponseMessage).requestId
466
+ );
467
+ if (handler) {
468
+ handler(data);
469
+ return;
470
+ }
471
+ }
472
+
473
+ this.#handleFrontendMessage(sessionId, message, ws);
474
+ } catch (error) {
475
+ console.error('Invalid JSON message:', error);
476
+ ws.close(1003, 'Invalid JSON');
477
+ }
478
+ }
479
+
480
+ #handleWebSocketClose(sessionId: string): void {
481
+ this.#cleanupSession(sessionId);
482
+ }
483
+
484
+ // ============================================
485
+ // Frontend Message Handling
486
+ // ============================================
487
+
488
+ #handleFrontendMessage(
489
+ sessionId: string,
490
+ message: FrontendMessage,
491
+ ws: WebSocketConnection
492
+ ): void {
493
+ switch (message.type) {
494
+ case 'authenticate':
495
+ this.#handleAuthentication(sessionId, message, ws);
496
+ break;
497
+ case 'register-tool':
498
+ this.#handleToolRegistration(sessionId, message);
499
+ break;
500
+ case 'register-resource':
501
+ this.#handleResourceRegistration(sessionId, message);
502
+ break;
503
+ case 'activity':
504
+ this.#handleActivity(sessionId, message);
505
+ break;
506
+ case 'tool-response':
507
+ // Handled by per-request listeners
508
+ break;
509
+ case 'resource-response':
510
+ // Handled by per-request listeners
511
+ break;
512
+ case 'query':
513
+ this.#handleQuery(sessionId, message, ws);
514
+ break;
515
+ case 'query_cancel':
516
+ this.#handleQueryCancel(message);
517
+ break;
518
+ default:
519
+ console.warn(`Unknown message type: ${(message as { type: string }).type}`);
520
+ }
521
+ }
522
+
523
+ #handleAuthentication(
524
+ sessionId: string,
525
+ message: AuthenticateMessage,
526
+ ws: WebSocketConnection
527
+ ): void {
528
+ const { authToken } = message;
529
+
530
+ // Check session limit
531
+ if (this.#config.maxSessionsPerToken) {
532
+ const existingSessions = this.#tokenSessionIds.get(authToken);
533
+ const currentCount = existingSessions?.size ?? 0;
534
+
535
+ if (currentCount >= this.#config.maxSessionsPerToken) {
536
+ if (this.#config.onSessionLimitExceeded === 'close_oldest') {
537
+ this.#closeOldestSessionForToken(authToken);
538
+ } else {
539
+ ws.send(
540
+ JSON.stringify({
541
+ type: 'authentication-failed',
542
+ error: 'Session limit exceeded',
543
+ code: SessionLimitExceededErrorCode,
544
+ })
545
+ );
546
+ ws.close(1008, 'Session limit exceeded');
547
+ return;
548
+ }
549
+ }
550
+ }
551
+
552
+ // Check for duplicate session name
553
+ if (message.sessionName) {
554
+ const existingSessionIds = this.#tokenSessionIds.get(authToken);
555
+ if (existingSessionIds) {
556
+ for (const existingId of existingSessionIds) {
557
+ const existingSession = this.#sessions.get(existingId);
558
+ if (existingSession?.sessionName === message.sessionName) {
559
+ ws.send(
560
+ JSON.stringify({
561
+ type: 'authentication-failed',
562
+ error: `Session name "${message.sessionName}" is already in use`,
563
+ code: SessionNameAlreadyInUseErrorCode,
564
+ })
565
+ );
566
+ ws.close(1008, 'Session name already in use');
567
+ return;
568
+ }
569
+ }
570
+ }
571
+ }
572
+
573
+ const sessionData: SessionData = {
574
+ ws,
575
+ authToken: message.authToken,
576
+ origin: message.origin,
577
+ pageTitle: message.pageTitle,
578
+ sessionName: message.sessionName,
579
+ userAgent: message.userAgent,
580
+ connectedAt: Date.now(),
581
+ lastActivity: Date.now(),
582
+ tools: new Map(),
583
+ resources: new Map(),
584
+ };
585
+
586
+ this.#sessions.set(sessionId, sessionData);
587
+
588
+ // Track session for this token
589
+ const sessionIds = this.#tokenSessionIds.get(authToken) ?? new Set();
590
+ sessionIds.add(sessionId);
591
+ this.#tokenSessionIds.set(authToken, sessionIds);
592
+
593
+ ws.send(
594
+ JSON.stringify({
595
+ type: 'authenticated',
596
+ sessionId,
597
+ success: true,
598
+ })
599
+ );
600
+ }
601
+
602
+ #handleToolRegistration(
603
+ sessionId: string,
604
+ message: RegisterToolMessage
605
+ ): void {
606
+ const session = this.#sessions.get(sessionId);
607
+ if (!session) {
608
+ console.warn(`Tool registration for unknown session: ${sessionId}`);
609
+ return;
610
+ }
611
+
612
+ const toolName = message.tool.name;
613
+ const newSchema = JSON.stringify(message.tool.inputSchema ?? {});
614
+
615
+ // Check sibling sessions (same auth token) for schema conflicts
616
+ const siblingSessionIds = this.#tokenSessionIds.get(session.authToken);
617
+ if (siblingSessionIds) {
618
+ for (const siblingId of siblingSessionIds) {
619
+ if (siblingId === sessionId) continue;
620
+ const sibling = this.#sessions.get(siblingId);
621
+ if (!sibling) continue;
622
+ const existingTool = sibling.tools.get(toolName);
623
+ if (existingTool) {
624
+ const existingSchema = JSON.stringify(existingTool.inputSchema ?? {});
625
+ if (existingSchema !== newSchema) {
626
+ console.warn(
627
+ `Tool schema conflict: '${toolName}' registered by session ${siblingId} has a different schema. Rejecting registration from session ${sessionId}.`
628
+ );
629
+ session.ws.send(
630
+ JSON.stringify({
631
+ type: 'tool-registration-error',
632
+ toolName,
633
+ error: ToolSchemaConflictErrorCode,
634
+ message: `Tool '${toolName}' is already registered by another session with a different schema. Tools with the same name must have identical schemas across sessions.`,
635
+ })
636
+ );
637
+ return;
638
+ }
639
+ }
640
+ }
641
+ }
642
+
643
+ console.log('registering tool for session', sessionId, message);
644
+ session.tools.set(message.tool.name, message.tool);
645
+
646
+ // Notify connected MCP clients (Claude Desktop) about tool changes
647
+ this.#notifyToolsChanged(session.authToken);
648
+ }
649
+
650
+ #handleResourceRegistration(
651
+ sessionId: string,
652
+ message: RegisterResourceMessage
653
+ ): void {
654
+ const session = this.#sessions.get(sessionId);
655
+ if (!session) {
656
+ console.warn(`Resource registration for unknown session: ${sessionId}`);
657
+ return;
658
+ }
659
+
660
+ console.log('registering resource for session', sessionId, message);
661
+ session.resources.set(message.resource.uri, message.resource);
662
+ }
663
+
664
+ #handleActivity(sessionId: string, message: ActivityMessage): void {
665
+ const session = this.#sessions.get(sessionId);
666
+ if (session) {
667
+ session.lastActivity = message.timestamp;
668
+ }
669
+ }
670
+
671
+ async #handleQueryCancel(message: QueryCancelMessage): Promise<void> {
672
+ const cancelMessage = QueryCancelMessageSchema.parse(message);
673
+ const { uuid } = cancelMessage;
674
+ const query = this.#queries.get(uuid);
675
+
676
+ if (!query) {
677
+ console.warn(`Cancel requested for unknown query: ${uuid}`);
678
+ return;
679
+ }
680
+
681
+ query.state = 'cancelled';
682
+
683
+ if (this.#config.agentUrl) {
684
+ try {
685
+ await fetch(buildQueryUrl(this.#config.agentUrl, uuid), {
686
+ method: 'DELETE',
687
+ headers: { 'Content-Type': 'application/json' },
688
+ });
689
+ } catch (error) {
690
+ console.debug(
691
+ `Failed to notify agent of query deletion (optional): ${error instanceof Error ? error.message : String(error)}`
692
+ );
693
+ }
694
+ }
695
+
696
+ this.#decrementQueryCountForQuery(query);
697
+ this.#queries.delete(uuid);
698
+ }
699
+
700
+ async #handleQuery(
701
+ sessionId: string,
702
+ message: QueryMessage,
703
+ ws: WebSocketConnection
704
+ ): Promise<void> {
705
+ const { uuid, responseTool, tools, restrictTools } = message;
706
+
707
+ if (!this.#config.agentUrl) {
708
+ ws.send(
709
+ JSON.stringify(
710
+ QueryFailureMessageSchema.parse({
711
+ uuid,
712
+ error: 'Missing Agent URL',
713
+ })
714
+ )
715
+ );
716
+ return;
717
+ }
718
+
719
+ const session = this.#sessions.get(sessionId);
720
+ if (!session) {
721
+ ws.send(
722
+ JSON.stringify(
723
+ QueryFailureMessageSchema.parse({
724
+ uuid,
725
+ error: 'Session not found',
726
+ })
727
+ )
728
+ );
729
+ return;
730
+ }
731
+
732
+ // Check query limit
733
+ if (this.#config.maxInFlightQueriesPerToken) {
734
+ const currentQueries =
735
+ this.#tokenQueryCounts.get(session.authToken) ?? 0;
736
+
737
+ if (currentQueries >= this.#config.maxInFlightQueriesPerToken) {
738
+ ws.send(
739
+ JSON.stringify(
740
+ QueryFailureMessageSchema.parse({
741
+ uuid,
742
+ error: 'Query limit exceeded. Wait for existing queries to complete.',
743
+ code: QueryLimitExceededErrorCode,
744
+ })
745
+ )
746
+ );
747
+ return;
748
+ }
749
+ }
750
+
751
+ // Increment query count
752
+ this.#tokenQueryCounts.set(
753
+ session.authToken,
754
+ (this.#tokenQueryCounts.get(session.authToken) ?? 0) + 1
755
+ );
756
+
757
+ try {
758
+ this.#queries.set(uuid, {
759
+ sessionId,
760
+ responseTool: responseTool?.name,
761
+ toolCalls: [],
762
+ ws,
763
+ state: 'active',
764
+ tools,
765
+ restrictTools,
766
+ });
767
+
768
+ const response = await fetch(
769
+ buildQueryUrl(this.#config.agentUrl, uuid),
770
+ {
771
+ method: 'PUT',
772
+ headers: {
773
+ 'Content-Type': 'application/json',
774
+ ...(this.#config.authToken && {
775
+ Authorization: `Bearer ${this.#config.authToken}`,
776
+ }),
777
+ },
778
+ body: JSON.stringify(QueryMessageSchema.parse(message)),
779
+ }
780
+ );
781
+
782
+ if (!response.ok) {
783
+ throw new Error(
784
+ `Agent responded with ${response.status}: ${response.statusText}`
785
+ );
786
+ }
787
+
788
+ ws.send(JSON.stringify(QueryAcceptedMessageSchema.parse({ uuid })));
789
+ } catch (error) {
790
+ console.error(`Error forwarding query ${uuid}:`, error);
791
+ this.#queries.delete(uuid);
792
+ this.#decrementQueryCount(session.authToken);
793
+ ws.send(
794
+ JSON.stringify(
795
+ QueryFailureMessageSchema.parse({
796
+ uuid,
797
+ error: `${error instanceof Error ? error.message : String(error)}`,
798
+ })
799
+ )
800
+ );
801
+ }
802
+ }
803
+
804
+ // ============================================
805
+ // HTTP Request Handling
806
+ // ============================================
807
+
808
+ async #handleHttpRequest(req: HttpRequest): Promise<HttpResponse | SSEResponse> {
809
+ const startTime = Date.now();
810
+
811
+ // Debug logging helper
812
+ const debug = (message: string, data?: unknown) => {
813
+ if (this.#config.debug) {
814
+ if (data !== undefined) {
815
+ console.log(`[MCP Debug] ${message}`, data);
816
+ } else {
817
+ console.log(`[MCP Debug] ${message}`);
818
+ }
819
+ }
820
+ };
821
+
822
+ debug(`→ ${req.method} ${req.url}`);
823
+ debug(` Headers:`, {
824
+ accept: req.headers.get('accept'),
825
+ contentType: req.headers.get('content-type'),
826
+ authorization: req.headers.get('authorization') ? '[PRESENT]' : '[ABSENT]',
827
+ mcpSessionId: req.headers.get('mcp-session-id'),
828
+ });
829
+
830
+ // Handle CORS preflight
831
+ if (req.method === 'OPTIONS') {
832
+ debug(`← 200 (CORS preflight)`);
833
+ return jsonResponse(200, '');
834
+ }
835
+
836
+ const url = new URL(req.url, 'http://localhost');
837
+ const pathname = url.pathname;
838
+
839
+ // Route query endpoints
840
+ const queryProgressMatch = pathname.match(/^\/query\/([^/]+)\/progress$/);
841
+ const queryCompleteMatch = pathname.match(/^\/query\/([^/]+)\/complete$/);
842
+ const queryFailMatch = pathname.match(/^\/query\/([^/]+)\/fail$/);
843
+ const queryCancelMatch = pathname.match(/^\/query\/([^/]+)\/cancel$/);
844
+
845
+ if (req.method === 'POST' && queryProgressMatch) {
846
+ return this.#handleQueryProgressEndpoint(queryProgressMatch[1], req);
847
+ }
848
+
849
+ if (req.method === 'PUT' && queryCompleteMatch) {
850
+ return this.#handleQueryCompleteEndpoint(queryCompleteMatch[1], req);
851
+ }
852
+
853
+ if (req.method === 'PUT' && queryFailMatch) {
854
+ return this.#handleQueryFailEndpoint(queryFailMatch[1], req);
855
+ }
856
+
857
+ if (req.method === 'PUT' && queryCancelMatch) {
858
+ return this.#handleQueryCancelEndpoint(queryCancelMatch[1], req);
859
+ }
860
+
861
+ // Handle MCP session deletion (client closing session)
862
+ if (req.method === 'DELETE') {
863
+ const mcpSessionId = req.headers.get('mcp-session-id');
864
+ if (mcpSessionId) {
865
+ debug(` Processing DELETE for session ${mcpSessionId}`);
866
+ const response = this.#handleMcpSessionDelete(mcpSessionId);
867
+ debug(`← ${response.status} (session delete) [${Date.now() - startTime}ms]`);
868
+ return response;
869
+ }
870
+ debug(`← 400 (missing Mcp-Session-Id) [${Date.now() - startTime}ms]`);
871
+ return jsonResponse(400, { error: 'Mcp-Session-Id header required' });
872
+ }
873
+
874
+ // Handle GET requests for SSE stream (Remote MCP server-initiated messages)
875
+ if (req.method === 'GET') {
876
+ const acceptsSSE = req.headers.get('accept')?.includes('text/event-stream');
877
+ if (acceptsSSE) {
878
+ debug(` Opening SSE stream`);
879
+ return this.#handleSSEStream(req);
880
+ }
881
+
882
+ // Plain GET returns server info (no auth required)
883
+ debug(`← 200 (server info) [${Date.now() - startTime}ms]`);
884
+ const icon = await this.#getIcon();
885
+ return jsonResponse(200, {
886
+ name: this.#config.name,
887
+ description: this.#config.description,
888
+ version: this.#getVersion(),
889
+ ...(icon && { icon }),
890
+ });
891
+ }
892
+
893
+ // Handle MCP JSON-RPC requests
894
+ if (req.method === 'POST') {
895
+ debug(` Processing MCP request`);
896
+ const response = await this.#handleMCPRequest(req);
897
+ debug(`← ${response.status} [${Date.now() - startTime}ms]`);
898
+ return response;
899
+ }
900
+
901
+ debug(`← 404 (not found) [${Date.now() - startTime}ms]`);
902
+ return jsonResponse(404, { error: 'Not Found' });
903
+ }
904
+
905
+ async #handleQueryProgressEndpoint(
906
+ uuid: string,
907
+ req: HttpRequest
908
+ ): Promise<HttpResponse> {
909
+ try {
910
+ const body = await req.text();
911
+ const message = JSON.parse(body);
912
+ const progressMessage = QueryProgressMessageSchema.parse({
913
+ uuid,
914
+ ...message,
915
+ });
916
+
917
+ const query = this.#queries.get(uuid);
918
+ if (!query) {
919
+ return jsonResponse(404, { error: QueryNotFoundErrorCode });
920
+ }
921
+
922
+ if (query.ws.readyState === 'OPEN') {
923
+ query.ws.send(JSON.stringify(progressMessage));
924
+ }
925
+
926
+ return jsonResponse(200, { success: true });
927
+ } catch (error) {
928
+ console.error('Error handling query progress:', error);
929
+ return jsonResponse(400, { error: 'Invalid request body' });
930
+ }
931
+ }
932
+
933
+ async #handleQueryCompleteEndpoint(
934
+ uuid: string,
935
+ req: HttpRequest
936
+ ): Promise<HttpResponse> {
937
+ try {
938
+ const body = await req.text();
939
+ const message = JSON.parse(body);
940
+ const completeMessage = QueryCompleteClientMessageSchema.parse({
941
+ uuid,
942
+ ...message,
943
+ });
944
+
945
+ const query = this.#queries.get(uuid);
946
+ if (!query) {
947
+ return jsonResponse(404, { error: QueryNotFoundErrorCode });
948
+ }
949
+
950
+ if (query.responseTool) {
951
+ const errorMessage = QueryFailureMessageSchema.parse({
952
+ uuid,
953
+ error: `Query specified responseTool '${query.responseTool}' but agent called queryComplete() instead`,
954
+ });
955
+
956
+ if (query.ws.readyState === 'OPEN') {
957
+ query.ws.send(JSON.stringify(errorMessage));
958
+ }
959
+
960
+ this.#decrementQueryCountForQuery(query);
961
+ this.#queries.delete(uuid);
962
+ return jsonResponse(400, { error: errorMessage.error });
963
+ }
964
+
965
+ query.state = 'completed';
966
+
967
+ const bridgeMessage = QueryCompleteBridgeMessageSchema.parse({
968
+ uuid,
969
+ message: completeMessage.message,
970
+ toolCalls: query.toolCalls,
971
+ });
972
+
973
+ if (query.ws.readyState === 'OPEN') {
974
+ query.ws.send(JSON.stringify(bridgeMessage));
975
+ }
976
+
977
+ this.#decrementQueryCountForQuery(query);
978
+ this.#queries.delete(uuid);
979
+ return jsonResponse(200, { success: true });
980
+ } catch (error) {
981
+ console.error('Error handling query complete:', error);
982
+ return jsonResponse(400, { error: 'Invalid request body' });
983
+ }
984
+ }
985
+
986
+ async #handleQueryFailEndpoint(
987
+ uuid: string,
988
+ req: HttpRequest
989
+ ): Promise<HttpResponse> {
990
+ try {
991
+ const body = await req.text();
992
+ const message = JSON.parse(body);
993
+ const failureMessage = QueryFailureMessageSchema.parse({
994
+ uuid,
995
+ ...message,
996
+ });
997
+
998
+ const query = this.#queries.get(uuid);
999
+ if (!query) {
1000
+ return jsonResponse(404, { error: QueryNotFoundErrorCode });
1001
+ }
1002
+
1003
+ query.state = 'failed';
1004
+
1005
+ if (query.ws.readyState === 'OPEN') {
1006
+ query.ws.send(JSON.stringify(failureMessage));
1007
+ }
1008
+
1009
+ this.#decrementQueryCountForQuery(query);
1010
+ this.#queries.delete(uuid);
1011
+ return jsonResponse(200, { success: true });
1012
+ } catch (error) {
1013
+ console.error('Error handling query fail:', error);
1014
+ return jsonResponse(400, { error: 'Invalid request body' });
1015
+ }
1016
+ }
1017
+
1018
+ async #handleQueryCancelEndpoint(
1019
+ uuid: string,
1020
+ req: HttpRequest
1021
+ ): Promise<HttpResponse> {
1022
+ try {
1023
+ const query = this.#queries.get(uuid);
1024
+ if (!query) {
1025
+ return jsonResponse(404, { error: QueryNotFoundErrorCode });
1026
+ }
1027
+
1028
+ query.state = 'cancelled';
1029
+
1030
+ const body = await req.text();
1031
+ const cancellationMessage = QueryCancelMessageSchema.parse({
1032
+ uuid,
1033
+ reason: body ? JSON.parse(body).reason : undefined,
1034
+ });
1035
+
1036
+ if (query.ws.readyState === 'OPEN') {
1037
+ query.ws.send(JSON.stringify(cancellationMessage));
1038
+ }
1039
+
1040
+ this.#decrementQueryCountForQuery(query);
1041
+ this.#queries.delete(uuid);
1042
+ return jsonResponse(200, { success: true });
1043
+ } catch (error) {
1044
+ console.error('Error handling query cancel:', error);
1045
+ return jsonResponse(400, { error: 'Invalid request body' });
1046
+ }
1047
+ }
1048
+
1049
+ // ============================================
1050
+ // MCP JSON-RPC Handling
1051
+ // ============================================
1052
+
1053
+ async #handleMCPRequest(req: HttpRequest): Promise<HttpResponse> {
1054
+ try {
1055
+ const body = await req.text();
1056
+ const mcpRequest: McpRequest = JSON.parse(body);
1057
+
1058
+ // Debug logging
1059
+ if (this.#config.debug) {
1060
+ console.log(`[MCP Debug] Method: ${mcpRequest.method}, ID: ${mcpRequest.id}`);
1061
+ if (mcpRequest.params && Object.keys(mcpRequest.params).length > 0) {
1062
+ console.log(`[MCP Debug] Params:`, JSON.stringify(mcpRequest.params).substring(0, 200));
1063
+ }
1064
+ }
1065
+
1066
+ // Extract auth token from header OR URL query param (for Remote MCP compatibility)
1067
+ const authHeader = req.headers.get('authorization');
1068
+ const url = new URL(req.url, 'http://localhost');
1069
+ const authToken = authHeader?.replace('Bearer ', '') ?? url.searchParams.get('token') ?? undefined;
1070
+ const mcpSessionId = req.headers.get('mcp-session-id');
1071
+ const queryId = mcpRequest.params?._meta?.queryId;
1072
+
1073
+ // Handle initialize separately - it creates an MCP session
1074
+ if (mcpRequest.method === 'initialize') {
1075
+ if (!authToken) {
1076
+ if (this.#config.debug) {
1077
+ console.log(`[MCP Debug] Error: Missing authentication token`);
1078
+ }
1079
+ return this.#mcpErrorResponse(
1080
+ mcpRequest.id,
1081
+ -32600,
1082
+ MissingAuthenticationErrorCode
1083
+ );
1084
+ }
1085
+ const { result, sessionId } = await this.#handleInitialize(authToken);
1086
+ if (this.#config.debug) {
1087
+ console.log(`[MCP Debug] Created MCP session: ${sessionId}`);
1088
+ }
1089
+ return this.#mcpSuccessResponseWithHeaders(mcpRequest.id, result, {
1090
+ 'Mcp-Session-Id': sessionId,
1091
+ });
1092
+ }
1093
+
1094
+ // Handle initialized notification (no response needed per spec, but we accept it)
1095
+ if (mcpRequest.method === 'notifications/initialized') {
1096
+ // Update MCP session activity
1097
+ if (mcpSessionId) {
1098
+ const mcpSession = this.#mcpSessions.get(mcpSessionId);
1099
+ if (mcpSession) {
1100
+ mcpSession.lastActivity = Date.now();
1101
+ }
1102
+ }
1103
+ return jsonResponse(202, '');
1104
+ }
1105
+
1106
+ // For all other requests, validate MCP session if provided
1107
+ if (mcpSessionId) {
1108
+ const mcpSession = this.#mcpSessions.get(mcpSessionId);
1109
+ if (!mcpSession) {
1110
+ return jsonResponse(404, { error: 'MCP session not found' });
1111
+ }
1112
+ mcpSession.lastActivity = Date.now();
1113
+ }
1114
+
1115
+ const sessions = new Map<string, SessionData>();
1116
+
1117
+ if (queryId) {
1118
+ const query = this.#queries.get(queryId);
1119
+ if (!query) {
1120
+ return this.#mcpErrorResponse(mcpRequest.id, -32600, QueryNotFoundErrorCode);
1121
+ }
1122
+ if (query.state !== 'active') {
1123
+ return this.#mcpErrorResponse(mcpRequest.id, -32600, QueryNotActiveErrorCode);
1124
+ }
1125
+ const session = this.#sessions.get(query.sessionId);
1126
+ if (!session) {
1127
+ return this.#mcpErrorResponse(mcpRequest.id, -32600, InvalidSessionErrorCode);
1128
+ }
1129
+ sessions.set(query.sessionId, session);
1130
+ } else if (authToken) {
1131
+ for (const [sessionId, session] of this.#sessions.entries()) {
1132
+ if (session.authToken === authToken) {
1133
+ sessions.set(sessionId, session);
1134
+ }
1135
+ }
1136
+ } else {
1137
+ return this.#mcpErrorResponse(
1138
+ mcpRequest.id,
1139
+ -32600,
1140
+ MissingAuthenticationErrorCode
1141
+ );
1142
+ }
1143
+
1144
+ if (sessions.size === 0) {
1145
+ return this.#mcpErrorResponse(mcpRequest.id, -32600, NoSessionsFoundErrorCode);
1146
+ }
1147
+
1148
+ let result: unknown;
1149
+ switch (mcpRequest.method) {
1150
+ case 'tools/list':
1151
+ result = await this.#handleToolsList(sessions, mcpRequest.params);
1152
+ break;
1153
+ case 'tools/call':
1154
+ result = await this.#handleToolCall(sessions, mcpRequest.params);
1155
+ result = this.#wrapToolCallResult(result);
1156
+ break;
1157
+ case 'resources/list':
1158
+ result = await this.#handleResourcesList(sessions, mcpRequest.params);
1159
+ break;
1160
+ case 'resources/read':
1161
+ result = await this.#handleResourceRead(sessions, mcpRequest.params);
1162
+ break;
1163
+ case 'prompts/list':
1164
+ result = await this.#handlePromptsList(sessions, mcpRequest.params);
1165
+ break;
1166
+ default:
1167
+ return this.#mcpErrorResponse(mcpRequest.id, -32601, UnknownMethodErrorCode);
1168
+ }
1169
+
1170
+ // Check for fatal errors
1171
+ if (
1172
+ result &&
1173
+ typeof result === 'object' &&
1174
+ 'error_is_fatal' in result &&
1175
+ result.error_is_fatal === true
1176
+ ) {
1177
+ const fatalError = result as FatalError;
1178
+ return this.#mcpErrorResponse(
1179
+ mcpRequest.id,
1180
+ -32602,
1181
+ fatalError.error_message,
1182
+ fatalError
1183
+ );
1184
+ }
1185
+
1186
+ return this.#mcpSuccessResponse(mcpRequest.id, result);
1187
+ } catch (error) {
1188
+ console.error('MCP request error:', error);
1189
+ return this.#mcpErrorResponse(0, -32603, InternalErrorCode);
1190
+ }
1191
+ }
1192
+
1193
+ #mcpSuccessResponse(id: string | number, result: unknown): HttpResponse {
1194
+ const response: McpResponse = {
1195
+ jsonrpc: '2.0',
1196
+ id,
1197
+ result,
1198
+ };
1199
+ return jsonResponse(200, response);
1200
+ }
1201
+
1202
+ #mcpSuccessResponseWithHeaders(
1203
+ id: string | number,
1204
+ result: unknown,
1205
+ headers: Record<string, string>
1206
+ ): HttpResponse {
1207
+ const response: McpResponse = {
1208
+ jsonrpc: '2.0',
1209
+ id,
1210
+ result,
1211
+ };
1212
+ return {
1213
+ status: 200,
1214
+ headers: {
1215
+ 'content-type': 'application/json',
1216
+ ...headers,
1217
+ },
1218
+ body: JSON.stringify(response),
1219
+ };
1220
+ }
1221
+
1222
+ #mcpErrorResponse(
1223
+ id: string | number,
1224
+ code: number,
1225
+ message: string,
1226
+ data?: unknown
1227
+ ): HttpResponse {
1228
+ const response: McpResponse = {
1229
+ jsonrpc: '2.0',
1230
+ id,
1231
+ error: { code, message, data },
1232
+ };
1233
+ return jsonResponse(200, response);
1234
+ }
1235
+
1236
+ /**
1237
+ * Wraps a tool call result in the MCP CallToolResult format.
1238
+ * This ensures compatibility with both Remote MCP (direct HTTP) and STDIO clients.
1239
+ *
1240
+ * If the result object contains a `_meta` field, it is extracted and placed at
1241
+ * the top level of the CallToolResult (as required by the MCP protocol), rather
1242
+ * than being serialized inside the JSON text content.
1243
+ */
1244
+ #wrapToolCallResult(result: unknown): {
1245
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
1246
+ isError?: boolean;
1247
+ _meta?: Record<string, unknown>;
1248
+ } {
1249
+ // Check if this is an error response
1250
+ if (result && typeof result === 'object' && 'error' in result) {
1251
+ return {
1252
+ content: [
1253
+ {
1254
+ type: 'text',
1255
+ text: JSON.stringify(result, null, 2),
1256
+ },
1257
+ ],
1258
+ isError: true,
1259
+ };
1260
+ }
1261
+
1262
+ // Handle different result types
1263
+ if (typeof result === 'string') {
1264
+ // Check if it's a data URL (image)
1265
+ if (result.startsWith('data:image/')) {
1266
+ const mimeType = result.split(';')[0].split(':')[1];
1267
+ // Extract base64 data after the comma
1268
+ const base64Data = result.split(',')[1];
1269
+ return {
1270
+ content: [
1271
+ {
1272
+ type: 'image',
1273
+ data: base64Data,
1274
+ mimeType,
1275
+ },
1276
+ ],
1277
+ };
1278
+ }
1279
+ return {
1280
+ content: [
1281
+ {
1282
+ type: 'text',
1283
+ text: result,
1284
+ },
1285
+ ],
1286
+ };
1287
+ }
1288
+
1289
+ if (result !== null && result !== undefined) {
1290
+ // Check if it's an object containing a data URL (e.g., { dataUrl: "data:image/png;base64,..." })
1291
+ // This handles tools that return image data wrapped in an object rather than as a raw string.
1292
+ if (typeof result === 'object' && 'dataUrl' in result) {
1293
+ const dataUrl = (result as Record<string, unknown>).dataUrl;
1294
+ if (typeof dataUrl === 'string' && dataUrl.startsWith('data:image/')) {
1295
+ const mimeType = dataUrl.split(';')[0].split(':')[1];
1296
+ const base64Data = dataUrl.split(',')[1];
1297
+ return {
1298
+ content: [
1299
+ {
1300
+ type: 'image',
1301
+ data: base64Data,
1302
+ mimeType,
1303
+ },
1304
+ ],
1305
+ };
1306
+ }
1307
+ }
1308
+
1309
+ // Extract _meta from the result object to place at the top level of CallToolResult.
1310
+ // The MCP protocol expects _meta as a top-level field on the result object,
1311
+ // not embedded inside the JSON text content (where the host can't find it).
1312
+ let topLevelMeta: Record<string, unknown> | undefined;
1313
+ let resultToSerialize = result;
1314
+
1315
+ if (typeof result === 'object' && '_meta' in result) {
1316
+ const { _meta, ...rest } = result as Record<string, unknown>;
1317
+ if (_meta && typeof _meta === 'object') {
1318
+ topLevelMeta = _meta as Record<string, unknown>;
1319
+ }
1320
+ resultToSerialize = rest;
1321
+ }
1322
+
1323
+ const wrapped: {
1324
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
1325
+ _meta?: Record<string, unknown>;
1326
+ } = {
1327
+ content: [
1328
+ {
1329
+ type: 'text',
1330
+ text: typeof resultToSerialize === 'object' ? JSON.stringify(resultToSerialize, null, 2) : String(resultToSerialize),
1331
+ },
1332
+ ],
1333
+ };
1334
+
1335
+ if (topLevelMeta) {
1336
+ wrapped._meta = topLevelMeta;
1337
+ }
1338
+
1339
+ return wrapped;
1340
+ }
1341
+
1342
+ // null or undefined result
1343
+ return {
1344
+ content: [
1345
+ {
1346
+ type: 'text',
1347
+ text: '',
1348
+ },
1349
+ ],
1350
+ };
1351
+ }
1352
+
1353
+ // ============================================
1354
+ // MCP Method Handlers
1355
+ // ============================================
1356
+
1357
+ #getVersion(): string {
1358
+ try {
1359
+ const __filename = fileURLToPath(import.meta.url);
1360
+ const __dirname = dirname(__filename);
1361
+ const packageJsonPath = join(__dirname, '..', 'package.json');
1362
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
1363
+ return packageJson.version || '1.0.0';
1364
+ } catch {
1365
+ return '1.0.0';
1366
+ }
1367
+ }
1368
+
1369
+ /**
1370
+ * Resolves the icon config value to a data URI.
1371
+ * If icon is already a data URI, uses it directly.
1372
+ * If icon is an HTTP(S) URL, fetches it and converts to a base64 data URI.
1373
+ * Fails gracefully — icon is simply omitted if resolution fails.
1374
+ */
1375
+ async #resolveIcon(): Promise<void> {
1376
+ const icon = this.#config.icon;
1377
+ if (!icon) return;
1378
+
1379
+ // Already a data URI — use as-is
1380
+ if (icon.startsWith('data:')) {
1381
+ this.#resolvedIcon = icon;
1382
+ return;
1383
+ }
1384
+
1385
+ // HTTP(S) URL — fetch and convert
1386
+ if (icon.startsWith('http://') || icon.startsWith('https://')) {
1387
+ try {
1388
+ const response = await fetch(icon);
1389
+ if (!response.ok) {
1390
+ console.warn(
1391
+ `[MCPWebBridge] Failed to fetch icon from ${icon}: HTTP ${response.status}`
1392
+ );
1393
+ return;
1394
+ }
1395
+
1396
+ const contentType =
1397
+ response.headers.get('content-type') || 'image/png';
1398
+ const buffer = await response.arrayBuffer();
1399
+ const base64 = Buffer.from(buffer).toString('base64');
1400
+ this.#resolvedIcon = `data:${contentType};base64,${base64}`;
1401
+ } catch (error) {
1402
+ console.warn(
1403
+ `[MCPWebBridge] Failed to fetch icon from ${icon}:`,
1404
+ error instanceof Error ? error.message : error
1405
+ );
1406
+ }
1407
+ return;
1408
+ }
1409
+
1410
+ // Unrecognized format — use as-is (could be a relative URL)
1411
+ this.#resolvedIcon = icon;
1412
+ }
1413
+
1414
+ /**
1415
+ * Returns the resolved icon data URI, waiting for resolution if needed.
1416
+ */
1417
+ async #getIcon(): Promise<string | undefined> {
1418
+ await this.#iconReady;
1419
+ return this.#resolvedIcon;
1420
+ }
1421
+
1422
+ /**
1423
+ * Handles MCP initialize request and creates a new MCP session.
1424
+ * Returns the initialize result along with a session ID for the Mcp-Session-Id header.
1425
+ */
1426
+ async #handleInitialize(authToken: string): Promise<{ result: unknown; sessionId: string }> {
1427
+ // Create a new MCP session
1428
+ const sessionId = crypto.randomUUID();
1429
+ const mcpSession: McpSession = {
1430
+ id: sessionId,
1431
+ authToken,
1432
+ createdAt: Date.now(),
1433
+ lastActivity: Date.now(),
1434
+ };
1435
+ this.#mcpSessions.set(sessionId, mcpSession);
1436
+
1437
+ const icon = await this.#getIcon();
1438
+ const result = {
1439
+ protocolVersion: '2024-11-05',
1440
+ capabilities: {
1441
+ tools: { listChanged: true },
1442
+ resources: {},
1443
+ prompts: {},
1444
+ },
1445
+ serverInfo: {
1446
+ name: this.#config.name,
1447
+ description: this.#config.description,
1448
+ version: this.#getVersion(),
1449
+ ...(icon && { icon }),
1450
+ },
1451
+ };
1452
+
1453
+ return { result, sessionId };
1454
+ }
1455
+
1456
+ #getSessionAndSessionId(
1457
+ sessions: Map<string, SessionData>,
1458
+ sessionId?: string
1459
+ ): [string, SessionData] | undefined {
1460
+ if (!sessionId) {
1461
+ if (sessions.size === 1) {
1462
+ sessionId = sessions.keys().next().value;
1463
+ if (!sessionId) {
1464
+ return undefined;
1465
+ }
1466
+ } else {
1467
+ return undefined;
1468
+ }
1469
+ }
1470
+
1471
+ const session = sessions.get(sessionId);
1472
+ if (!session) {
1473
+ return undefined;
1474
+ }
1475
+
1476
+ return [sessionId, session];
1477
+ }
1478
+
1479
+ #getSessionFromMetaParams(
1480
+ sessions: Map<string, SessionData>,
1481
+ params?: McpRequest['params']
1482
+ ): SessionData | undefined {
1483
+ const sessionId = params?._meta?.sessionId as string | undefined;
1484
+ return this.#getSessionAndSessionId(sessions, sessionId)?.[1];
1485
+ }
1486
+
1487
+ #createSessionNotFoundError(sessions: Map<string, SessionData>): {
1488
+ error: string;
1489
+ error_message?: string;
1490
+ available_sessions?: unknown;
1491
+ } {
1492
+ if (sessions.size > 1) {
1493
+ return {
1494
+ error: SessionNotSpecifiedErrorCode,
1495
+ error_message: SessionNotSpecifiedErrorDetails,
1496
+ available_sessions: this.#listSessions(sessions),
1497
+ };
1498
+ }
1499
+ return { error: SessionNotFoundErrorCode };
1500
+ }
1501
+
1502
+ async #handleToolsList(
1503
+ sessions: Map<string, SessionData>,
1504
+ params?: McpRequest['params']
1505
+ ): Promise<ListToolsResult | ErroredListToolsResult | FatalError> {
1506
+ const session = this.#getSessionFromMetaParams(sessions, params);
1507
+
1508
+ const listSessionsTool: Tool = {
1509
+ name: 'list_sessions',
1510
+ description: 'List all browser sessions with their available tools',
1511
+ inputSchema: {
1512
+ type: 'object',
1513
+ properties: {},
1514
+ required: [],
1515
+ },
1516
+ };
1517
+
1518
+ if (!session && sessions.size > 1) {
1519
+ // Multiple sessions: expose all tools (deduplicated) with session_id required
1520
+ const tools: Tool[] = [listSessionsTool];
1521
+ const seen = new Set<string>();
1522
+
1523
+ for (const s of sessions.values()) {
1524
+ for (const tool of s.tools.values()) {
1525
+ if (seen.has(tool.name)) continue;
1526
+ seen.add(tool.name);
1527
+ tools.push({
1528
+ name: tool.name,
1529
+ description: tool.description,
1530
+ inputSchema: {
1531
+ type: 'object',
1532
+ properties: {
1533
+ session_id: {
1534
+ type: 'string',
1535
+ description:
1536
+ 'Session ID (required) — use list_sessions to see available sessions',
1537
+ },
1538
+ ...(tool.inputSchema?.properties || {}),
1539
+ },
1540
+ required: [
1541
+ 'session_id',
1542
+ ...(tool.inputSchema?.required || []),
1543
+ ],
1544
+ },
1545
+ // Forward _meta (e.g., _meta.ui.resourceUri for MCP Apps)
1546
+ ...(tool._meta ? { _meta: tool._meta } : {}),
1547
+ });
1548
+ }
1549
+ }
1550
+
1551
+ return {
1552
+ tools,
1553
+ _meta: { available_sessions: this.#listSessions(sessions) },
1554
+ } satisfies ListToolsResult;
1555
+ }
1556
+
1557
+ if (!session) {
1558
+ return {
1559
+ error: SessionNotFoundErrorCode,
1560
+ error_message: 'No session found for the provided authentication',
1561
+ error_is_fatal: true,
1562
+ } satisfies FatalError;
1563
+ }
1564
+
1565
+ const tools: Tool[] = [listSessionsTool];
1566
+
1567
+ for (const tool of session.tools.values()) {
1568
+ const sessionAwareTool: Tool = {
1569
+ name: tool.name,
1570
+ description: tool.description,
1571
+ inputSchema: {
1572
+ type: 'object',
1573
+ properties: {
1574
+ session_id: {
1575
+ type: 'string',
1576
+ description:
1577
+ 'Session ID (optional - will auto-select if only one session active)',
1578
+ },
1579
+ ...(tool.inputSchema?.properties || {}),
1580
+ },
1581
+ required: tool.inputSchema?.required || [],
1582
+ },
1583
+ // Forward _meta (e.g., _meta.ui.resourceUri for MCP Apps)
1584
+ ...(tool._meta ? { _meta: tool._meta } : {}),
1585
+ };
1586
+ tools.push(sessionAwareTool);
1587
+ }
1588
+
1589
+ return { tools } satisfies ListToolsResult;
1590
+ }
1591
+
1592
+ async #handleToolCall(
1593
+ sessions: Map<string, SessionData>,
1594
+ params?: McpRequest['params']
1595
+ ): Promise<unknown> {
1596
+ const { name: toolName, arguments: toolInput, _meta } = params || {};
1597
+
1598
+ if (!toolName) {
1599
+ return { error: ToolNameRequiredErrorCode };
1600
+ }
1601
+
1602
+ const queryId = _meta?.queryId;
1603
+
1604
+ if (queryId) {
1605
+ const query = this.#queries.get(queryId);
1606
+ if (!query) {
1607
+ return { error: QueryNotFoundErrorCode };
1608
+ }
1609
+ if (query.state !== 'active') {
1610
+ return { error: QueryNotActiveErrorCode };
1611
+ }
1612
+
1613
+ if (query.restrictTools && query.tools) {
1614
+ const allowed = query.tools.some((t) => t.name === toolName);
1615
+ if (!allowed) {
1616
+ return {
1617
+ error: ToolNotAllowedErrorCode,
1618
+ details:
1619
+ 'The query restricts the allowed tool calls. Use one of `allowed_tools`.',
1620
+ allowed_tools: query.tools.map((t) => t.name),
1621
+ };
1622
+ }
1623
+ }
1624
+ }
1625
+
1626
+ if (toolName === 'list_sessions') {
1627
+ return { sessions: this.#listSessions(sessions) };
1628
+ }
1629
+
1630
+ const [sessionId, session] =
1631
+ this.#getSessionAndSessionId(
1632
+ sessions,
1633
+ (toolInput?.session_id as string | undefined) || _meta?.sessionId
1634
+ ) || [];
1635
+
1636
+ if (!sessionId || !session) {
1637
+ return this.#createSessionNotFoundError(sessions);
1638
+ }
1639
+
1640
+ if (!session.tools.has(toolName)) {
1641
+ return {
1642
+ error: ToolNotFoundErrorCode,
1643
+ available_tools: Array.from(session.tools.keys()),
1644
+ };
1645
+ }
1646
+
1647
+ // Strip session_id from tool input before forwarding — it's a routing
1648
+ // parameter injected by the bridge, not an actual tool argument.
1649
+ const { session_id: _, ...forwardedInput } = toolInput || {};
1650
+
1651
+ return this.#forwardToolCallToSession(sessionId, toolName, forwardedInput, queryId);
1652
+ }
1653
+
1654
+ async #handleResourcesList(
1655
+ sessions: Map<string, SessionData>,
1656
+ params?: McpRequest['params']
1657
+ ): Promise<ListResourcesResult | ErroredListResourcesResult | FatalError> {
1658
+ const session = this.#getSessionFromMetaParams(sessions, params);
1659
+
1660
+ const sessionListResource: Resource = {
1661
+ uri: 'sessions://list',
1662
+ name: 'sessions',
1663
+ description:
1664
+ 'List of all active browser sessions for this authentication context',
1665
+ mimeType: 'application/json',
1666
+ };
1667
+
1668
+ if (!session && sessions.size > 1) {
1669
+ return {
1670
+ resources: [sessionListResource],
1671
+ isError: true,
1672
+ error: SessionNotSpecifiedErrorCode,
1673
+ error_message: SessionNotSpecifiedErrorDetails,
1674
+ error_is_fatal: false,
1675
+ available_sessions: this.#listSessions(sessions),
1676
+ } satisfies ErroredListResourcesResult;
1677
+ }
1678
+
1679
+ if (!session) {
1680
+ return {
1681
+ error: SessionNotFoundErrorCode,
1682
+ error_message: 'No session found for the provided authentication',
1683
+ error_is_fatal: true,
1684
+ } satisfies FatalError;
1685
+ }
1686
+
1687
+ const resources: Resource[] = [sessionListResource];
1688
+
1689
+ // Add frontend-registered resources
1690
+ for (const resource of session.resources.values()) {
1691
+ resources.push({
1692
+ uri: resource.uri,
1693
+ name: resource.name,
1694
+ description: resource.description,
1695
+ mimeType: resource.mimeType ?? 'text/html',
1696
+ });
1697
+ }
1698
+
1699
+ return { resources } satisfies ListResourcesResult;
1700
+ }
1701
+
1702
+ async #handleResourceRead(
1703
+ sessions: Map<string, SessionData>,
1704
+ params?: McpRequest['params']
1705
+ ): Promise<unknown> {
1706
+ const { uri, _meta } = (params as { uri?: string; _meta?: { sessionId?: string } }) || {};
1707
+
1708
+ if (!uri) {
1709
+ return { error: 'Resource URI is required' };
1710
+ }
1711
+
1712
+ if (uri === 'sessions://list') {
1713
+ const sessionData = this.#listSessions(sessions);
1714
+ return {
1715
+ contents: [
1716
+ {
1717
+ uri: 'sessions://list',
1718
+ mimeType: 'application/json',
1719
+ text: JSON.stringify(sessionData, null, 2),
1720
+ },
1721
+ ],
1722
+ };
1723
+ }
1724
+
1725
+ // Look for frontend-registered resource
1726
+ const [sessionId, session] = this.#getSessionAndSessionId(sessions, _meta?.sessionId) || [];
1727
+
1728
+ if (!sessionId || !session) {
1729
+ // If no session specified and multiple sessions, check all sessions for the resource
1730
+ for (const [sid, sess] of sessions.entries()) {
1731
+ if (sess.resources.has(uri)) {
1732
+ return this.#forwardResourceReadToSession(sid, uri);
1733
+ }
1734
+ }
1735
+ return { error: 'Resource not found' };
1736
+ }
1737
+
1738
+ if (!session.resources.has(uri)) {
1739
+ return { error: 'Resource not found' };
1740
+ }
1741
+
1742
+ return this.#forwardResourceReadToSession(sessionId, uri);
1743
+ }
1744
+
1745
+ #listSessions(sessions: Map<string, SessionData>): AvailableSession[] {
1746
+ return Array.from(sessions.entries()).map(([key, session]) => ({
1747
+ session_id: key,
1748
+ session_name: session.sessionName,
1749
+ origin: session.origin,
1750
+ page_title: session.pageTitle,
1751
+ connected_at: new Date(session.connectedAt).toISOString(),
1752
+ last_activity: new Date(session.lastActivity).toISOString(),
1753
+ available_tools: Array.from(session.tools.keys()),
1754
+ }));
1755
+ }
1756
+
1757
+ async #handlePromptsList(
1758
+ sessions: Map<string, SessionData>,
1759
+ params?: McpRequest['params']
1760
+ ): Promise<ListPromptsResult | ErroredListPromptsResult | FatalError> {
1761
+ const session = this.#getSessionFromMetaParams(sessions, params);
1762
+
1763
+ if (!session && sessions.size > 1) {
1764
+ return {
1765
+ prompts: [],
1766
+ isError: true,
1767
+ error: SessionNotSpecifiedErrorCode,
1768
+ error_message: SessionNotSpecifiedErrorDetails,
1769
+ error_is_fatal: false,
1770
+ available_sessions: this.#listSessions(sessions),
1771
+ } satisfies ErroredListPromptsResult;
1772
+ }
1773
+
1774
+ if (!session) {
1775
+ return {
1776
+ error: SessionNotFoundErrorCode,
1777
+ error_message: 'No session found for the provided authentication',
1778
+ error_is_fatal: true,
1779
+ } satisfies FatalError;
1780
+ }
1781
+
1782
+ return { prompts: [] } satisfies ListPromptsResult;
1783
+ }
1784
+
1785
+ async #forwardToolCallToSession(
1786
+ sessionId: string,
1787
+ toolName: string,
1788
+ toolInput?: Record<string, unknown>,
1789
+ queryId?: string
1790
+ ): Promise<unknown> {
1791
+ const session = this.#sessions.get(sessionId);
1792
+ if (!session || session.ws.readyState !== 'OPEN') {
1793
+ return { error: 'Session not available' };
1794
+ }
1795
+
1796
+ const requestId = crypto.randomUUID();
1797
+
1798
+ const toolCall: ToolCallMessage = {
1799
+ type: 'tool-call',
1800
+ requestId,
1801
+ toolName,
1802
+ toolInput,
1803
+ ...(queryId && { queryId }),
1804
+ };
1805
+
1806
+ return new Promise((resolve) => {
1807
+ let timeoutId: string | undefined;
1808
+
1809
+ const handleResponse = (data: string): void => {
1810
+ try {
1811
+ const message: ToolResponseMessage = JSON.parse(data);
1812
+ if (
1813
+ message.type === 'tool-response' &&
1814
+ message.requestId === requestId
1815
+ ) {
1816
+ if (timeoutId) {
1817
+ this.#scheduler.cancel(timeoutId);
1818
+ }
1819
+ this.#toolResponseHandlers.delete(requestId);
1820
+ session.ws.offMessage(handleResponse);
1821
+
1822
+ const toolResult = message.result;
1823
+
1824
+ if (queryId) {
1825
+ const query = this.#queries.get(queryId);
1826
+ if (query) {
1827
+ query.toolCalls.push({
1828
+ tool: toolName,
1829
+ arguments: toolInput,
1830
+ result: toolResult,
1831
+ });
1832
+
1833
+ if (query.responseTool === toolName) {
1834
+ if (
1835
+ !(
1836
+ toolResult &&
1837
+ typeof toolResult === 'object' &&
1838
+ 'error' in toolResult
1839
+ )
1840
+ ) {
1841
+ const bridgeMessage = QueryCompleteBridgeMessageSchema.parse({
1842
+ uuid: queryId,
1843
+ message: undefined,
1844
+ toolCalls: query.toolCalls,
1845
+ });
1846
+
1847
+ if (query.ws.readyState === 'OPEN') {
1848
+ query.ws.send(JSON.stringify(bridgeMessage));
1849
+ }
1850
+
1851
+ this.#queries.delete(queryId);
1852
+ }
1853
+ }
1854
+ }
1855
+ }
1856
+
1857
+ resolve(toolResult);
1858
+ }
1859
+ } catch {
1860
+ // Ignore invalid JSON
1861
+ }
1862
+ };
1863
+
1864
+ // Set up timeout
1865
+ timeoutId = this.#scheduler.schedule(() => {
1866
+ this.#toolResponseHandlers.delete(requestId);
1867
+ session.ws.offMessage(handleResponse);
1868
+ resolve({ error: 'Tool call timeout' });
1869
+ }, 30000);
1870
+
1871
+ this.#toolResponseHandlers.set(requestId, handleResponse);
1872
+ session.ws.onMessage(handleResponse);
1873
+ session.ws.send(JSON.stringify(toolCall));
1874
+ });
1875
+ }
1876
+
1877
+ async #forwardResourceReadToSession(
1878
+ sessionId: string,
1879
+ uri: string
1880
+ ): Promise<unknown> {
1881
+ const session = this.#sessions.get(sessionId);
1882
+ if (!session || session.ws.readyState !== 'OPEN') {
1883
+ return { error: 'Session not available' };
1884
+ }
1885
+
1886
+ const requestId = crypto.randomUUID();
1887
+
1888
+ const resourceRead: ResourceReadMessage = {
1889
+ type: 'resource-read',
1890
+ requestId,
1891
+ uri,
1892
+ };
1893
+
1894
+ return new Promise((resolve) => {
1895
+ let timeoutId: string | undefined;
1896
+
1897
+ const handleResponse = (data: string): void => {
1898
+ try {
1899
+ const message: ResourceResponseMessage = JSON.parse(data);
1900
+ if (
1901
+ message.type === 'resource-response' &&
1902
+ message.requestId === requestId
1903
+ ) {
1904
+ if (timeoutId) {
1905
+ this.#scheduler.cancel(timeoutId);
1906
+ }
1907
+ this.#resourceResponseHandlers.delete(requestId);
1908
+ session.ws.offMessage(handleResponse);
1909
+
1910
+ if (message.error) {
1911
+ resolve({ error: message.error });
1912
+ return;
1913
+ }
1914
+
1915
+ // Build MCP resource read response
1916
+ if (message.blob) {
1917
+ // Binary content (base64 encoded)
1918
+ resolve({
1919
+ contents: [
1920
+ {
1921
+ uri,
1922
+ mimeType: message.mimeType,
1923
+ blob: message.blob,
1924
+ },
1925
+ ],
1926
+ });
1927
+ } else {
1928
+ // Text content
1929
+ resolve({
1930
+ contents: [
1931
+ {
1932
+ uri,
1933
+ mimeType: message.mimeType,
1934
+ text: message.content,
1935
+ },
1936
+ ],
1937
+ });
1938
+ }
1939
+ }
1940
+ } catch {
1941
+ // Ignore invalid JSON
1942
+ }
1943
+ };
1944
+
1945
+ // Set up timeout
1946
+ timeoutId = this.#scheduler.schedule(() => {
1947
+ this.#resourceResponseHandlers.delete(requestId);
1948
+ session.ws.offMessage(handleResponse);
1949
+ resolve({ error: 'Resource read timeout' });
1950
+ }, 30000);
1951
+
1952
+ this.#resourceResponseHandlers.set(requestId, handleResponse);
1953
+ session.ws.onMessage(handleResponse);
1954
+ session.ws.send(JSON.stringify(resourceRead));
1955
+ });
1956
+ }
1957
+
1958
+ // ============================================
1959
+ // Session & Query Limit Helpers
1960
+ // ============================================
1961
+
1962
+ #decrementQueryCount(authToken: string): void {
1963
+ const count = this.#tokenQueryCounts.get(authToken) ?? 0;
1964
+ if (count <= 1) {
1965
+ this.#tokenQueryCounts.delete(authToken);
1966
+ } else {
1967
+ this.#tokenQueryCounts.set(authToken, count - 1);
1968
+ }
1969
+ }
1970
+
1971
+ #decrementQueryCountForQuery(query: QueryTracking): void {
1972
+ const session = this.#sessions.get(query.sessionId);
1973
+ if (session) {
1974
+ this.#decrementQueryCount(session.authToken);
1975
+ }
1976
+ }
1977
+
1978
+ #closeOldestSessionForToken(authToken: string): void {
1979
+ const sessionIds = this.#tokenSessionIds.get(authToken);
1980
+ if (!sessionIds || sessionIds.size === 0) return;
1981
+
1982
+ let oldest: { sessionId: string; connectedAt: number } | null = null;
1983
+
1984
+ for (const sessionId of sessionIds) {
1985
+ const session = this.#sessions.get(sessionId);
1986
+ if (session && (!oldest || session.connectedAt < oldest.connectedAt)) {
1987
+ oldest = { sessionId, connectedAt: session.connectedAt };
1988
+ }
1989
+ }
1990
+
1991
+ if (oldest) {
1992
+ const session = this.#sessions.get(oldest.sessionId);
1993
+ if (session) {
1994
+ session.ws.send(
1995
+ JSON.stringify({
1996
+ type: 'session-closed',
1997
+ reason: 'Session limit exceeded, closing oldest session',
1998
+ code: SessionLimitExceededErrorCode,
1999
+ })
2000
+ );
2001
+ session.ws.close(1008, 'Session limit exceeded');
2002
+ }
2003
+ }
2004
+ }
2005
+
2006
+ #cleanupSession(sessionId: string): void {
2007
+ const session = this.#sessions.get(sessionId);
2008
+ if (session) {
2009
+ const sessionIds = this.#tokenSessionIds.get(session.authToken);
2010
+ if (sessionIds) {
2011
+ sessionIds.delete(sessionId);
2012
+ if (sessionIds.size === 0) {
2013
+ this.#tokenSessionIds.delete(session.authToken);
2014
+ }
2015
+ }
2016
+
2017
+ // Notify connected MCP clients about tool changes (tools removed)
2018
+ this.#notifyToolsChanged(session.authToken);
2019
+ }
2020
+ this.#sessions.delete(sessionId);
2021
+ }
2022
+
2023
+ #startSessionTimeoutChecker(): void {
2024
+ const maxDuration = this.#config.sessionMaxDurationMs;
2025
+ if (!maxDuration) return;
2026
+
2027
+ this.#sessionTimeoutIntervalId = this.#scheduler.scheduleInterval(() => {
2028
+ const now = Date.now();
2029
+ for (const [_sessionId, session] of this.#sessions) {
2030
+ if (now - session.connectedAt > maxDuration) {
2031
+ session.ws.send(
2032
+ JSON.stringify({
2033
+ type: 'session-expired',
2034
+ code: SessionExpiredErrorCode,
2035
+ })
2036
+ );
2037
+ session.ws.close(1008, 'Session expired');
2038
+ }
2039
+ }
2040
+ }, 60000);
2041
+ }
2042
+
2043
+ // ============================================
2044
+ // Remote MCP (Streamable HTTP) Support
2045
+ // ============================================
2046
+
2047
+ /**
2048
+ * Starts periodic checker to clean up idle MCP sessions.
2049
+ * Sessions are removed after MCP_SESSION_IDLE_TIMEOUT_MS of inactivity.
2050
+ */
2051
+ #startMcpSessionTimeoutChecker(): void {
2052
+ // Check every minute
2053
+ this.#mcpSessionTimeoutIntervalId = this.#scheduler.scheduleInterval(() => {
2054
+ const now = Date.now();
2055
+ for (const [sessionId, mcpSession] of this.#mcpSessions) {
2056
+ const idleTime = now - mcpSession.lastActivity;
2057
+ if (idleTime > MCPWebBridge.MCP_SESSION_IDLE_TIMEOUT_MS) {
2058
+ // Clean up SSE stream if open
2059
+ if (mcpSession.sseCleanup) {
2060
+ mcpSession.sseCleanup();
2061
+ }
2062
+ this.#mcpSessions.delete(sessionId);
2063
+ console.log(`MCP session ${sessionId} expired after ${idleTime}ms of inactivity`);
2064
+ }
2065
+ }
2066
+ }, 60000);
2067
+ }
2068
+
2069
+ /**
2070
+ * Notifies all connected MCP clients (Claude Desktop) about tool changes.
2071
+ * Sends `notifications/tools/list_changed` via SSE to clients with matching auth token.
2072
+ */
2073
+ #notifyToolsChanged(authToken: string): void {
2074
+ for (const mcpSession of this.#mcpSessions.values()) {
2075
+ // Only notify sessions with matching auth token
2076
+ if (mcpSession.authToken === authToken && mcpSession.sseWriter) {
2077
+ const notification = {
2078
+ jsonrpc: '2.0',
2079
+ method: 'notifications/tools/list_changed',
2080
+ };
2081
+ mcpSession.sseWriter(JSON.stringify(notification));
2082
+ }
2083
+ }
2084
+ }
2085
+
2086
+ /**
2087
+ * Handles GET requests for SSE stream (Remote MCP server-initiated messages).
2088
+ * Claude Desktop opens this stream to receive notifications like tools/list_changed.
2089
+ */
2090
+ #handleSSEStream(req: HttpRequest): SSEResponse {
2091
+ const mcpSessionId = req.headers.get('mcp-session-id');
2092
+
2093
+ if (this.#config.debug) {
2094
+ console.log(`[MCP Debug] SSE stream request, session: ${mcpSessionId || '[NONE]'}`);
2095
+ }
2096
+
2097
+ if (!mcpSessionId) {
2098
+ if (this.#config.debug) {
2099
+ console.log(`[MCP Debug] SSE Error: Missing Mcp-Session-Id header`);
2100
+ }
2101
+ // Return a regular response indicating error - we need Mcp-Session-Id
2102
+ return sseResponse((writer, _onClose) => {
2103
+ writer(
2104
+ JSON.stringify({
2105
+ jsonrpc: '2.0',
2106
+ error: {
2107
+ code: -32600,
2108
+ message: 'Mcp-Session-Id header required for SSE stream',
2109
+ },
2110
+ })
2111
+ );
2112
+ });
2113
+ }
2114
+
2115
+ const mcpSession = this.#mcpSessions.get(mcpSessionId);
2116
+ if (!mcpSession) {
2117
+ if (this.#config.debug) {
2118
+ console.log(`[MCP Debug] SSE Error: MCP session not found`);
2119
+ }
2120
+ return sseResponse((writer, _onClose) => {
2121
+ writer(
2122
+ JSON.stringify({
2123
+ jsonrpc: '2.0',
2124
+ error: {
2125
+ code: -32600,
2126
+ message: 'MCP session not found',
2127
+ },
2128
+ })
2129
+ );
2130
+ });
2131
+ }
2132
+
2133
+ if (this.#config.debug) {
2134
+ console.log(`[MCP Debug] SSE stream opened successfully`);
2135
+ }
2136
+
2137
+ return sseResponse((writer, onClose) => {
2138
+ // Store the writer so we can push notifications later
2139
+ mcpSession.sseWriter = writer;
2140
+ mcpSession.lastActivity = Date.now();
2141
+
2142
+ // Set up cleanup for when client disconnects
2143
+ const cleanup = () => {
2144
+ if (this.#config.debug) {
2145
+ console.log(`[MCP Debug] SSE stream closed for session ${mcpSessionId}`);
2146
+ }
2147
+ mcpSession.sseWriter = undefined;
2148
+ mcpSession.sseCleanup = undefined;
2149
+ };
2150
+ mcpSession.sseCleanup = cleanup;
2151
+
2152
+ // Register the onClose callback
2153
+ // Note: The adapter will call onClose when the client disconnects
2154
+ // We store our cleanup function so we can also call it manually
2155
+ });
2156
+ }
2157
+
2158
+ /**
2159
+ * Handles MCP session deletion (client explicitly closing session).
2160
+ */
2161
+ #handleMcpSessionDelete(sessionId: string): HttpResponse {
2162
+ const mcpSession = this.#mcpSessions.get(sessionId);
2163
+
2164
+ if (!mcpSession) {
2165
+ return jsonResponse(404, { error: 'MCP session not found' });
2166
+ }
2167
+
2168
+ // Clean up SSE stream if open
2169
+ if (mcpSession.sseCleanup) {
2170
+ mcpSession.sseCleanup();
2171
+ }
2172
+
2173
+ this.#mcpSessions.delete(sessionId);
2174
+ return jsonResponse(200, { success: true });
2175
+ }
2176
+ }