@mcp-ts/sdk 1.3.6 → 1.3.9

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 (103) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +398 -404
  3. package/dist/adapters/agui-adapter.d.mts +1 -1
  4. package/dist/adapters/agui-adapter.d.ts +1 -1
  5. package/dist/adapters/agui-adapter.js +2 -2
  6. package/dist/adapters/agui-adapter.js.map +1 -1
  7. package/dist/adapters/agui-adapter.mjs +2 -2
  8. package/dist/adapters/agui-adapter.mjs.map +1 -1
  9. package/dist/adapters/agui-middleware.d.mts +1 -1
  10. package/dist/adapters/agui-middleware.d.ts +1 -1
  11. package/dist/adapters/agui-middleware.js.map +1 -1
  12. package/dist/adapters/agui-middleware.mjs.map +1 -1
  13. package/dist/adapters/ai-adapter.d.mts +1 -1
  14. package/dist/adapters/ai-adapter.d.ts +1 -1
  15. package/dist/adapters/ai-adapter.js +1 -1
  16. package/dist/adapters/ai-adapter.js.map +1 -1
  17. package/dist/adapters/ai-adapter.mjs +1 -1
  18. package/dist/adapters/ai-adapter.mjs.map +1 -1
  19. package/dist/adapters/langchain-adapter.d.mts +1 -1
  20. package/dist/adapters/langchain-adapter.d.ts +1 -1
  21. package/dist/adapters/langchain-adapter.js +1 -1
  22. package/dist/adapters/langchain-adapter.js.map +1 -1
  23. package/dist/adapters/langchain-adapter.mjs +1 -1
  24. package/dist/adapters/langchain-adapter.mjs.map +1 -1
  25. package/dist/adapters/mastra-adapter.d.mts +1 -1
  26. package/dist/adapters/mastra-adapter.d.ts +1 -1
  27. package/dist/adapters/mastra-adapter.js +1 -1
  28. package/dist/adapters/mastra-adapter.js.map +1 -1
  29. package/dist/adapters/mastra-adapter.mjs +1 -1
  30. package/dist/adapters/mastra-adapter.mjs.map +1 -1
  31. package/dist/bin/mcp-ts.js +0 -0
  32. package/dist/bin/mcp-ts.js.map +1 -1
  33. package/dist/bin/mcp-ts.mjs +0 -0
  34. package/dist/bin/mcp-ts.mjs.map +1 -1
  35. package/dist/client/index.js.map +1 -1
  36. package/dist/client/index.mjs.map +1 -1
  37. package/dist/client/react.d.mts +2 -2
  38. package/dist/client/react.d.ts +2 -2
  39. package/dist/client/react.js +25 -2
  40. package/dist/client/react.js.map +1 -1
  41. package/dist/client/react.mjs +26 -3
  42. package/dist/client/react.mjs.map +1 -1
  43. package/dist/client/vue.js.map +1 -1
  44. package/dist/client/vue.mjs.map +1 -1
  45. package/dist/index.d.mts +1 -1
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.js +134 -71
  48. package/dist/index.js.map +1 -1
  49. package/dist/index.mjs +134 -71
  50. package/dist/index.mjs.map +1 -1
  51. package/dist/{multi-session-client-BYLarghq.d.ts → multi-session-client-CHE8QpVE.d.ts} +75 -5
  52. package/dist/{multi-session-client-CzhMkE0k.d.mts → multi-session-client-CQsRbxYI.d.mts} +75 -5
  53. package/dist/server/index.d.mts +1 -1
  54. package/dist/server/index.d.ts +1 -1
  55. package/dist/server/index.js +134 -71
  56. package/dist/server/index.js.map +1 -1
  57. package/dist/server/index.mjs +134 -71
  58. package/dist/server/index.mjs.map +1 -1
  59. package/dist/shared/index.js +10 -2
  60. package/dist/shared/index.js.map +1 -1
  61. package/dist/shared/index.mjs +10 -2
  62. package/dist/shared/index.mjs.map +1 -1
  63. package/package.json +185 -185
  64. package/src/adapters/agui-adapter.ts +222 -222
  65. package/src/adapters/agui-middleware.ts +382 -382
  66. package/src/adapters/ai-adapter.ts +115 -115
  67. package/src/adapters/langchain-adapter.ts +127 -127
  68. package/src/adapters/mastra-adapter.ts +126 -126
  69. package/src/bin/mcp-ts.ts +102 -102
  70. package/src/client/core/app-host.ts +417 -417
  71. package/src/client/core/sse-client.ts +371 -371
  72. package/src/client/core/types.ts +31 -31
  73. package/src/client/index.ts +27 -27
  74. package/src/client/react/index.ts +16 -16
  75. package/src/client/react/use-app-host.ts +73 -73
  76. package/src/client/react/use-mcp-apps.tsx +247 -214
  77. package/src/client/react/use-mcp.ts +641 -641
  78. package/src/client/vue/index.ts +10 -10
  79. package/src/client/vue/use-mcp.ts +617 -617
  80. package/src/index.ts +11 -11
  81. package/src/server/handlers/nextjs-handler.ts +204 -204
  82. package/src/server/handlers/sse-handler.ts +631 -631
  83. package/src/server/index.ts +57 -57
  84. package/src/server/mcp/multi-session-client.ts +228 -132
  85. package/src/server/mcp/oauth-client.ts +1188 -1188
  86. package/src/server/mcp/storage-oauth-provider.ts +272 -272
  87. package/src/server/storage/file-backend.ts +157 -170
  88. package/src/server/storage/index.ts +176 -175
  89. package/src/server/storage/memory-backend.ts +123 -136
  90. package/src/server/storage/redis-backend.ts +276 -289
  91. package/src/server/storage/redis.ts +160 -160
  92. package/src/server/storage/sqlite-backend.ts +182 -186
  93. package/src/server/storage/supabase-backend.ts +228 -227
  94. package/src/server/storage/types.ts +116 -116
  95. package/src/shared/constants.ts +29 -29
  96. package/src/shared/errors.ts +133 -133
  97. package/src/shared/event-routing.ts +28 -28
  98. package/src/shared/events.ts +180 -180
  99. package/src/shared/index.ts +75 -75
  100. package/src/shared/tool-utils.ts +61 -61
  101. package/src/shared/types.ts +282 -282
  102. package/src/shared/utils.ts +38 -16
  103. package/supabase/migrations/20260330195700_install_mcp_sessions.sql +84 -84
@@ -1,631 +1,631 @@
1
- /**
2
- * SSE (Server-Sent Events) Handler for MCP Connections
3
- *
4
- * Manages real-time bidirectional communication with MCP clients:
5
- * - SSE stream for server → client events (connection state, tools, logs)
6
- * - HTTP POST for client → server RPC requests
7
- *
8
- * Key features:
9
- * - Direct HTTP response for RPC calls (bypasses SSE latency)
10
- * - Automatic session restoration and validation
11
- * - OAuth 2.1 authentication flow support
12
- * - Heartbeat to keep connections alive
13
- */
14
-
15
- import type { McpConnectionEvent, McpObservabilityEvent } from '../../shared/events.js';
16
- import type {
17
- McpRpcRequest,
18
- McpRpcResponse,
19
- ConnectParams,
20
- DisconnectParams,
21
- SessionParams,
22
- CallToolParams,
23
- GetPromptParams,
24
- ReadResourceParams,
25
- FinishAuthParams,
26
- SessionListResult,
27
- ConnectResult,
28
- DisconnectResult,
29
- RestoreSessionResult,
30
- FinishAuthResult,
31
- ListToolsRpcResult,
32
- ListPromptsResult,
33
- ListResourcesResult,
34
- CallToolResult,
35
- } from '../../shared/types.js';
36
- import { RpcErrorCodes } from '../../shared/errors.js';
37
- import { UnauthorizedError } from '../../shared/errors.js';
38
- import { isConnectionEvent, isRpcResponseEvent } from '../../shared/event-routing.js';
39
- import { MCPClient } from '../mcp/oauth-client.js';
40
- import { storage } from '../storage/index.js';
41
-
42
- // ============================================
43
- // Types & Interfaces
44
- // ============================================
45
-
46
- export interface ClientMetadata {
47
- clientName?: string;
48
- clientUri?: string;
49
- logoUri?: string;
50
- policyUri?: string;
51
- }
52
-
53
- export interface SSEHandlerOptions {
54
- /** User/Client identifier */
55
- identity: string;
56
-
57
- /** Optional callback for authentication/authorization */
58
- onAuth?: (identity: string) => Promise<boolean>;
59
-
60
- /** Heartbeat interval in milliseconds @default 30000 */
61
- heartbeatInterval?: number;
62
-
63
- /** Static OAuth client metadata defaults (for all connections) */
64
- clientDefaults?: ClientMetadata;
65
-
66
- /** Dynamic OAuth client metadata getter (per-request, useful for multi-tenant) */
67
- getClientMetadata?: (request?: unknown) => ClientMetadata | Promise<ClientMetadata>;
68
- }
69
-
70
- // ============================================
71
- // Constants
72
- // ============================================
73
-
74
- const DEFAULT_HEARTBEAT_INTERVAL = 30000;
75
-
76
- // ============================================
77
- // SSEConnectionManager Class
78
- // ============================================
79
-
80
- /**
81
- * Manages a single SSE connection and handles MCP operations.
82
- * Each instance corresponds to one connected browser client.
83
- */
84
- export class SSEConnectionManager {
85
- private readonly identity: string;
86
- private readonly clients = new Map<string, MCPClient>();
87
- private heartbeatTimer?: NodeJS.Timeout;
88
- private isActive = true;
89
-
90
- constructor(
91
- private readonly options: SSEHandlerOptions,
92
- private readonly sendEvent: (event: McpConnectionEvent | McpObservabilityEvent | McpRpcResponse) => void
93
- ) {
94
- this.identity = options.identity;
95
- this.startHeartbeat();
96
- }
97
-
98
- /**
99
- * Get resolved client metadata (dynamic > static > defaults)
100
- */
101
- private async getResolvedClientMetadata(request?: any): Promise<ClientMetadata> {
102
- // Priority: getClientMetadata() > clientDefaults > empty object
103
- let metadata: ClientMetadata = {};
104
-
105
- // Start with static defaults
106
- if (this.options.clientDefaults) {
107
- metadata = { ...this.options.clientDefaults };
108
- }
109
-
110
- // Override with dynamic metadata if provided
111
- if (this.options.getClientMetadata) {
112
- const dynamicMetadata = await this.options.getClientMetadata(request);
113
- metadata = { ...metadata, ...dynamicMetadata };
114
- }
115
-
116
- return metadata;
117
- }
118
-
119
- /**
120
- * Start heartbeat to keep connection alive
121
- */
122
- private startHeartbeat(): void {
123
- const interval = this.options.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL;
124
- this.heartbeatTimer = setInterval(() => {
125
- if (this.isActive) {
126
- this.sendEvent({
127
- level: 'debug',
128
- message: 'heartbeat',
129
- timestamp: Date.now(),
130
- } as McpObservabilityEvent);
131
- }
132
- }, interval);
133
- }
134
-
135
- /**
136
- * Handle incoming RPC requests
137
- * Returns the RPC response directly for immediate HTTP response (bypassing SSE latency)
138
- */
139
- async handleRequest(request: McpRpcRequest): Promise<McpRpcResponse> {
140
- try {
141
- let result: SessionListResult | ConnectResult | DisconnectResult | RestoreSessionResult | FinishAuthResult | ListToolsRpcResult | ListPromptsResult | ListResourcesResult | unknown;
142
-
143
- switch (request.method) {
144
- case 'getSessions':
145
- result = await this.getSessions();
146
- break;
147
-
148
- case 'connect':
149
- result = await this.connect(request.params as ConnectParams);
150
- break;
151
-
152
- case 'disconnect':
153
- result = await this.disconnect(request.params as DisconnectParams);
154
- break;
155
-
156
- case 'listTools':
157
- result = await this.listTools(request.params as SessionParams);
158
- break;
159
-
160
- case 'callTool':
161
- result = await this.callTool(request.params as CallToolParams);
162
- break;
163
-
164
- case 'restoreSession':
165
- result = await this.restoreSession(request.params as SessionParams);
166
- break;
167
-
168
- case 'finishAuth':
169
- result = await this.finishAuth(request.params as FinishAuthParams);
170
- break;
171
-
172
- case 'listPrompts':
173
- result = await this.listPrompts(request.params as SessionParams);
174
- break;
175
-
176
- case 'getPrompt':
177
- result = await this.getPrompt(request.params as GetPromptParams);
178
- break;
179
-
180
- case 'listResources':
181
- result = await this.listResources(request.params as SessionParams);
182
- break;
183
-
184
- case 'readResource':
185
- result = await this.readResource(request.params as ReadResourceParams);
186
- break;
187
-
188
- default:
189
- throw new Error(`Unknown method: ${request.method}`);
190
- }
191
-
192
- const response: McpRpcResponse = {
193
- id: request.id,
194
- result,
195
- };
196
-
197
- // Also send via SSE for backwards compatibility
198
- this.sendEvent(response);
199
-
200
- return response;
201
- } catch (error) {
202
- const errorResponse: McpRpcResponse = {
203
- id: request.id,
204
- error: {
205
- code: RpcErrorCodes.EXECUTION_ERROR,
206
- message: error instanceof Error ? error.message : 'Unknown error',
207
- },
208
- };
209
-
210
- // Also send via SSE for backwards compatibility
211
- this.sendEvent(errorResponse);
212
-
213
- return errorResponse;
214
- }
215
- }
216
-
217
- /**
218
- * Get all sessions for the current identity
219
- */
220
- private async getSessions(): Promise<SessionListResult> {
221
- const sessions = await storage.getIdentitySessionsData(this.identity);
222
-
223
- return {
224
- sessions: sessions.map((s) => ({
225
- sessionId: s.sessionId,
226
- serverId: s.serverId,
227
- serverName: s.serverName,
228
- serverUrl: s.serverUrl,
229
- transport: s.transportType,
230
- createdAt: s.createdAt,
231
- active: s.active !== false,
232
- })),
233
- };
234
- }
235
-
236
- /**
237
- * Connect to an MCP server
238
- */
239
- private async connect(params: ConnectParams): Promise<ConnectResult> {
240
- const { serverName, serverUrl, callbackUrl, transportType } = params;
241
-
242
- // Normalize serverId to max 12 chars to keep tool names under 64 chars (DeepSeek/OpenAI limits)
243
- // Tool name format: tool_<serverId>_<toolName> - with 12 char serverId leaves 46 chars for tool name
244
- const serverId = params.serverId && params.serverId.length <= 12
245
- ? params.serverId
246
- : await storage.generateSessionId();
247
-
248
- // Check for existing connections
249
- const existingSessions = await storage.getIdentitySessionsData(this.identity);
250
- const duplicate = existingSessions.find(s =>
251
- s.serverId === serverId || s.serverUrl === serverUrl
252
- );
253
-
254
- if (duplicate) {
255
- // If the existing session is still pending OAuth, treat connect as "resume auth"
256
- // instead of failing with duplicate connection error.
257
- if (duplicate.active === false) {
258
- await this.restoreSession({ sessionId: duplicate.sessionId });
259
- return {
260
- sessionId: duplicate.sessionId,
261
- success: true,
262
- };
263
- }
264
- throw new Error(`Connection already exists for server: ${duplicate.serverUrl || duplicate.serverId} (${duplicate.serverName})`);
265
- }
266
-
267
- // Generate session ID
268
- const sessionId = await storage.generateSessionId();
269
-
270
- try {
271
- // Get resolved client metadata
272
- const clientMetadata = await this.getResolvedClientMetadata();
273
-
274
- // Create MCP client
275
- const client = new MCPClient({
276
- identity: this.identity,
277
- sessionId,
278
- serverId,
279
- serverName,
280
- serverUrl,
281
- callbackUrl,
282
- transportType,
283
- ...clientMetadata, // Spread client metadata (clientName, clientUri, logoUri, policyUri)
284
- });
285
-
286
- // Note: Session will be created by MCPClient after successful connection
287
- // This ensures sessions only exist for successful or OAuth-pending connections
288
-
289
- // Store client
290
- this.clients.set(sessionId, client);
291
-
292
- // Subscribe to client events
293
- client.onConnectionEvent((event) => {
294
- this.emitConnectionEvent(event);
295
- });
296
-
297
- client.onObservabilityEvent((event) => {
298
- this.sendEvent(event);
299
- });
300
-
301
- // Attempt connection
302
- await client.connect();
303
-
304
- // Fetch tools
305
- await client.listTools();
306
-
307
- return {
308
- sessionId,
309
- success: true,
310
- };
311
- } catch (error) {
312
- if (error instanceof UnauthorizedError) {
313
- // OAuth-required is a pending-auth state, not a failed connection.
314
- this.clients.delete(sessionId);
315
- return {
316
- sessionId,
317
- success: true,
318
- };
319
- }
320
-
321
- this.emitConnectionEvent({
322
- type: 'error',
323
- sessionId,
324
- serverId,
325
- error: error instanceof Error ? error.message : 'Connection failed',
326
- errorType: 'connection',
327
- timestamp: Date.now(),
328
- });
329
-
330
- // Clean up client
331
- this.clients.delete(sessionId);
332
-
333
- throw error;
334
- }
335
- }
336
-
337
- /**
338
- * Disconnect from an MCP server
339
- */
340
- private async disconnect(params: DisconnectParams): Promise<DisconnectResult> {
341
- const { sessionId } = params;
342
- const client = this.clients.get(sessionId);
343
-
344
- if (client) {
345
- await client.clearSession();
346
- client.disconnect();
347
- this.clients.delete(sessionId);
348
- } else {
349
- // Handle orphaned sessions (e.g., OAuth flow failed before client was stored)
350
- // Directly remove from storage since there's no active client
351
- await storage.removeSession(this.identity, sessionId);
352
- }
353
-
354
- return { success: true };
355
- }
356
-
357
- /**
358
- * Get an existing client or create and connect a new one for the session.
359
- */
360
- private async getOrCreateClient(sessionId: string): Promise<MCPClient> {
361
- const existing = this.clients.get(sessionId);
362
- if (existing) {
363
- return existing;
364
- }
365
-
366
- const client = new MCPClient({
367
- identity: this.identity,
368
- sessionId,
369
- });
370
-
371
- // Subscribe to events before connecting
372
- client.onConnectionEvent((event) => this.emitConnectionEvent(event));
373
- client.onObservabilityEvent((event) => this.sendEvent(event));
374
-
375
- await client.connect();
376
- this.clients.set(sessionId, client);
377
-
378
- return client;
379
- }
380
-
381
- /**
382
- * List tools from a session
383
- */
384
- private async listTools(params: SessionParams): Promise<ListToolsRpcResult> {
385
- const { sessionId } = params;
386
- const client = await this.getOrCreateClient(sessionId);
387
- const result = await client.listTools();
388
- return { tools: result.tools };
389
- }
390
-
391
- /**
392
- * Call a tool on the MCP server
393
- */
394
- private async callTool(params: CallToolParams): Promise<CallToolResult> {
395
- const { sessionId, toolName, toolArgs } = params;
396
- const client = await this.getOrCreateClient(sessionId);
397
- const result = await client.callTool(toolName, toolArgs);
398
-
399
- // Inject sessionId into meta so client knows who handled it
400
- // This allows AppHost to auto-launch without scanning all sessions
401
- const meta = result._meta || {};
402
-
403
- return {
404
- ...result,
405
- _meta: {
406
- ...meta,
407
- sessionId,
408
- }
409
- };
410
- }
411
-
412
- /**
413
- * Restore and validate an existing session
414
- */
415
- private async restoreSession(params: SessionParams): Promise<RestoreSessionResult> {
416
- const { sessionId } = params;
417
-
418
- const session = await storage.getSession(this.identity, sessionId);
419
- if (!session) {
420
- throw new Error('Session not found');
421
- }
422
-
423
- this.emitConnectionEvent({
424
- type: 'state_changed',
425
- sessionId,
426
- serverId: session.serverId ?? 'unknown',
427
- serverName: session.serverName ?? 'Unknown',
428
- serverUrl: session.serverUrl,
429
- state: 'VALIDATING',
430
- previousState: 'DISCONNECTED',
431
- timestamp: Date.now(),
432
- });
433
-
434
- try {
435
- const clientMetadata = await this.getResolvedClientMetadata();
436
-
437
- const client = new MCPClient({
438
- identity: this.identity,
439
- sessionId,
440
- ...clientMetadata,
441
- });
442
-
443
- client.onConnectionEvent((event) => this.emitConnectionEvent(event));
444
- client.onObservabilityEvent((event) => this.sendEvent(event));
445
-
446
- await client.connect();
447
- this.clients.set(sessionId, client);
448
-
449
- const tools = await client.listTools();
450
-
451
- return { success: true, toolCount: tools.tools.length };
452
- } catch (error) {
453
- this.emitConnectionEvent({
454
- type: 'error',
455
- sessionId,
456
- serverId: session.serverId ?? 'unknown',
457
- error: error instanceof Error ? error.message : 'Validation failed',
458
- errorType: 'validation',
459
- timestamp: Date.now(),
460
- });
461
-
462
- throw error;
463
- }
464
- }
465
-
466
- /**
467
- * Complete OAuth authorization flow
468
- */
469
- private async finishAuth(params: FinishAuthParams): Promise<FinishAuthResult> {
470
- const { sessionId, code } = params;
471
-
472
- const session = await storage.getSession(this.identity, sessionId);
473
- if (!session) {
474
- throw new Error('Session not found');
475
- }
476
-
477
- try {
478
- const client = new MCPClient({
479
- identity: this.identity,
480
- sessionId,
481
- });
482
-
483
- client.onConnectionEvent((event) => this.emitConnectionEvent(event));
484
-
485
- await client.finishAuth(code);
486
- this.clients.set(sessionId, client);
487
-
488
- const tools = await client.listTools();
489
-
490
- return { success: true, toolCount: tools.tools.length };
491
- } catch (error) {
492
- this.emitConnectionEvent({
493
- type: 'error',
494
- sessionId,
495
- serverId: session.serverId ?? 'unknown',
496
- error: error instanceof Error ? error.message : 'OAuth completion failed',
497
- errorType: 'auth',
498
- timestamp: Date.now(),
499
- });
500
-
501
- throw error;
502
- }
503
- }
504
-
505
- /**
506
- * List prompts from a session
507
- */
508
- private async listPrompts(params: SessionParams): Promise<ListPromptsResult> {
509
- const { sessionId } = params;
510
- const client = await this.getOrCreateClient(sessionId);
511
- const result = await client.listPrompts();
512
- return { prompts: result.prompts };
513
- }
514
-
515
- /**
516
- * Get a specific prompt
517
- */
518
- private async getPrompt(params: GetPromptParams): Promise<unknown> {
519
- const { sessionId, name, args } = params;
520
- const client = await this.getOrCreateClient(sessionId);
521
- return await client.getPrompt(name, args);
522
- }
523
-
524
- /**
525
- * List resources from a session
526
- */
527
- private async listResources(params: SessionParams): Promise<ListResourcesResult> {
528
- const { sessionId } = params;
529
- const client = await this.getOrCreateClient(sessionId);
530
- const result = await client.listResources();
531
- return { resources: result.resources };
532
- }
533
-
534
- /**
535
- * Read a specific resource
536
- */
537
- private async readResource(params: ReadResourceParams): Promise<unknown> {
538
- const { sessionId, uri } = params;
539
- const client = await this.getOrCreateClient(sessionId);
540
- return client.readResource(uri);
541
- }
542
-
543
- /**
544
- * Emit connection event
545
- */
546
- private emitConnectionEvent(event: McpConnectionEvent): void {
547
- this.sendEvent(event);
548
- }
549
-
550
- /**
551
- * Cleanup and close all connections
552
- */
553
- dispose(): void {
554
- this.isActive = false;
555
-
556
- if (this.heartbeatTimer) {
557
- clearInterval(this.heartbeatTimer);
558
- }
559
-
560
- for (const client of this.clients.values()) {
561
- client.disconnect();
562
- }
563
-
564
- this.clients.clear();
565
- }
566
- }
567
-
568
- // ============================================
569
- // SSE Handler Factory
570
- // ============================================
571
-
572
- /**
573
- * Create an SSE endpoint handler compatible with Node.js HTTP frameworks.
574
- * Handles both SSE streaming (GET) and RPC requests (POST).
575
- */
576
- export function createSSEHandler(options: SSEHandlerOptions) {
577
- return async (req: { method?: string; on: Function }, res: { writeHead: Function; write: Function }) => {
578
- // Set SSE headers
579
- res.writeHead(200, {
580
- 'Content-Type': 'text/event-stream',
581
- 'Cache-Control': 'no-cache',
582
- 'Connection': 'keep-alive',
583
- 'Access-Control-Allow-Origin': '*',
584
- });
585
-
586
- // Send initial connection acknowledgment
587
- writeSSEEvent(res, 'connected', { timestamp: Date.now() });
588
-
589
- // Create connection manager with event routing
590
- const manager = new SSEConnectionManager(options, (event) => {
591
- if (isRpcResponseEvent(event)) {
592
- writeSSEEvent(res, 'rpc-response', event);
593
- } else if (isConnectionEvent(event)) {
594
- writeSSEEvent(res, 'connection', event);
595
- } else {
596
- writeSSEEvent(res, 'observability', event);
597
- }
598
- });
599
-
600
- // Cleanup on client disconnect
601
- req.on('close', () => manager.dispose());
602
-
603
- // Handle RPC requests via POST
604
- if (req.method === 'POST') {
605
- let body = '';
606
- req.on('data', (chunk: Buffer) => {
607
- body += chunk.toString();
608
- });
609
- req.on('end', async () => {
610
- try {
611
- const request: McpRpcRequest = JSON.parse(body);
612
- await manager.handleRequest(request);
613
- } catch {
614
- // Request parsing/handling errors are sent via SSE error events
615
- }
616
- });
617
- }
618
- };
619
- }
620
-
621
- // ============================================
622
- // Utilities
623
- // ============================================
624
-
625
- /**
626
- * Write an SSE event to the response stream
627
- */
628
- function writeSSEEvent(res: { write: Function }, event: string, data: unknown): void {
629
- res.write(`event: ${event}\n`);
630
- res.write(`data: ${JSON.stringify(data)}\n\n`);
631
- }
1
+ /**
2
+ * SSE (Server-Sent Events) Handler for MCP Connections
3
+ *
4
+ * Manages real-time bidirectional communication with MCP clients:
5
+ * - SSE stream for server → client events (connection state, tools, logs)
6
+ * - HTTP POST for client → server RPC requests
7
+ *
8
+ * Key features:
9
+ * - Direct HTTP response for RPC calls (bypasses SSE latency)
10
+ * - Automatic session restoration and validation
11
+ * - OAuth 2.1 authentication flow support
12
+ * - Heartbeat to keep connections alive
13
+ */
14
+
15
+ import type { McpConnectionEvent, McpObservabilityEvent } from '../../shared/events.js';
16
+ import type {
17
+ McpRpcRequest,
18
+ McpRpcResponse,
19
+ ConnectParams,
20
+ DisconnectParams,
21
+ SessionParams,
22
+ CallToolParams,
23
+ GetPromptParams,
24
+ ReadResourceParams,
25
+ FinishAuthParams,
26
+ SessionListResult,
27
+ ConnectResult,
28
+ DisconnectResult,
29
+ RestoreSessionResult,
30
+ FinishAuthResult,
31
+ ListToolsRpcResult,
32
+ ListPromptsResult,
33
+ ListResourcesResult,
34
+ CallToolResult,
35
+ } from '../../shared/types.js';
36
+ import { RpcErrorCodes } from '../../shared/errors.js';
37
+ import { UnauthorizedError } from '../../shared/errors.js';
38
+ import { isConnectionEvent, isRpcResponseEvent } from '../../shared/event-routing.js';
39
+ import { MCPClient } from '../mcp/oauth-client.js';
40
+ import { storage } from '../storage/index.js';
41
+
42
+ // ============================================
43
+ // Types & Interfaces
44
+ // ============================================
45
+
46
+ export interface ClientMetadata {
47
+ clientName?: string;
48
+ clientUri?: string;
49
+ logoUri?: string;
50
+ policyUri?: string;
51
+ }
52
+
53
+ export interface SSEHandlerOptions {
54
+ /** User/Client identifier */
55
+ identity: string;
56
+
57
+ /** Optional callback for authentication/authorization */
58
+ onAuth?: (identity: string) => Promise<boolean>;
59
+
60
+ /** Heartbeat interval in milliseconds @default 30000 */
61
+ heartbeatInterval?: number;
62
+
63
+ /** Static OAuth client metadata defaults (for all connections) */
64
+ clientDefaults?: ClientMetadata;
65
+
66
+ /** Dynamic OAuth client metadata getter (per-request, useful for multi-tenant) */
67
+ getClientMetadata?: (request?: unknown) => ClientMetadata | Promise<ClientMetadata>;
68
+ }
69
+
70
+ // ============================================
71
+ // Constants
72
+ // ============================================
73
+
74
+ const DEFAULT_HEARTBEAT_INTERVAL = 30000;
75
+
76
+ // ============================================
77
+ // SSEConnectionManager Class
78
+ // ============================================
79
+
80
+ /**
81
+ * Manages a single SSE connection and handles MCP operations.
82
+ * Each instance corresponds to one connected browser client.
83
+ */
84
+ export class SSEConnectionManager {
85
+ private readonly identity: string;
86
+ private readonly clients = new Map<string, MCPClient>();
87
+ private heartbeatTimer?: NodeJS.Timeout;
88
+ private isActive = true;
89
+
90
+ constructor(
91
+ private readonly options: SSEHandlerOptions,
92
+ private readonly sendEvent: (event: McpConnectionEvent | McpObservabilityEvent | McpRpcResponse) => void
93
+ ) {
94
+ this.identity = options.identity;
95
+ this.startHeartbeat();
96
+ }
97
+
98
+ /**
99
+ * Get resolved client metadata (dynamic > static > defaults)
100
+ */
101
+ private async getResolvedClientMetadata(request?: any): Promise<ClientMetadata> {
102
+ // Priority: getClientMetadata() > clientDefaults > empty object
103
+ let metadata: ClientMetadata = {};
104
+
105
+ // Start with static defaults
106
+ if (this.options.clientDefaults) {
107
+ metadata = { ...this.options.clientDefaults };
108
+ }
109
+
110
+ // Override with dynamic metadata if provided
111
+ if (this.options.getClientMetadata) {
112
+ const dynamicMetadata = await this.options.getClientMetadata(request);
113
+ metadata = { ...metadata, ...dynamicMetadata };
114
+ }
115
+
116
+ return metadata;
117
+ }
118
+
119
+ /**
120
+ * Start heartbeat to keep connection alive
121
+ */
122
+ private startHeartbeat(): void {
123
+ const interval = this.options.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL;
124
+ this.heartbeatTimer = setInterval(() => {
125
+ if (this.isActive) {
126
+ this.sendEvent({
127
+ level: 'debug',
128
+ message: 'heartbeat',
129
+ timestamp: Date.now(),
130
+ } as McpObservabilityEvent);
131
+ }
132
+ }, interval);
133
+ }
134
+
135
+ /**
136
+ * Handle incoming RPC requests
137
+ * Returns the RPC response directly for immediate HTTP response (bypassing SSE latency)
138
+ */
139
+ async handleRequest(request: McpRpcRequest): Promise<McpRpcResponse> {
140
+ try {
141
+ let result: SessionListResult | ConnectResult | DisconnectResult | RestoreSessionResult | FinishAuthResult | ListToolsRpcResult | ListPromptsResult | ListResourcesResult | unknown;
142
+
143
+ switch (request.method) {
144
+ case 'getSessions':
145
+ result = await this.getSessions();
146
+ break;
147
+
148
+ case 'connect':
149
+ result = await this.connect(request.params as ConnectParams);
150
+ break;
151
+
152
+ case 'disconnect':
153
+ result = await this.disconnect(request.params as DisconnectParams);
154
+ break;
155
+
156
+ case 'listTools':
157
+ result = await this.listTools(request.params as SessionParams);
158
+ break;
159
+
160
+ case 'callTool':
161
+ result = await this.callTool(request.params as CallToolParams);
162
+ break;
163
+
164
+ case 'restoreSession':
165
+ result = await this.restoreSession(request.params as SessionParams);
166
+ break;
167
+
168
+ case 'finishAuth':
169
+ result = await this.finishAuth(request.params as FinishAuthParams);
170
+ break;
171
+
172
+ case 'listPrompts':
173
+ result = await this.listPrompts(request.params as SessionParams);
174
+ break;
175
+
176
+ case 'getPrompt':
177
+ result = await this.getPrompt(request.params as GetPromptParams);
178
+ break;
179
+
180
+ case 'listResources':
181
+ result = await this.listResources(request.params as SessionParams);
182
+ break;
183
+
184
+ case 'readResource':
185
+ result = await this.readResource(request.params as ReadResourceParams);
186
+ break;
187
+
188
+ default:
189
+ throw new Error(`Unknown method: ${request.method}`);
190
+ }
191
+
192
+ const response: McpRpcResponse = {
193
+ id: request.id,
194
+ result,
195
+ };
196
+
197
+ // Also send via SSE for backwards compatibility
198
+ this.sendEvent(response);
199
+
200
+ return response;
201
+ } catch (error) {
202
+ const errorResponse: McpRpcResponse = {
203
+ id: request.id,
204
+ error: {
205
+ code: RpcErrorCodes.EXECUTION_ERROR,
206
+ message: error instanceof Error ? error.message : 'Unknown error',
207
+ },
208
+ };
209
+
210
+ // Also send via SSE for backwards compatibility
211
+ this.sendEvent(errorResponse);
212
+
213
+ return errorResponse;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Get all sessions for the current identity
219
+ */
220
+ private async getSessions(): Promise<SessionListResult> {
221
+ const sessions = await storage.getIdentitySessionsData(this.identity);
222
+
223
+ return {
224
+ sessions: sessions.map((s) => ({
225
+ sessionId: s.sessionId,
226
+ serverId: s.serverId,
227
+ serverName: s.serverName,
228
+ serverUrl: s.serverUrl,
229
+ transport: s.transportType,
230
+ createdAt: s.createdAt,
231
+ active: s.active !== false,
232
+ })),
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Connect to an MCP server
238
+ */
239
+ private async connect(params: ConnectParams): Promise<ConnectResult> {
240
+ const { serverName, serverUrl, callbackUrl, transportType } = params;
241
+
242
+ // Normalize serverId to max 12 chars to keep tool names under 64 chars (DeepSeek/OpenAI limits)
243
+ // Tool name format: tool_<serverId>_<toolName> - with 12 char serverId leaves 46 chars for tool name
244
+ const serverId = params.serverId && params.serverId.length <= 12
245
+ ? params.serverId
246
+ : await storage.generateSessionId();
247
+
248
+ // Check for existing connections
249
+ const existingSessions = await storage.getIdentitySessionsData(this.identity);
250
+ const duplicate = existingSessions.find(s =>
251
+ s.serverId === serverId || s.serverUrl === serverUrl
252
+ );
253
+
254
+ if (duplicate) {
255
+ // If the existing session is still pending OAuth, treat connect as "resume auth"
256
+ // instead of failing with duplicate connection error.
257
+ if (duplicate.active === false) {
258
+ await this.restoreSession({ sessionId: duplicate.sessionId });
259
+ return {
260
+ sessionId: duplicate.sessionId,
261
+ success: true,
262
+ };
263
+ }
264
+ throw new Error(`Connection already exists for server: ${duplicate.serverUrl || duplicate.serverId} (${duplicate.serverName})`);
265
+ }
266
+
267
+ // Generate session ID
268
+ const sessionId = await storage.generateSessionId();
269
+
270
+ try {
271
+ // Get resolved client metadata
272
+ const clientMetadata = await this.getResolvedClientMetadata();
273
+
274
+ // Create MCP client
275
+ const client = new MCPClient({
276
+ identity: this.identity,
277
+ sessionId,
278
+ serverId,
279
+ serverName,
280
+ serverUrl,
281
+ callbackUrl,
282
+ transportType,
283
+ ...clientMetadata, // Spread client metadata (clientName, clientUri, logoUri, policyUri)
284
+ });
285
+
286
+ // Note: Session will be created by MCPClient after successful connection
287
+ // This ensures sessions only exist for successful or OAuth-pending connections
288
+
289
+ // Store client
290
+ this.clients.set(sessionId, client);
291
+
292
+ // Subscribe to client events
293
+ client.onConnectionEvent((event) => {
294
+ this.emitConnectionEvent(event);
295
+ });
296
+
297
+ client.onObservabilityEvent((event) => {
298
+ this.sendEvent(event);
299
+ });
300
+
301
+ // Attempt connection
302
+ await client.connect();
303
+
304
+ // Fetch tools
305
+ await client.listTools();
306
+
307
+ return {
308
+ sessionId,
309
+ success: true,
310
+ };
311
+ } catch (error) {
312
+ if (error instanceof UnauthorizedError) {
313
+ // OAuth-required is a pending-auth state, not a failed connection.
314
+ this.clients.delete(sessionId);
315
+ return {
316
+ sessionId,
317
+ success: true,
318
+ };
319
+ }
320
+
321
+ this.emitConnectionEvent({
322
+ type: 'error',
323
+ sessionId,
324
+ serverId,
325
+ error: error instanceof Error ? error.message : 'Connection failed',
326
+ errorType: 'connection',
327
+ timestamp: Date.now(),
328
+ });
329
+
330
+ // Clean up client
331
+ this.clients.delete(sessionId);
332
+
333
+ throw error;
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Disconnect from an MCP server
339
+ */
340
+ private async disconnect(params: DisconnectParams): Promise<DisconnectResult> {
341
+ const { sessionId } = params;
342
+ const client = this.clients.get(sessionId);
343
+
344
+ if (client) {
345
+ await client.clearSession();
346
+ client.disconnect();
347
+ this.clients.delete(sessionId);
348
+ } else {
349
+ // Handle orphaned sessions (e.g., OAuth flow failed before client was stored)
350
+ // Directly remove from storage since there's no active client
351
+ await storage.removeSession(this.identity, sessionId);
352
+ }
353
+
354
+ return { success: true };
355
+ }
356
+
357
+ /**
358
+ * Get an existing client or create and connect a new one for the session.
359
+ */
360
+ private async getOrCreateClient(sessionId: string): Promise<MCPClient> {
361
+ const existing = this.clients.get(sessionId);
362
+ if (existing) {
363
+ return existing;
364
+ }
365
+
366
+ const client = new MCPClient({
367
+ identity: this.identity,
368
+ sessionId,
369
+ });
370
+
371
+ // Subscribe to events before connecting
372
+ client.onConnectionEvent((event) => this.emitConnectionEvent(event));
373
+ client.onObservabilityEvent((event) => this.sendEvent(event));
374
+
375
+ await client.connect();
376
+ this.clients.set(sessionId, client);
377
+
378
+ return client;
379
+ }
380
+
381
+ /**
382
+ * List tools from a session
383
+ */
384
+ private async listTools(params: SessionParams): Promise<ListToolsRpcResult> {
385
+ const { sessionId } = params;
386
+ const client = await this.getOrCreateClient(sessionId);
387
+ const result = await client.listTools();
388
+ return { tools: result.tools };
389
+ }
390
+
391
+ /**
392
+ * Call a tool on the MCP server
393
+ */
394
+ private async callTool(params: CallToolParams): Promise<CallToolResult> {
395
+ const { sessionId, toolName, toolArgs } = params;
396
+ const client = await this.getOrCreateClient(sessionId);
397
+ const result = await client.callTool(toolName, toolArgs);
398
+
399
+ // Inject sessionId into meta so client knows who handled it
400
+ // This allows AppHost to auto-launch without scanning all sessions
401
+ const meta = result._meta || {};
402
+
403
+ return {
404
+ ...result,
405
+ _meta: {
406
+ ...meta,
407
+ sessionId,
408
+ }
409
+ };
410
+ }
411
+
412
+ /**
413
+ * Restore and validate an existing session
414
+ */
415
+ private async restoreSession(params: SessionParams): Promise<RestoreSessionResult> {
416
+ const { sessionId } = params;
417
+
418
+ const session = await storage.getSession(this.identity, sessionId);
419
+ if (!session) {
420
+ throw new Error('Session not found');
421
+ }
422
+
423
+ this.emitConnectionEvent({
424
+ type: 'state_changed',
425
+ sessionId,
426
+ serverId: session.serverId ?? 'unknown',
427
+ serverName: session.serverName ?? 'Unknown',
428
+ serverUrl: session.serverUrl,
429
+ state: 'VALIDATING',
430
+ previousState: 'DISCONNECTED',
431
+ timestamp: Date.now(),
432
+ });
433
+
434
+ try {
435
+ const clientMetadata = await this.getResolvedClientMetadata();
436
+
437
+ const client = new MCPClient({
438
+ identity: this.identity,
439
+ sessionId,
440
+ ...clientMetadata,
441
+ });
442
+
443
+ client.onConnectionEvent((event) => this.emitConnectionEvent(event));
444
+ client.onObservabilityEvent((event) => this.sendEvent(event));
445
+
446
+ await client.connect();
447
+ this.clients.set(sessionId, client);
448
+
449
+ const tools = await client.listTools();
450
+
451
+ return { success: true, toolCount: tools.tools.length };
452
+ } catch (error) {
453
+ this.emitConnectionEvent({
454
+ type: 'error',
455
+ sessionId,
456
+ serverId: session.serverId ?? 'unknown',
457
+ error: error instanceof Error ? error.message : 'Validation failed',
458
+ errorType: 'validation',
459
+ timestamp: Date.now(),
460
+ });
461
+
462
+ throw error;
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Complete OAuth authorization flow
468
+ */
469
+ private async finishAuth(params: FinishAuthParams): Promise<FinishAuthResult> {
470
+ const { sessionId, code } = params;
471
+
472
+ const session = await storage.getSession(this.identity, sessionId);
473
+ if (!session) {
474
+ throw new Error('Session not found');
475
+ }
476
+
477
+ try {
478
+ const client = new MCPClient({
479
+ identity: this.identity,
480
+ sessionId,
481
+ });
482
+
483
+ client.onConnectionEvent((event) => this.emitConnectionEvent(event));
484
+
485
+ await client.finishAuth(code);
486
+ this.clients.set(sessionId, client);
487
+
488
+ const tools = await client.listTools();
489
+
490
+ return { success: true, toolCount: tools.tools.length };
491
+ } catch (error) {
492
+ this.emitConnectionEvent({
493
+ type: 'error',
494
+ sessionId,
495
+ serverId: session.serverId ?? 'unknown',
496
+ error: error instanceof Error ? error.message : 'OAuth completion failed',
497
+ errorType: 'auth',
498
+ timestamp: Date.now(),
499
+ });
500
+
501
+ throw error;
502
+ }
503
+ }
504
+
505
+ /**
506
+ * List prompts from a session
507
+ */
508
+ private async listPrompts(params: SessionParams): Promise<ListPromptsResult> {
509
+ const { sessionId } = params;
510
+ const client = await this.getOrCreateClient(sessionId);
511
+ const result = await client.listPrompts();
512
+ return { prompts: result.prompts };
513
+ }
514
+
515
+ /**
516
+ * Get a specific prompt
517
+ */
518
+ private async getPrompt(params: GetPromptParams): Promise<unknown> {
519
+ const { sessionId, name, args } = params;
520
+ const client = await this.getOrCreateClient(sessionId);
521
+ return await client.getPrompt(name, args);
522
+ }
523
+
524
+ /**
525
+ * List resources from a session
526
+ */
527
+ private async listResources(params: SessionParams): Promise<ListResourcesResult> {
528
+ const { sessionId } = params;
529
+ const client = await this.getOrCreateClient(sessionId);
530
+ const result = await client.listResources();
531
+ return { resources: result.resources };
532
+ }
533
+
534
+ /**
535
+ * Read a specific resource
536
+ */
537
+ private async readResource(params: ReadResourceParams): Promise<unknown> {
538
+ const { sessionId, uri } = params;
539
+ const client = await this.getOrCreateClient(sessionId);
540
+ return client.readResource(uri);
541
+ }
542
+
543
+ /**
544
+ * Emit connection event
545
+ */
546
+ private emitConnectionEvent(event: McpConnectionEvent): void {
547
+ this.sendEvent(event);
548
+ }
549
+
550
+ /**
551
+ * Cleanup and close all connections
552
+ */
553
+ dispose(): void {
554
+ this.isActive = false;
555
+
556
+ if (this.heartbeatTimer) {
557
+ clearInterval(this.heartbeatTimer);
558
+ }
559
+
560
+ for (const client of this.clients.values()) {
561
+ client.disconnect();
562
+ }
563
+
564
+ this.clients.clear();
565
+ }
566
+ }
567
+
568
+ // ============================================
569
+ // SSE Handler Factory
570
+ // ============================================
571
+
572
+ /**
573
+ * Create an SSE endpoint handler compatible with Node.js HTTP frameworks.
574
+ * Handles both SSE streaming (GET) and RPC requests (POST).
575
+ */
576
+ export function createSSEHandler(options: SSEHandlerOptions) {
577
+ return async (req: { method?: string; on: Function }, res: { writeHead: Function; write: Function }) => {
578
+ // Set SSE headers
579
+ res.writeHead(200, {
580
+ 'Content-Type': 'text/event-stream',
581
+ 'Cache-Control': 'no-cache',
582
+ 'Connection': 'keep-alive',
583
+ 'Access-Control-Allow-Origin': '*',
584
+ });
585
+
586
+ // Send initial connection acknowledgment
587
+ writeSSEEvent(res, 'connected', { timestamp: Date.now() });
588
+
589
+ // Create connection manager with event routing
590
+ const manager = new SSEConnectionManager(options, (event) => {
591
+ if (isRpcResponseEvent(event)) {
592
+ writeSSEEvent(res, 'rpc-response', event);
593
+ } else if (isConnectionEvent(event)) {
594
+ writeSSEEvent(res, 'connection', event);
595
+ } else {
596
+ writeSSEEvent(res, 'observability', event);
597
+ }
598
+ });
599
+
600
+ // Cleanup on client disconnect
601
+ req.on('close', () => manager.dispose());
602
+
603
+ // Handle RPC requests via POST
604
+ if (req.method === 'POST') {
605
+ let body = '';
606
+ req.on('data', (chunk: Buffer) => {
607
+ body += chunk.toString();
608
+ });
609
+ req.on('end', async () => {
610
+ try {
611
+ const request: McpRpcRequest = JSON.parse(body);
612
+ await manager.handleRequest(request);
613
+ } catch {
614
+ // Request parsing/handling errors are sent via SSE error events
615
+ }
616
+ });
617
+ }
618
+ };
619
+ }
620
+
621
+ // ============================================
622
+ // Utilities
623
+ // ============================================
624
+
625
+ /**
626
+ * Write an SSE event to the response stream
627
+ */
628
+ function writeSSEEvent(res: { write: Function }, event: string, data: unknown): void {
629
+ res.write(`event: ${event}\n`);
630
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
631
+ }