@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.
- package/LICENSE +201 -0
- package/README.md +311 -0
- package/dist/adapters/bun.d.ts +95 -0
- package/dist/adapters/bun.d.ts.map +1 -0
- package/dist/adapters/bun.js +286 -0
- package/dist/adapters/bun.js.map +1 -0
- package/dist/adapters/deno.d.ts +89 -0
- package/dist/adapters/deno.d.ts.map +1 -0
- package/dist/adapters/deno.js +249 -0
- package/dist/adapters/deno.js.map +1 -0
- package/dist/adapters/index.d.ts +21 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +21 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/node.d.ts +112 -0
- package/dist/adapters/node.d.ts.map +1 -0
- package/dist/adapters/node.js +309 -0
- package/dist/adapters/node.js.map +1 -0
- package/dist/adapters/partykit.d.ts +153 -0
- package/dist/adapters/partykit.d.ts.map +1 -0
- package/dist/adapters/partykit.js +372 -0
- package/dist/adapters/partykit.js.map +1 -0
- package/dist/bridge.d.ts +38 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +1004 -0
- package/dist/bridge.js.map +1 -0
- package/dist/core.d.ts +75 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +1508 -0
- package/dist/core.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/index.d.ts +11 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +9 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/scheduler.d.ts +69 -0
- package/dist/runtime/scheduler.d.ts.map +1 -0
- package/dist/runtime/scheduler.js +88 -0
- package/dist/runtime/scheduler.js.map +1 -0
- package/dist/runtime/types.d.ts +144 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/runtime/types.js +82 -0
- package/dist/runtime/types.js.map +1 -0
- package/dist/schemas.d.ts +6 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +6 -0
- package/dist/schemas.js.map +1 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +28 -0
- package/src/adapters/bun.ts +354 -0
- package/src/adapters/deno.ts +282 -0
- package/src/adapters/index.ts +28 -0
- package/src/adapters/node.ts +385 -0
- package/src/adapters/partykit.ts +482 -0
- package/src/bridge.test.ts +64 -0
- package/src/core.ts +2176 -0
- package/src/index.ts +90 -0
- package/src/limits.test.ts +436 -0
- package/src/remote-mcp.test.ts +770 -0
- package/src/runtime/index.ts +24 -0
- package/src/runtime/scheduler.ts +130 -0
- package/src/runtime/types.ts +229 -0
- package/src/schemas.ts +6 -0
- package/src/session-naming.test.ts +443 -0
- package/src/types.ts +180 -0
- 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
|
+
}
|