@mcp-ts/sdk 1.3.7 → 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.
- package/LICENSE +21 -21
- package/README.md +398 -404
- package/dist/adapters/agui-middleware.js.map +1 -1
- package/dist/adapters/agui-middleware.mjs.map +1 -1
- package/dist/bin/mcp-ts.js +0 -0
- package/dist/bin/mcp-ts.js.map +1 -1
- package/dist/bin/mcp-ts.mjs +0 -0
- package/dist/bin/mcp-ts.mjs.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs.map +1 -1
- package/dist/client/react.d.mts +2 -2
- package/dist/client/react.d.ts +2 -2
- package/dist/client/react.js +25 -2
- package/dist/client/react.js.map +1 -1
- package/dist/client/react.mjs +26 -3
- package/dist/client/react.mjs.map +1 -1
- package/dist/client/vue.js.map +1 -1
- package/dist/client/vue.mjs.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs.map +1 -1
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/index.mjs.map +1 -1
- package/package.json +185 -185
- package/src/adapters/agui-middleware.ts +382 -382
- package/src/bin/mcp-ts.ts +102 -102
- package/src/client/core/app-host.ts +417 -417
- package/src/client/core/sse-client.ts +371 -371
- package/src/client/core/types.ts +31 -31
- package/src/client/index.ts +27 -27
- package/src/client/react/index.ts +16 -16
- package/src/client/react/use-app-host.ts +73 -73
- package/src/client/react/use-mcp-apps.tsx +247 -214
- package/src/client/react/use-mcp.ts +641 -641
- package/src/client/vue/index.ts +10 -10
- package/src/client/vue/use-mcp.ts +617 -617
- package/src/index.ts +11 -11
- package/src/server/handlers/nextjs-handler.ts +204 -204
- package/src/server/handlers/sse-handler.ts +631 -631
- package/src/server/index.ts +57 -57
- package/src/server/mcp/multi-session-client.ts +228 -228
- package/src/server/mcp/oauth-client.ts +1188 -1188
- package/src/server/mcp/storage-oauth-provider.ts +272 -272
- package/src/server/storage/file-backend.ts +157 -157
- package/src/server/storage/index.ts +176 -176
- package/src/server/storage/memory-backend.ts +123 -123
- package/src/server/storage/redis-backend.ts +276 -276
- package/src/server/storage/redis.ts +160 -160
- package/src/server/storage/sqlite-backend.ts +182 -182
- package/src/server/storage/supabase-backend.ts +228 -228
- package/src/server/storage/types.ts +116 -116
- package/src/shared/constants.ts +29 -29
- package/src/shared/errors.ts +133 -133
- package/src/shared/event-routing.ts +28 -28
- package/src/shared/events.ts +180 -180
- package/src/shared/index.ts +75 -75
- package/src/shared/tool-utils.ts +61 -61
- package/src/shared/types.ts +282 -282
- package/src/shared/utils.ts +38 -38
- 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
|
+
}
|