@mcp-ts/sdk 2.5.2 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +3 -574
  2. package/dist/adapters/agui-adapter.d.mts +4 -4
  3. package/dist/adapters/agui-adapter.d.ts +4 -4
  4. package/dist/adapters/agui-middleware.d.mts +4 -4
  5. package/dist/adapters/agui-middleware.d.ts +4 -4
  6. package/dist/adapters/ai-adapter.d.mts +4 -4
  7. package/dist/adapters/ai-adapter.d.ts +4 -4
  8. package/dist/adapters/langchain-adapter.d.mts +4 -4
  9. package/dist/adapters/langchain-adapter.d.ts +4 -4
  10. package/dist/adapters/mastra-adapter.d.mts +3 -3
  11. package/dist/adapters/mastra-adapter.d.ts +3 -3
  12. package/dist/client/index.d.mts +2 -2
  13. package/dist/client/index.d.ts +2 -2
  14. package/dist/client/index.js +3 -0
  15. package/dist/client/index.js.map +1 -1
  16. package/dist/client/index.mjs +3 -0
  17. package/dist/client/index.mjs.map +1 -1
  18. package/dist/client/react.d.mts +10 -18
  19. package/dist/client/react.d.ts +10 -18
  20. package/dist/client/react.js +17 -0
  21. package/dist/client/react.js.map +1 -1
  22. package/dist/client/react.mjs +17 -0
  23. package/dist/client/react.mjs.map +1 -1
  24. package/dist/client/vue.d.mts +4 -4
  25. package/dist/client/vue.d.ts +4 -4
  26. package/dist/client/vue.js +3 -0
  27. package/dist/client/vue.js.map +1 -1
  28. package/dist/client/vue.mjs +3 -0
  29. package/dist/client/vue.mjs.map +1 -1
  30. package/dist/{index-B3GPsPsi.d.mts → index-BrDkbut5.d.ts} +2 -1
  31. package/dist/{index-gNdSQTSz.d.ts → index-DHQvlx4K.d.mts} +2 -1
  32. package/dist/index.d.mts +5 -5
  33. package/dist/index.d.ts +5 -5
  34. package/dist/index.js +1056 -863
  35. package/dist/index.js.map +1 -1
  36. package/dist/index.mjs +1048 -858
  37. package/dist/index.mjs.map +1 -1
  38. package/dist/{multi-session-client-CB4oDrQX.d.ts → multi-session-client-CQzymvHl.d.ts} +183 -227
  39. package/dist/{multi-session-client-xj3iQIv6.d.mts → multi-session-client-C_Ja2TuV.d.mts} +183 -227
  40. package/dist/server/index.d.mts +338 -88
  41. package/dist/server/index.d.ts +338 -88
  42. package/dist/server/index.js +1054 -864
  43. package/dist/server/index.js.map +1 -1
  44. package/dist/server/index.mjs +1046 -859
  45. package/dist/server/index.mjs.map +1 -1
  46. package/dist/shared/index.d.mts +4 -4
  47. package/dist/shared/index.d.ts +4 -4
  48. package/dist/shared/index.js.map +1 -1
  49. package/dist/shared/index.mjs.map +1 -1
  50. package/dist/{tool-router-CsKVXbQB.d.mts → tool-router-C1n7OXbl.d.mts} +1 -1
  51. package/dist/{tool-router-C-Mw1_BQ.d.ts → tool-router-CBIawFnt.d.ts} +1 -1
  52. package/dist/{types-6SmXege4.d.mts → types-DX71u9gR.d.mts} +11 -3
  53. package/dist/{types-6SmXege4.d.ts → types-DX71u9gR.d.ts} +11 -3
  54. package/migrations/neon/20260513010000_install_mcp_sessions.sql +4 -34
  55. package/migrations/supabase/20260330195700_install_mcp_sessions.sql +5 -62
  56. package/package.json +2 -1
  57. package/src/client/core/sse-client.ts +5 -0
  58. package/src/client/react/use-mcp.ts +27 -28
  59. package/src/server/handlers/sse-handler.ts +671 -629
  60. package/src/server/index.ts +2 -1
  61. package/src/server/mcp/multi-session-client.ts +34 -10
  62. package/src/server/mcp/oauth-client.ts +197 -205
  63. package/src/server/mcp/storage-oauth-provider.ts +149 -29
  64. package/src/server/mcp/tool-policy-gateway.ts +11 -1
  65. package/src/server/storage/file-backend.ts +25 -21
  66. package/src/server/storage/index.ts +71 -0
  67. package/src/server/storage/memory-backend.ts +24 -12
  68. package/src/server/storage/neon-backend.ts +70 -72
  69. package/src/server/storage/redis-backend.ts +26 -36
  70. package/src/server/storage/sqlite-backend.ts +25 -39
  71. package/src/server/storage/supabase-backend.ts +45 -54
  72. package/src/server/storage/types.ts +22 -2
  73. package/src/shared/types.ts +13 -1
@@ -1,15 +1,16 @@
1
1
  /**
2
- * SSE (Server-Sent Events) Handler for MCP Connections
2
+ * SSE (Server-Sent Events) Handler for MCP Connections.
3
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 clientserver RPC requests
4
+ * Provides real-time bidirectional communication between browser clients and
5
+ * MCP servers via a single HTTP endpoint:
6
+ * - GET → opens an SSE stream for serverclient events
7
+ * - POST → delivers client → server RPC requests with direct HTTP response
7
8
  *
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
9
+ * Built on {@link SSEConnectionManager} which handles the RPC dispatch logic,
10
+ * session lifecycle, OAuth 2.1 flows, tool-policy enforcement, and heartbeat
11
+ * keep-alive all while remaining stateless across serverless invocations.
12
+ *
13
+ * @module sse-handler
13
14
  */
14
15
 
15
16
  import type { McpConnectionEvent, McpObservabilityEvent } from '../../shared/events.js';
@@ -37,20 +38,28 @@ import type {
37
38
  SetToolPolicyResult,
38
39
  GetToolPolicyParams,
39
40
  GetToolPolicyResult,
41
+ UpdateSessionParams,
42
+ UpdateSessionResult,
40
43
  } from '../../shared/types.js';
41
- import { RpcErrorCodes } from '../../shared/errors.js';
42
- import { UnauthorizedError } from '../../shared/errors.js';
44
+ import { RpcErrorCodes, UnauthorizedError } from '../../shared/errors.js';
43
45
  import { isConnectionEvent, isRpcResponseEvent } from '../../shared/event-routing.js';
44
46
  import { parseOAuthState } from '../../shared/utils.js';
45
47
  import { MCPClient } from '../mcp/oauth-client.js';
46
- import { sessions, generateServerId, type Session } from '../storage/index.js';
47
- import { createToolId, isToolAllowed, normalizeToolPolicyForUpdate, validateToolPolicyAgainstTools } from '../storage/tool-policy.js';
48
+ import { sessions, generateServerId, withDbObservability, type Session, type SessionStore } from '../storage/index.js';
49
+ import {
50
+ createToolId,
51
+ isToolAllowed,
52
+ normalizeToolPolicyForUpdate,
53
+ validateToolPolicyAgainstTools,
54
+ } from '../storage/tool-policy.js';
48
55
  import { createToolPolicyGateway } from '../mcp/tool-policy-gateway.js';
56
+ import { runWithCodeVerifierState } from '../mcp/storage-oauth-provider.js';
49
57
 
50
- // ============================================
51
- // Types & Interfaces
52
- // ============================================
58
+ // ---------------------------------------------------------------------------
59
+ // Public Types
60
+ // ---------------------------------------------------------------------------
53
61
 
62
+ /** OAuth client metadata surfaced during connection and authorization. */
54
63
  export interface ClientMetadata {
55
64
  clientName?: string;
56
65
  clientUri?: string;
@@ -58,812 +67,850 @@ export interface ClientMetadata {
58
67
  policyUri?: string;
59
68
  }
60
69
 
70
+ /** Options passed to {@link createSSEHandler}. */
61
71
  export interface SSEHandlerOptions {
62
- /** User/Client identifier */
72
+ /** Authenticated user / tenant identifier. */
63
73
  userId: string;
64
74
 
65
- /** Optional callback for authentication/authorization */
75
+ /** Optional auth check called per-request before any RPC dispatch. */
66
76
  onAuth?: (userId: string) => Promise<boolean>;
67
77
 
68
- /** Heartbeat interval in milliseconds @default 30000 */
78
+ /** SSE heartbeat interval in milliseconds. @default 30000 */
69
79
  heartbeatInterval?: number;
70
80
 
71
- /** Static OAuth client metadata defaults (for all connections) */
81
+ /** Static OAuth client metadata applied to all connections. */
72
82
  clientDefaults?: ClientMetadata;
73
83
 
74
- /** Dynamic OAuth client metadata getter (per-request, useful for multi-tenant) */
84
+ /**
85
+ * Dynamic OAuth client metadata resolver, called once per connection.
86
+ * Overrides `clientDefaults`. Useful for multi-tenant scenarios where
87
+ * metadata varies by request (headers, subdomain, etc.).
88
+ */
75
89
  getClientMetadata?: (request?: unknown) => ClientMetadata | Promise<ClientMetadata>;
76
90
  }
77
91
 
78
- // ============================================
92
+ // ---------------------------------------------------------------------------
79
93
  // Constants
80
- // ============================================
94
+ // ---------------------------------------------------------------------------
81
95
 
82
- const DEFAULT_HEARTBEAT_INTERVAL = 30000;
96
+ const DEFAULT_HEARTBEAT_MS = 30_000;
83
97
 
84
- function normalizeHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
98
+ // ---------------------------------------------------------------------------
99
+ // Helpers
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Normalizes a raw headers object: trims keys & string values,
104
+ * drops entries with empty key or value, returns `undefined` when nothing remains.
105
+ */
106
+ function normalizeHeaders(
107
+ headers?: Record<string, string>,
108
+ ): Record<string, string> | undefined {
85
109
  if (!headers || typeof headers !== 'object') return undefined;
86
110
 
87
111
  const entries = Object.entries(headers)
88
- .map(([key, value]) => [key.trim(), String(value).trim()] as const)
89
- .filter(([key, value]) => key.length > 0 && value.length > 0);
112
+ .map(([k, v]) => [k.trim(), String(v).trim()] as const)
113
+ .filter(([k, v]) => k.length > 0 && v.length > 0);
90
114
 
91
115
  return entries.length > 0 ? Object.fromEntries(entries) : undefined;
92
116
  }
93
117
 
94
- // ============================================
95
- // SSEConnectionManager Class
96
- // ============================================
118
+ /**
119
+ * Formats a {@link McpConnectionEvent} `state_changed` payload for consistent
120
+ * `VALIDATING` → `DISCONNECTED` transitions emitted during session restore.
121
+ */
122
+ function validatingStateEvent(
123
+ sessionId: string,
124
+ session: Session,
125
+ ): McpConnectionEvent {
126
+ return {
127
+ type: 'state_changed',
128
+ sessionId,
129
+ serverId: session.serverId ?? 'unknown',
130
+ serverName: session.serverName ?? 'Unknown',
131
+ serverUrl: session.serverUrl,
132
+ state: 'VALIDATING',
133
+ previousState: 'DISCONNECTED',
134
+ timestamp: Date.now(),
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Builds a `connection_error` event with the appropriate error type discriminator.
140
+ */
141
+ function connectionErrorEvent(
142
+ sessionId: string,
143
+ serverId: string | undefined,
144
+ error: unknown,
145
+ errorType: 'connection' | 'validation' | 'auth',
146
+ ): McpConnectionEvent {
147
+ return {
148
+ type: 'error',
149
+ sessionId,
150
+ serverId: serverId ?? 'unknown',
151
+ error: error instanceof Error ? error.message : 'Connection failed',
152
+ errorType,
153
+ timestamp: Date.now(),
154
+ };
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // SSEConnectionManager
159
+ // ---------------------------------------------------------------------------
97
160
 
98
161
  /**
99
- * Manages a single SSE connection and handles MCP operations.
100
- * Each instance corresponds to one connected browser client.
162
+ * Manages a single browser-facing SSE connection and all MCP server sessions
163
+ * owned by the associated user.
164
+ *
165
+ * ## Responsibilities
166
+ * - RPC method dispatch (`connect`, `disconnect`, `callTool`, `listTools`, …)
167
+ * - Session lifecycle: create, restore, re-validate, OAuth completion
168
+ * - In-memory client cache (`Map<string, MCPClient>`) for active transports
169
+ * - SSE event emission for connection state, tool discovery, and errors
170
+ * - Periodic heartbeat to prevent proxy/CDN timeouts
171
+ * - Unified observability: RPC timing, DB operations, connection lifecycle
172
+ *
173
+ * Each instance is tied to one browser client. It is **not** a singleton —
174
+ * a new instance is created per incoming SSE connection.
101
175
  */
102
176
  export class SSEConnectionManager {
177
+ // -----------------------------------------------------------------------
178
+ // State
179
+ // -----------------------------------------------------------------------
180
+
103
181
  private readonly userId: string;
182
+
183
+ /** Active MCP transports keyed by sessionId. */
104
184
  private readonly clients = new Map<string, MCPClient>();
105
- private heartbeatTimer?: NodeJS.Timeout;
106
- private isActive = true;
107
185
 
186
+ /** Instrumented session store — always wraps `sessions` with DB observability. */
187
+ private readonly observedStore: SessionStore;
188
+
189
+ private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
190
+ private active = true;
191
+
192
+ // -----------------------------------------------------------------------
193
+ // Constructor
194
+ // -----------------------------------------------------------------------
195
+
196
+ /**
197
+ * @param options - Handler configuration (userId, auth, metadata, heartbeat, observability).
198
+ * @param sendEvent - Callback that writes a typed event onto the SSE stream.
199
+ */
108
200
  constructor(
109
201
  private readonly options: SSEHandlerOptions,
110
- private readonly sendEvent: (event: McpConnectionEvent | McpObservabilityEvent | McpRpcResponse) => void
202
+ private readonly sendEvent: (
203
+ event: McpConnectionEvent | McpObservabilityEvent | McpRpcResponse,
204
+ ) => void,
111
205
  ) {
112
206
  this.userId = options.userId;
207
+ this.observedStore = withDbObservability(sessions, (event) => this.sendEvent(event));
113
208
  this.startHeartbeat();
114
209
  }
115
210
 
116
- /**
117
- * Get resolved client metadata (dynamic > static > defaults)
118
- */
119
- private async getResolvedClientMetadata(request?: any): Promise<ClientMetadata> {
120
- // Priority: getClientMetadata() > clientDefaults > empty object
121
- let metadata: ClientMetadata = {};
122
-
123
- // Start with static defaults
124
- if (this.options.clientDefaults) {
125
- metadata = { ...this.options.clientDefaults };
126
- }
127
-
128
- // Override with dynamic metadata if provided
129
- if (this.options.getClientMetadata) {
130
- const dynamicMetadata = await this.options.getClientMetadata(request);
131
- metadata = { ...metadata, ...dynamicMetadata };
132
- }
133
-
134
- return metadata;
135
- }
136
-
137
- /**
138
- * Start heartbeat to keep connection alive
139
- */
140
- private startHeartbeat(): void {
141
- const interval = this.options.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL;
142
- this.heartbeatTimer = setInterval(() => {
143
- if (this.isActive) {
144
- this.sendEvent({
145
- level: 'debug',
146
- message: 'heartbeat',
147
- timestamp: Date.now(),
148
- } as McpObservabilityEvent);
149
- }
150
- }, interval);
151
- }
211
+ // -----------------------------------------------------------------------
212
+ // Public API
213
+ // -----------------------------------------------------------------------
152
214
 
153
215
  /**
154
- * Handle incoming RPC requests
155
- * Returns the RPC response directly for immediate HTTP response (bypassing SSE latency)
216
+ * Dispatches an incoming RPC request, emits timing observability,
217
+ * and returns the response.
218
+ *
219
+ * Emits `rpc:start` before dispatch and `rpc:end` on completion
220
+ * (success or error), each carrying the method name, sessionId, and
221
+ * duration. All events flow through the unified `onObservability`
222
+ * and the SSE stream.
223
+ *
224
+ * @param request - The deserialized RPC envelope.
225
+ * @returns The RPC response (success or error).
156
226
  */
157
227
  async handleRequest(request: McpRpcRequest): Promise<McpRpcResponse> {
158
- try {
159
- let result: SessionListResult | ConnectResult | DisconnectResult | GetSessionResult | FinishAuthResult | ListToolsRpcResult | SetToolPolicyResult | GetToolPolicyResult | ListPromptsResult | ListResourcesResult | unknown;
160
-
161
- switch (request.method) {
162
- case 'listSessions':
163
- result = await this.listSessions();
164
- break;
165
-
166
- case 'connect':
167
- result = await this.connect(request.params as ConnectParams);
168
- break;
169
-
170
- case 'reconnect':
171
- result = await this.reconnect(request.params as ReconnectParams);
172
- break;
173
-
174
- case 'disconnect':
175
- result = await this.disconnect(request.params as DisconnectParams);
176
- break;
177
-
178
- case 'listTools':
179
- result = await this.listTools(request.params as SessionParams);
180
- break;
181
-
182
- case 'setToolPolicy':
183
- result = await this.setToolPolicy(request.params as SetToolPolicyParams);
184
- break;
185
-
186
- case 'getToolPolicy':
187
- result = await this.getToolPolicy(request.params as GetToolPolicyParams);
188
- break;
189
-
190
- case 'callTool':
191
- result = await this.callTool(request.params as CallToolParams);
192
- break;
193
-
194
- case 'getSession':
195
- result = await this.getSession(request.params as SessionParams);
196
- break;
197
-
198
- case 'finishAuth':
199
- result = await this.finishAuth(request.params as FinishAuthParams);
200
- break;
201
-
202
- case 'listPrompts':
203
- result = await this.listPrompts(request.params as SessionParams);
204
- break;
205
-
206
- case 'getPrompt':
207
- result = await this.getPrompt(request.params as GetPromptParams);
208
- break;
209
-
210
- case 'listResources':
211
- result = await this.listResources(request.params as SessionParams);
212
- break;
213
-
214
- case 'readResource':
215
- result = await this.readResource(request.params as ReadResourceParams);
216
- break;
228
+ const method = request.method;
229
+ const sessionId = (request.params as Record<string, unknown> | undefined)?.sessionId as string | undefined;
230
+ const t0 = performance.now();
231
+
232
+ this.sendEvent({
233
+ type: 'rpc:start',
234
+ level: 'debug',
235
+ message: method,
236
+ sessionId,
237
+ timestamp: Date.now(),
238
+ });
217
239
 
218
- default:
219
- throw new Error(`Unknown method: ${request.method}`);
220
- }
240
+ try {
241
+ const result = await this.dispatchImpl(request);
221
242
 
222
- const response: McpRpcResponse = {
223
- id: request.id,
224
- result,
225
- };
243
+ this.sendEvent({
244
+ type: 'rpc:end',
245
+ level: 'debug',
246
+ message: method,
247
+ sessionId,
248
+ payload: { durationMs: performance.now() - t0 },
249
+ timestamp: Date.now(),
250
+ });
226
251
 
227
- // Also send via SSE for backwards compatibility
252
+ const response: McpRpcResponse = { id: request.id, result };
228
253
  this.sendEvent(response);
229
-
230
254
  return response;
231
255
  } catch (error) {
232
- const errorResponse: McpRpcResponse = {
256
+ this.sendEvent({
257
+ type: 'rpc:end',
258
+ level: 'error',
259
+ message: method,
260
+ sessionId,
261
+ payload: { durationMs: performance.now() - t0, error: String(error) },
262
+ timestamp: Date.now(),
263
+ });
264
+
265
+ const response: McpRpcResponse = {
233
266
  id: request.id,
234
267
  error: {
235
268
  code: RpcErrorCodes.EXECUTION_ERROR,
236
269
  message: error instanceof Error ? error.message : 'Unknown error',
237
270
  },
238
271
  };
272
+ this.sendEvent(response);
273
+ return response;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Tears down all active MCP transports and stops the heartbeat timer.
279
+ *
280
+ * Disconnects are issued in parallel across all sessions. After calling
281
+ * this method the manager instance should be discarded.
282
+ */
283
+ async dispose(): Promise<void> {
284
+ this.active = false;
285
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
239
286
 
240
- // Also send via SSE for backwards compatibility
241
- this.sendEvent(errorResponse);
287
+ await Promise.all(
288
+ Array.from(this.clients.values()).map((c) => c.disconnect()),
289
+ );
242
290
 
243
- return errorResponse;
291
+ this.clients.clear();
292
+ }
293
+
294
+ // -----------------------------------------------------------------------
295
+ // RPC Dispatch (raw — called by handleRequest which adds timing)
296
+ // -----------------------------------------------------------------------
297
+
298
+ /**
299
+ * Routes an RPC method name to the appropriate private handler.
300
+ *
301
+ * @throws {Error} When the method name is unrecognized.
302
+ */
303
+ private async dispatchImpl(request: McpRpcRequest): Promise<unknown> {
304
+ switch (request.method) {
305
+ case 'listSessions': return this.listSessions();
306
+ case 'connect': return this.connect(request.params as ConnectParams);
307
+ case 'reconnect': return this.reconnect(request.params as ReconnectParams);
308
+ case 'disconnect': return this.disconnect(request.params as DisconnectParams);
309
+ case 'listTools': return this.listTools(request.params as SessionParams);
310
+ case 'setToolPolicy': return this.setToolPolicy(request.params as SetToolPolicyParams);
311
+ case 'getToolPolicy': return this.getToolPolicy(request.params as GetToolPolicyParams);
312
+ case 'updateSession': return this.updateSession(request.params as UpdateSessionParams);
313
+ case 'callTool': return this.callTool(request.params as CallToolParams);
314
+ case 'getSession': return this.getSession(request.params as SessionParams);
315
+ case 'finishAuth': return this.finishAuth(request.params as FinishAuthParams);
316
+ case 'listPrompts': return this.listPrompts(request.params as SessionParams);
317
+ case 'getPrompt': return this.getPrompt(request.params as GetPromptParams);
318
+ case 'listResources': return this.listResources(request.params as SessionParams);
319
+ case 'readResource': return this.readResource(request.params as ReadResourceParams);
320
+ default:
321
+ throw new Error(`Unknown RPC method: ${request.method}`);
244
322
  }
245
323
  }
246
324
 
325
+ // -----------------------------------------------------------------------
326
+ // Session Query
327
+ // -----------------------------------------------------------------------
328
+
247
329
  /**
248
- * Get all sessions for the current userId
330
+ * Lists all sessions owned by the current user.
331
+ *
332
+ * Returns a lightweight view — session metadata only, no credential fields.
249
333
  */
250
334
  private async listSessions(): Promise<SessionListResult> {
251
- const sessionList = await sessions.list(this.userId);
252
-
335
+ const all = await sessions.list(this.userId);
253
336
  return {
254
- sessions: sessionList.map((s) => ({
255
- sessionId: s.sessionId,
256
- serverId: s.serverId,
257
- serverName: s.serverName,
258
- serverUrl: s.serverUrl,
259
- transport: s.transportType,
260
- createdAt: s.createdAt,
261
- updatedAt: s.updatedAt ?? s.createdAt,
262
- status: s.status ?? 'pending',
263
- toolPolicy: s.toolPolicy,
337
+ sessions: all.map((s) => ({
338
+ sessionId: s.sessionId,
339
+ serverId: s.serverId,
340
+ serverName: s.serverName,
341
+ serverUrl: s.serverUrl,
342
+ transport: s.transportType,
343
+ createdAt: s.createdAt,
344
+ updatedAt: s.updatedAt ?? s.createdAt,
345
+ status: s.status ?? 'pending',
346
+ toolPolicy: s.toolPolicy,
347
+ enabled: s.enabled ?? true,
264
348
  })),
265
349
  };
266
350
  }
267
351
 
268
352
  /**
269
- * Connect to an MCP server
353
+ * Restores and validates a previously persisted session.
354
+ *
355
+ * Loads the full session row (including credentials) from storage, creates
356
+ * a new MCP transport, connects to the remote server, and emits the
357
+ * discovered (policy-filtered) tool list via SSE.
270
358
  */
271
- private async connect(params: ConnectParams): Promise<ConnectResult> {
272
- const { serverName, serverUrl, callbackUrl, transportType } = params;
273
- const headers = normalizeHeaders(params.headers);
274
-
275
- // Normalize serverId to max 12 chars to keep tool names under 64 chars (DeepSeek/OpenAI limits)
276
- // Tool name format: tool_<serverId>_<toolName> - with 12 char serverId leaves 46 chars for tool name
277
- const serverId = params.serverId && params.serverId.length <= 12
278
- ? params.serverId
279
- : generateServerId();
280
-
281
- // Check for existing connections
282
- const existingSessions = await sessions.list(this.userId);
283
- const duplicate = existingSessions.find(s =>
284
- s.serverId === serverId || s.serverUrl === serverUrl
285
- );
286
-
287
- if (duplicate) {
288
- // If the existing session is still pending OAuth, treat connect as "resume auth"
289
- // instead of failing with duplicate connection error.
290
- if (duplicate.status === 'pending') {
291
- await this.getSession({ sessionId: duplicate.sessionId });
292
- return {
293
- sessionId: duplicate.sessionId,
294
- success: true,
295
- };
296
- }
297
- throw new Error(`Connection already exists for server: ${duplicate.serverUrl || duplicate.serverId} (${duplicate.serverName})`);
298
- }
299
-
300
- // Generate session ID
301
- const sessionId = await sessions.generateSessionId();
359
+ private async getSession(params: SessionParams): Promise<GetSessionResult> {
360
+ const session = await this.requireSession(params.sessionId);
361
+ this.sendEvent(validatingStateEvent(params.sessionId, session));
302
362
 
303
363
  try {
304
- // Get resolved client metadata
305
- const clientMetadata = await this.getResolvedClientMetadata();
364
+ const client = this.restoreClient(session);
365
+ this.attachClientEvents(client);
306
366
 
307
- // Create MCP client
308
- const client = new MCPClient({
309
- userId: this.userId,
310
- sessionId,
311
- serverId,
312
- serverName,
313
- serverUrl,
314
- callbackUrl,
315
- transportType,
316
- headers,
317
- ...clientMetadata, // Spread client metadata (clientName, clientUri, logoUri, policyUri)
318
- });
319
-
320
- // Note: Session will be created by MCPClient after successful connection
321
- // This ensures sessions only exist for successful or OAuth-pending connections
322
-
323
- // Store client
324
- this.clients.set(sessionId, client);
325
-
326
- // Subscribe to client events
327
- client.onConnectionEvent((event) => {
328
- this.emitConnectionEvent(event);
329
- });
330
-
331
- client.onObservabilityEvent((event) => {
332
- this.sendEvent(event);
333
- });
334
-
335
- // Attempt connection
336
367
  await client.connect();
368
+ this.clients.set(params.sessionId, client);
337
369
 
338
- // Fetch policy-filtered tools for agent-facing discovery
339
- await this.listPolicyFilteredTools(sessionId);
340
-
341
- return {
342
- sessionId,
343
- success: true,
344
- };
370
+ const { result } = await this.listPolicyFilteredTools(params.sessionId);
371
+ return { success: true, toolCount: result.tools.length };
345
372
  } catch (error) {
346
- if (error instanceof UnauthorizedError) {
347
- // OAuth-required is a pending-auth state, not a failed connection.
348
- this.clients.delete(sessionId);
349
- return {
350
- sessionId,
351
- success: true,
352
- };
353
- }
354
-
355
- this.emitConnectionEvent({
356
- type: 'error',
357
- sessionId,
358
- serverId,
359
- error: error instanceof Error ? error.message : 'Connection failed',
360
- errorType: 'connection',
361
- timestamp: Date.now(),
362
- });
363
-
364
- // Clean up client
365
- this.clients.delete(sessionId);
366
-
373
+ this.sendEvent(connectionErrorEvent(params.sessionId, session.serverId, error, 'validation'));
367
374
  throw error;
368
375
  }
369
376
  }
370
377
 
378
+ // -----------------------------------------------------------------------
379
+ // Connection Lifecycle
380
+ // -----------------------------------------------------------------------
381
+
371
382
  /**
372
- * Reconnect to an MCP server — tears down the active client transport/connection
373
- * and creates a fresh connection while reusing the existing session credentials in a single RPC call.
383
+ * Initiates a connection to a new MCP server.
384
+ *
385
+ * If a session for the same `serverId` or `serverUrl` already exists and
386
+ * is still in a `pending` (OAuth) state, the existing session is resumed
387
+ * instead of creating a duplicate.
388
+ *
389
+ * `UnauthorizedError` is treated as a pending-auth state and returned as
390
+ * a successful result (the client will then redirect to the auth URL).
374
391
  */
375
- private async reconnect(params: ReconnectParams): Promise<ConnectResult> {
376
- const { serverId: rawServerId, serverName, serverUrl, callbackUrl, transportType } = params;
377
- const headers = normalizeHeaders(params.headers);
378
-
379
- // Normalize serverId the same way connect() does
380
- const serverId = rawServerId && rawServerId.length <= 12
381
- ? rawServerId
382
- : generateServerId();
383
-
384
- // Find existing session for the same server to reuse its session ID
385
- const existingSessions = await sessions.list(this.userId);
386
- const duplicate = existingSessions.find(s =>
387
- s.serverId === serverId || s.serverUrl === serverUrl
388
- );
389
-
390
- // Reuse the duplicate sessionId if present, otherwise generate a new one
391
- const sessionId = duplicate ? duplicate.sessionId : await sessions.generateSessionId();
392
+ private async connect(params: ConnectParams): Promise<ConnectResult> {
393
+ const headers = normalizeHeaders(params.headers);
394
+ const serverId = this.normalizeServerId(params.serverId);
392
395
 
393
- if (duplicate) {
394
- // Disconnect any active in-memory client transport without deleting the database session
395
- const existingClient = this.clients.get(duplicate.sessionId);
396
- if (existingClient) {
397
- await existingClient.disconnect();
398
- this.clients.delete(duplicate.sessionId);
396
+ const existing = await this.findExistingSession(serverId, params.serverUrl);
397
+ if (existing) {
398
+ if (existing.status === 'pending') {
399
+ return this.getSession({ sessionId: existing.sessionId }).then(() => ({
400
+ sessionId: existing.sessionId,
401
+ success: true,
402
+ }));
399
403
  }
404
+ throw new Error(
405
+ `Connection already exists for server: ${existing.serverUrl ?? existing.serverId} (${existing.serverName})`,
406
+ );
400
407
  }
401
408
 
402
- try {
403
- const clientMetadata = await this.getResolvedClientMetadata();
404
-
405
- // Create a new client instantiating the reused session ID (which preserves DCR credentials and tokens)
406
- const client = new MCPClient({
407
- userId: this.userId,
408
- sessionId,
409
- serverId,
410
- serverName,
411
- serverUrl,
412
- callbackUrl,
413
- transportType,
414
- headers,
415
- ...clientMetadata,
416
- });
409
+ const sessionId = await sessions.generateSessionId();
410
+ const metadata = await this.getResolvedClientMetadata();
417
411
 
418
- this.clients.set(sessionId, client);
412
+ const client = new MCPClient({
413
+ userId: this.userId,
414
+ sessionId,
415
+ serverId,
416
+ serverName: params.serverName,
417
+ serverUrl: params.serverUrl,
418
+ callbackUrl: params.callbackUrl,
419
+ transportType: params.transportType,
420
+ headers,
421
+ sessionStore: this.observedStore,
422
+ ...metadata,
423
+ });
419
424
 
420
- client.onConnectionEvent((event) => {
421
- this.emitConnectionEvent(event);
422
- });
425
+ this.cacheClient(sessionId, client);
426
+ return this.connectAndDiscover(client, sessionId, serverId);
427
+ }
423
428
 
424
- client.onObservabilityEvent((event) => {
425
- this.sendEvent(event);
426
- });
429
+ /**
430
+ * Reconnects to an MCP server by tearing down the active transport if one
431
+ * exists and instantiating a fresh connection, reusing the existing session
432
+ * (and its stored credentials / DCR client info) from the database.
433
+ */
434
+ private async reconnect(params: ReconnectParams): Promise<ConnectResult> {
435
+ const headers = normalizeHeaders(params.headers);
436
+ const serverId = this.normalizeServerId(params.serverId);
427
437
 
428
- await client.connect();
429
- await this.listPolicyFilteredTools(sessionId);
438
+ const existing = await this.findExistingSession(serverId, params.serverUrl);
439
+ const sessionId = existing ? existing.sessionId : await sessions.generateSessionId();
430
440
 
431
- return { sessionId, success: true };
432
- } catch (error) {
433
- if (error instanceof UnauthorizedError) {
434
- this.clients.delete(sessionId);
435
- return { sessionId, success: true };
441
+ if (existing) {
442
+ const staleClient = this.clients.get(existing.sessionId);
443
+ if (staleClient) {
444
+ await staleClient.disconnect();
445
+ this.clients.delete(existing.sessionId);
436
446
  }
447
+ }
437
448
 
438
- this.emitConnectionEvent({
439
- type: 'error',
440
- sessionId,
441
- serverId,
442
- error: error instanceof Error ? error.message : 'Connection failed',
443
- errorType: 'connection',
444
- timestamp: Date.now(),
445
- });
449
+ const metadata = await this.getResolvedClientMetadata();
450
+ const client = new MCPClient({
451
+ userId: this.userId,
452
+ sessionId,
453
+ serverId,
454
+ serverName: params.serverName,
455
+ serverUrl: params.serverUrl,
456
+ callbackUrl: params.callbackUrl,
457
+ transportType: params.transportType,
458
+ headers,
459
+ sessionStore: this.observedStore,
460
+ ...metadata,
461
+ });
446
462
 
447
- this.clients.delete(sessionId);
448
- throw error;
449
- }
463
+ this.cacheClient(sessionId, client);
464
+ return this.connectAndDiscover(client, sessionId, serverId);
450
465
  }
451
466
 
452
467
  /**
453
- * Disconnect from an MCP server
468
+ * Disconnects from an MCP server.
469
+ *
470
+ * If an active in-memory transport exists it delegates to `clearSession()`
471
+ * (which sends the server an HTTP DELETE per the Streamable spec). If no
472
+ * active transport is available (orphaned session from a failed OAuth flow),
473
+ * the session row is removed directly from storage.
454
474
  */
455
475
  private async disconnect(params: DisconnectParams): Promise<DisconnectResult> {
456
- const { sessionId } = params;
457
- const client = this.clients.get(sessionId);
458
-
476
+ const client = this.clients.get(params.sessionId);
459
477
  if (client) {
460
- // clearSession() handles DELETE + local cleanup internally.
461
478
  await client.clearSession();
462
- this.clients.delete(sessionId);
479
+ this.clients.delete(params.sessionId);
463
480
  } else {
464
- // Handle orphaned sessions (e.g., OAuth flow failed before client was stored)
465
- // Directly remove from storage since there's no active client
466
- await sessions.delete(this.userId, sessionId);
481
+ await sessions.delete(this.userId, params.sessionId);
467
482
  }
468
-
469
483
  return { success: true };
470
484
  }
471
485
 
486
+ // -----------------------------------------------------------------------
487
+ // OAuth
488
+ // -----------------------------------------------------------------------
489
+
472
490
  /**
473
- * Get an existing client or create and connect a new one for the session.
491
+ * Completes the OAuth 2.1 authorization code flow for a pending session.
492
+ *
493
+ * Loads the stored session (with credentials), creates a fresh `MCPClient`,
494
+ * and calls `finishAuth` inside a {@link runWithCodeVerifierState} context
495
+ * so the PKCE code verifier is available without a DB read.
496
+ *
497
+ * The session's stored `transportType` is intentionally **omitted** from the
498
+ * client config — this lets `MCPClient` auto-negotiate (try Streamable HTTP
499
+ * first, fall back to SSE), which is required for servers like Neon that
500
+ * only support SSE transport.
474
501
  */
475
- private async getOrCreateClient(sessionId: string): Promise<MCPClient> {
476
- const existing = this.clients.get(sessionId);
477
- if (existing) {
478
- return existing;
479
- }
502
+ private async finishAuth(params: FinishAuthParams): Promise<FinishAuthResult> {
503
+ const parsed = parseOAuthState(params.state);
504
+ const sessionId = parsed?.sessionId ?? params.state;
505
+ const session = await this.requireSession(sessionId);
480
506
 
481
- const session = await sessions.get(this.userId, sessionId);
482
- if (!session) {
483
- throw new Error('Session not found');
484
- }
507
+ try {
508
+ const clientId = session.clientId ?? undefined;
509
+ const clientSecret = (session.clientInformation as any)?.client_secret ?? undefined;
485
510
 
486
- const client = new MCPClient({
487
- userId: this.userId,
488
- sessionId,
489
- // These fields are optional in MCPClient, but when rehydrating a known
490
- // stored session on the server we pass them explicitly to preserve the
491
- // original transport/connection metadata instead of relying on lazy
492
- // reloading during initialize().
493
- serverId: session.serverId,
494
- serverName: session.serverName,
495
- serverUrl: session.serverUrl,
496
- callbackUrl: session.callbackUrl,
497
- transportType: session.transportType,
498
- headers: session.headers,
499
- });
511
+ const client = new MCPClient({
512
+ userId: this.userId,
513
+ sessionId,
514
+ serverId: session.serverId,
515
+ serverName: session.serverName,
516
+ serverUrl: session.serverUrl,
517
+ callbackUrl: session.callbackUrl,
518
+ headers: session.headers,
519
+ clientId,
520
+ clientSecret,
521
+ hasSession: true,
522
+ cachedCredentials: { tokens: session.tokens ?? undefined },
523
+ sessionStore: this.observedStore,
524
+ });
500
525
 
501
- // Subscribe to events before connecting
502
- client.onConnectionEvent((event) => this.emitConnectionEvent(event));
503
- client.onObservabilityEvent((event) => this.sendEvent(event));
526
+ this.attachClientEvents(client);
504
527
 
505
- await client.connect();
506
- this.clients.set(sessionId, client);
528
+ await runWithCodeVerifierState(session.codeVerifier ?? '', 'S256', () =>
529
+ client.finishAuth(params.code, params.state),
530
+ );
507
531
 
508
- return client;
532
+ this.clients.set(sessionId, client);
533
+
534
+ const { result } = await this.listPolicyFilteredTools(sessionId);
535
+ return { success: true, toolCount: result.tools.length };
536
+ } catch (error) {
537
+ this.sendEvent(connectionErrorEvent(sessionId, session.serverId, error, 'auth'));
538
+ throw error;
539
+ }
509
540
  }
510
541
 
542
+ // -----------------------------------------------------------------------
543
+ // Tool Discovery & Policy
544
+ // -----------------------------------------------------------------------
545
+
511
546
  /**
512
- * Fetches all tools from the remote MCP server and emits a `tools_discovered` event.
547
+ * Fetches the complete tool list from the remote MCP server and emits a
548
+ * `tools_discovered` SSE event with two lists:
513
549
  *
514
- * Two lists are always published together:
515
- * - `tools` policy-filtered list that agents are allowed to call.
516
- * - `allTools` — the complete, unfiltered list used by the management UI so
517
- * that blocked tools still appear as checkboxes in the dialog.
550
+ * - `tools` — policy-filtered (what agents are allowed to call)
551
+ * - `allTools` complete, unfiltered (used by the management UI)
518
552
  *
519
- * `fetchTools()` is called first (populates the in-memory cache), then
520
- * `gateway.listTools()` re-uses that cache internally — so only one remote
521
- * network round-trip is made regardless of how many callers follow.
553
+ * Only a single network round-trip is made: `fetchTools()` populates the
554
+ * in-memory cache, then `gateway.listTools()` reuses that cache.
522
555
  *
523
- * @param sessionId - The session whose tools should be discovered.
524
- * @returns The session record and the policy-filtered tool list.
525
- * @throws {Error} When the session does not exist in the store.
556
+ * @param sessionId - The session to discover tools for.
557
+ * @returns The session record and the filtered tool result.
558
+ * @throws {Error} If the session does not exist in storage.
526
559
  */
527
- private async listPolicyFilteredTools(sessionId: string): Promise<{ session: Session; result: ListToolsRpcResult }> {
528
- const session = await sessions.get(this.userId, sessionId);
529
- if (!session) {
530
- throw new Error('Session not found');
531
- }
532
-
533
- const client = await this.getOrCreateClient(sessionId);
534
- const allTools = await client.fetchTools().catch(() => ({ tools: [] }));
535
- const gateway = createToolPolicyGateway(this.userId, sessionId, client);
536
- const result = await gateway.listTools();
537
-
538
- this.emitConnectionEvent({
539
- type: 'tools_discovered',
560
+ private async listPolicyFilteredTools(
561
+ sessionId: string,
562
+ ): Promise<{ session: Session; result: ListToolsRpcResult }> {
563
+ const session = await this.requireSession(sessionId);
564
+ const client = await this.getOrCreateClient(sessionId);
565
+
566
+ const allTools = await client.fetchTools().catch(() => ({ tools: [] as any[] }));
567
+ const gateway = createToolPolicyGateway(this.userId, sessionId, client);
568
+ const result = await gateway.listTools();
569
+
570
+ this.sendEvent({
571
+ type: 'tools_discovered',
540
572
  sessionId,
541
- serverId: session.serverId ?? 'unknown',
542
- toolCount: result.tools.length,
543
- tools: result.tools,
544
- allTools: allTools.tools,
545
- timestamp: Date.now(),
573
+ serverId: session.serverId ?? 'unknown',
574
+ toolCount: result.tools.length,
575
+ tools: result.tools,
576
+ allTools: allTools.tools,
577
+ timestamp: Date.now(),
546
578
  });
547
579
 
548
580
  return { session, result };
549
581
  }
550
582
 
551
583
  /**
552
- * Returns the policy-filtered tool list for a session (agent-facing).
553
- * Internally re-uses `listPolicyFilteredTools` which also emits a
554
- * `tools_discovered` SSE event to keep client state up to date.
584
+ * Returns the policy-filtered tool list for a session.
585
+ *
586
+ * Delegates to {@link listPolicyFilteredTools}, which also emits a
587
+ * `tools_discovered` SSE event to keep the client state current.
555
588
  */
556
589
  private async listTools(params: SessionParams): Promise<ListToolsRpcResult> {
557
- const { sessionId } = params;
558
- const { result } = await this.listPolicyFilteredTools(sessionId);
590
+ const { result } = await this.listPolicyFilteredTools(params.sessionId);
559
591
  return { tools: result.tools };
560
592
  }
561
593
 
562
594
  /**
563
- * Get all raw tools with effective access state for management UI.
595
+ * Returns all raw tools annotated with their effective policy state
596
+ * for display in the management UI.
564
597
  */
565
- private async getToolPolicy(params: GetToolPolicyParams): Promise<GetToolPolicyResult> {
566
- const { sessionId } = params;
567
- const session = await sessions.get(this.userId, sessionId);
568
- if (!session) {
569
- throw new Error('Session not found');
570
- }
571
-
572
- const client = await this.getOrCreateClient(sessionId);
598
+ private async getToolPolicy(
599
+ params: GetToolPolicyParams,
600
+ ): Promise<GetToolPolicyResult> {
601
+ const session = await this.requireSession(params.sessionId);
602
+ const client = await this.getOrCreateClient(params.sessionId);
573
603
  const allTools = await client.fetchTools();
574
- const toolPolicy = session.toolPolicy ?? {
604
+
605
+ const policy = session.toolPolicy ?? {
575
606
  mode: 'all' as const,
576
607
  toolIds: [],
577
608
  updatedAt: session.updatedAt ?? session.createdAt,
578
609
  };
610
+
579
611
  const serverId = session.serverId ?? 'unknown';
580
- const tools = allTools.tools.map((tool) => ({
581
- ...tool,
582
- toolId: createToolId(serverId, tool.name),
583
- allowed: isToolAllowed(toolPolicy, tool.name, session.serverId),
612
+ const tools = allTools.tools.map((t) => ({
613
+ ...t,
614
+ toolId: createToolId(serverId, t.name),
615
+ allowed: isToolAllowed(policy, t.name, session.serverId),
584
616
  }));
585
617
 
586
618
  return {
587
- toolPolicy,
619
+ toolPolicy: policy,
588
620
  tools,
589
- toolCount: tools.length,
590
- allowedToolCount: tools.filter((tool) => tool.allowed).length,
621
+ toolCount: tools.length,
622
+ allowedToolCount: tools.filter((t) => t.allowed).length,
591
623
  };
592
624
  }
593
625
 
594
626
  /**
595
- * Persists a new tool access policy for a session and broadcasts the updated
596
- * filtered tool list to all connected browser clients via a `tools_discovered` event.
627
+ * Persists a new tool access policy and broadcasts the updated filtered
628
+ * tool list via a `tools_discovered` SSE event.
597
629
  *
598
- * Both `tools` (policy-filtered) and `allTools` (complete list) are emitted
599
- * so the management UI can immediately reflect the new checkbox states without
600
- * an additional round-trip to the server.
601
- *
602
- * @param params - Session ID and the new `{ mode, toolIds }` policy to apply.
603
- * @throws {Error} When the session does not exist or the policy references unknown tool IDs.
630
+ * @throws {Error} If the session does not exist or the policy references
631
+ * tool IDs that don't match any known tool.
604
632
  */
605
- private async setToolPolicy(params: SetToolPolicyParams): Promise<SetToolPolicyResult> {
606
- const { sessionId } = params;
607
- const session = await sessions.get(this.userId, sessionId);
608
- if (!session) {
609
- throw new Error('Session not found');
610
- }
611
-
612
- const client = await this.getOrCreateClient(sessionId);
633
+ private async setToolPolicy(
634
+ params: SetToolPolicyParams,
635
+ ): Promise<SetToolPolicyResult> {
636
+ const session = await this.requireSession(params.sessionId);
637
+ const client = await this.getOrCreateClient(params.sessionId);
613
638
  const allTools = await client.fetchTools();
614
- const toolPolicy = normalizeToolPolicyForUpdate(params.toolPolicy);
615
- validateToolPolicyAgainstTools(toolPolicy, allTools.tools, session.serverId);
616
-
617
- await sessions.update(this.userId, sessionId, { toolPolicy });
618
639
 
619
- const filteredTools = createToolPolicyGateway(this.userId, sessionId, client).filterTools({ ...session, toolPolicy }, allTools.tools);
620
- this.emitConnectionEvent({
621
- type: 'tools_discovered',
622
- sessionId,
623
- serverId: session.serverId ?? 'unknown',
624
- toolCount: filteredTools.length,
625
- tools: filteredTools,
626
- allTools: allTools.tools,
627
- timestamp: Date.now(),
640
+ const policy = normalizeToolPolicyForUpdate(params.toolPolicy);
641
+ validateToolPolicyAgainstTools(policy, allTools.tools, session.serverId);
642
+ await sessions.update(this.userId, params.sessionId, { toolPolicy: policy });
643
+
644
+ const filtered = createToolPolicyGateway(
645
+ this.userId, params.sessionId, client,
646
+ ).filterTools({ ...session, toolPolicy: policy }, allTools.tools);
647
+
648
+ this.sendEvent({
649
+ type: 'tools_discovered',
650
+ sessionId: params.sessionId,
651
+ serverId: session.serverId ?? 'unknown',
652
+ toolCount: filtered.length,
653
+ tools: filtered,
654
+ allTools: allTools.tools,
655
+ timestamp: Date.now(),
628
656
  });
629
657
 
630
- return {
631
- success: true,
632
- toolPolicy,
633
- tools: filteredTools,
634
- toolCount: filteredTools.length,
635
- };
658
+ return { success: true, toolPolicy: policy, tools: filtered, toolCount: filtered.length };
636
659
  }
637
660
 
638
661
  /**
639
- * Proxies a tool invocation to the remote MCP server.
640
- * Resolves the client for the given session and delegates to the tool router.
662
+ * Enables or disables a session for agent tool discovery.
663
+ *
664
+ * Disabled sessions retain their OAuth tokens and connection metadata
665
+ * but are hidden from `MultiSessionClient.connect()` and blocked from
666
+ * RPC tool access. Re-enabling does not require re-authentication.
667
+ *
668
+ * @param params - `{ sessionId, enabled: boolean }`
669
+ * @returns `{ success: true }`
670
+ * @throws {Error} If the session does not exist.
641
671
  */
642
- private async callTool(params: CallToolParams): Promise<CallToolResult> {
643
- const { sessionId, toolName, toolArgs } = params;
644
- const client = await this.getOrCreateClient(sessionId);
645
- const result = await createToolPolicyGateway(this.userId, sessionId, client).callTool(toolName, toolArgs);
646
-
647
- // Inject sessionId into meta so client knows who handled it
648
- // This allows AppHost to auto-launch without scanning all sessions
649
- const meta = result._meta || {};
650
-
651
- return {
652
- ...result,
653
- _meta: {
654
- ...meta,
655
- sessionId,
656
- }
657
- };
672
+ private async updateSession(params: UpdateSessionParams): Promise<UpdateSessionResult> {
673
+ await this.requireSession(params.sessionId);
674
+ await sessions.update(this.userId, params.sessionId, { enabled: params.enabled });
675
+ return { success: true };
658
676
  }
659
677
 
678
+ // -----------------------------------------------------------------------
679
+ // Tool Execution
680
+ // -----------------------------------------------------------------------
681
+
660
682
  /**
661
- * Restore and validate an existing session
683
+ * Proxies a tool invocation to the remote MCP server.
684
+ *
685
+ * Resolves (or creates) the transport for the given session, runs the call
686
+ * through the tool-policy gateway, and injects `sessionId` into the result
687
+ * metadata so the client can route the response without scanning all sessions.
662
688
  */
663
- private async getSession(params: SessionParams): Promise<GetSessionResult> {
664
- const { sessionId } = params;
689
+ private async callTool(params: CallToolParams): Promise<CallToolResult> {
690
+ const client = await this.getOrCreateClient(params.sessionId);
691
+ const result = await createToolPolicyGateway(
692
+ this.userId, params.sessionId, client,
693
+ ).callTool(params.toolName, params.toolArgs);
665
694
 
666
- const session = await sessions.get(this.userId, sessionId);
667
- if (!session) {
668
- throw new Error('Session not found');
669
- }
695
+ return { ...result, _meta: { ...(result._meta ?? {}), sessionId: params.sessionId } };
696
+ }
670
697
 
671
- this.emitConnectionEvent({
672
- type: 'state_changed',
673
- sessionId,
674
- serverId: session.serverId ?? 'unknown',
675
- serverName: session.serverName ?? 'Unknown',
676
- serverUrl: session.serverUrl,
677
- state: 'VALIDATING',
678
- previousState: 'DISCONNECTED',
679
- timestamp: Date.now(),
680
- });
698
+ // -----------------------------------------------------------------------
699
+ // Prompts
700
+ // -----------------------------------------------------------------------
681
701
 
682
- try {
683
- const clientMetadata = await this.getResolvedClientMetadata();
702
+ /** Lists all prompts exposed by the remote MCP server. */
703
+ private async listPrompts(params: SessionParams): Promise<ListPromptsResult> {
704
+ const client = await this.getOrCreateClient(params.sessionId);
705
+ const result = await client.listPrompts();
706
+ return { prompts: result.prompts };
707
+ }
684
708
 
685
- const client = new MCPClient({
686
- userId: this.userId,
687
- sessionId,
688
- // These fields are optional in MCPClient, but when rehydrating a known
689
- // stored session on the server we pass them explicitly to preserve the
690
- // original transport/connection metadata instead of relying on lazy
691
- // reloading during initialize().
692
- serverId: session.serverId,
693
- serverName: session.serverName,
694
- serverUrl: session.serverUrl,
695
- callbackUrl: session.callbackUrl,
696
- transportType: session.transportType,
697
- headers: session.headers,
698
- ...clientMetadata,
699
- });
709
+ /** Retrieves a specific prompt by name with optional arguments. */
710
+ private async getPrompt(params: GetPromptParams): Promise<unknown> {
711
+ const client = await this.getOrCreateClient(params.sessionId);
712
+ return client.getPrompt(params.name, params.args);
713
+ }
700
714
 
701
- client.onConnectionEvent((event) => this.emitConnectionEvent(event));
702
- client.onObservabilityEvent((event) => this.sendEvent(event));
715
+ // -----------------------------------------------------------------------
716
+ // Resources
717
+ // -----------------------------------------------------------------------
703
718
 
704
- await client.connect();
705
- this.clients.set(sessionId, client);
719
+ /** Lists all resources exposed by the remote MCP server. */
720
+ private async listResources(params: SessionParams): Promise<ListResourcesResult> {
721
+ const client = await this.getOrCreateClient(params.sessionId);
722
+ const result = await client.listResources();
723
+ return { resources: result.resources };
724
+ }
706
725
 
707
- const { result: tools } = await this.listPolicyFilteredTools(sessionId);
726
+ /** Reads a specific resource identified by URI. */
727
+ private async readResource(params: ReadResourceParams): Promise<unknown> {
728
+ const client = await this.getOrCreateClient(params.sessionId);
729
+ return client.readResource(params.uri);
730
+ }
708
731
 
709
- return { success: true, toolCount: tools.tools.length };
710
- } catch (error) {
711
- this.emitConnectionEvent({
712
- type: 'error',
713
- sessionId,
714
- serverId: session.serverId ?? 'unknown',
715
- error: error instanceof Error ? error.message : 'Validation failed',
716
- errorType: 'validation',
717
- timestamp: Date.now(),
718
- });
732
+ // -----------------------------------------------------------------------
733
+ // Internal Helpers
734
+ // -----------------------------------------------------------------------
719
735
 
720
- throw error;
721
- }
722
- }
736
+ /** Resolves client metadata: `getClientMetadata()` → `clientDefaults` → `{}`. */
737
+ private async getResolvedClientMetadata(request?: unknown): Promise<ClientMetadata> {
738
+ let metadata: ClientMetadata = this.options.clientDefaults
739
+ ? { ...this.options.clientDefaults }
740
+ : {};
723
741
 
724
- /**
725
- * Complete OAuth authorization flow
726
- */
727
- private async finishAuth(params: FinishAuthParams): Promise<FinishAuthResult> {
728
- const { code } = params;
729
- const oauthState = params.state;
730
- const parsedState = parseOAuthState(oauthState);
731
- const sessionId = parsedState?.sessionId || oauthState;
732
-
733
- const session = await sessions.get(this.userId, sessionId);
734
- if (!session) {
735
- throw new Error('Session not found');
742
+ if (this.options.getClientMetadata) {
743
+ metadata = { ...metadata, ...(await this.options.getClientMetadata(request)) };
736
744
  }
745
+ return metadata;
746
+ }
737
747
 
738
- try {
739
- const client = new MCPClient({
740
- userId: this.userId,
741
- sessionId,
742
- // These fields are optional in MCPClient, but when rehydrating a known
743
- // stored session on the server we pass them explicitly to preserve the
744
- // original connection metadata instead of relying on lazy
745
- // reloading during initialize().
746
- serverId: session.serverId,
747
- serverName: session.serverName,
748
- serverUrl: session.serverUrl,
749
- callbackUrl: session.callbackUrl,
750
- // NOTE: transportType is intentionally omitted here.
751
- // The session's stored transportType is a placeholder ('streamable-http')
752
- // set before transport negotiation. Omitting it lets MCPClient auto-negotiate
753
- // (try streamable_http → SSE fallback), which is critical for servers like
754
- // Neon that only support SSE transport.
755
- headers: session.headers,
756
- });
748
+ /** Ensures the given session exists in storage and throws otherwise. */
749
+ private async requireSession(sessionId: string): Promise<Session> {
750
+ const session = await sessions.get(this.userId, sessionId, { includeCredentials: true });
751
+ if (!session) throw new Error(`Session ${sessionId} not found`);
752
+ return session;
753
+ }
757
754
 
758
- client.onConnectionEvent((event) => this.emitConnectionEvent(event));
755
+ /** Finds an existing session matching the given serverId or serverUrl. */
756
+ private async findExistingSession(
757
+ serverId: string,
758
+ serverUrl: string,
759
+ ): Promise<Session | undefined> {
760
+ const all = await sessions.list(this.userId);
761
+ return all.find((s) => s.serverId === serverId || s.serverUrl === serverUrl);
762
+ }
759
763
 
760
- await client.finishAuth(code, oauthState);
761
- this.clients.set(sessionId, client);
764
+ /** Normalizes a serverId to max 12 chars (DeepSeek/OpenAI 64-char tool-name limit). */
765
+ private normalizeServerId(raw?: string): string {
766
+ return raw && raw.length <= 12 ? raw : generateServerId();
767
+ }
762
768
 
763
- const { result: tools } = await this.listPolicyFilteredTools(sessionId);
769
+ /**
770
+ * Returns the cached in-memory transport for `sessionId`, or creates one
771
+ * from the persisted session row (with credentials) and connects it.
772
+ */
773
+ private async getOrCreateClient(sessionId: string): Promise<MCPClient> {
774
+ const existing = this.clients.get(sessionId);
775
+ if (existing) return existing;
764
776
 
765
- return { success: true, toolCount: tools.tools.length };
766
- } catch (error) {
767
- this.emitConnectionEvent({
768
- type: 'error',
769
- sessionId,
770
- serverId: session.serverId ?? 'unknown',
771
- error: error instanceof Error ? error.message : 'OAuth completion failed',
772
- errorType: 'auth',
773
- timestamp: Date.now(),
774
- });
777
+ const session = await this.requireSession(sessionId);
775
778
 
776
- throw error;
779
+ if (session.enabled === false) {
780
+ throw new Error('Session is disabled — re-enable it via updateSession to access tools');
777
781
  }
778
- }
782
+ const client = this.restoreClient(session);
779
783
 
780
- /**
781
- * List prompts from a session
782
- */
783
- private async listPrompts(params: SessionParams): Promise<ListPromptsResult> {
784
- const { sessionId } = params;
785
- const client = await this.getOrCreateClient(sessionId);
786
- const result = await client.listPrompts();
787
- return { prompts: result.prompts };
788
- }
784
+ this.attachClientEvents(client);
789
785
 
790
- /**
791
- * Get a specific prompt
792
- */
793
- private async getPrompt(params: GetPromptParams): Promise<unknown> {
794
- const { sessionId, name, args } = params;
795
- const client = await this.getOrCreateClient(sessionId);
796
- return await client.getPrompt(name, args);
786
+ await client.connect();
787
+ this.clients.set(sessionId, client);
788
+ return client;
797
789
  }
798
790
 
799
791
  /**
800
- * List resources from a session
792
+ * Builds an `MCPClient` from a stored session row.
793
+ *
794
+ * Extracts `clientId` and `clientSecret` from the session's credential
795
+ * fields and passes `hasSession: true` + `cachedCredentials` so the client
796
+ * can skip redundant existence checks and credential reads.
801
797
  */
802
- private async listResources(params: SessionParams): Promise<ListResourcesResult> {
803
- const { sessionId } = params;
804
- const client = await this.getOrCreateClient(sessionId);
805
- const result = await client.listResources();
806
- return { resources: result.resources };
798
+ private restoreClient(session: Session): MCPClient {
799
+ const clientId = session.clientId ?? undefined;
800
+ const clientSecret = (session.clientInformation as any)?.client_secret ?? undefined;
801
+
802
+ return new MCPClient({
803
+ userId: this.userId,
804
+ sessionId: session.sessionId,
805
+ serverId: session.serverId,
806
+ serverName: session.serverName,
807
+ serverUrl: session.serverUrl,
808
+ callbackUrl: session.callbackUrl,
809
+ transportType: session.transportType,
810
+ headers: session.headers,
811
+ clientId,
812
+ clientSecret,
813
+ hasSession: true,
814
+ cachedCredentials: { tokens: session.tokens ?? undefined },
815
+ sessionStore: this.observedStore,
816
+ });
807
817
  }
808
818
 
809
819
  /**
810
- * Read a specific resource
820
+ * Wires connection and observability events from the client into the
821
+ * unified observability channel. Connection events go to `sendEvent`
822
+ * (SSE stream); observability events go to `emitObs` (user callback
823
+ * + SSE stream).
811
824
  */
812
- private async readResource(params: ReadResourceParams): Promise<unknown> {
813
- const { sessionId, uri } = params;
814
- const client = await this.getOrCreateClient(sessionId);
815
- return client.readResource(uri);
825
+ private attachClientEvents(client: MCPClient): void {
826
+ client.onConnectionEvent((e) => this.sendEvent(e));
827
+ client.onObservabilityEvent((e) => this.sendEvent(e));
816
828
  }
817
829
 
818
830
  /**
819
- * Emit connection event
831
+ * Registers the client in the in-memory cache and attaches its event
832
+ * listeners to the unified observability channel.
820
833
  */
821
- private emitConnectionEvent(event: McpConnectionEvent): void {
822
- this.sendEvent(event);
834
+ private cacheClient(sessionId: string, client: MCPClient): void {
835
+ this.attachClientEvents(client);
836
+ this.clients.set(sessionId, client);
823
837
  }
824
838
 
825
839
  /**
826
- * Cleanup and close all connections
840
+ * Attempts a `client.connect()` and, on success, discovers tools.
841
+ *
842
+ * If the server responds with `UnauthorizedError`, the session is
843
+ * treated as pending OAuth — a successful result is returned with
844
+ * the sessionId so the browser client can redirect to the auth URL.
845
+ *
846
+ * For any other error an `error` connection event is emitted and the
847
+ * client is removed from the in-memory cache before re-throwing.
827
848
  */
828
- async dispose(): Promise<void> {
829
- this.isActive = false;
849
+ private async connectAndDiscover(
850
+ client: MCPClient,
851
+ sessionId: string,
852
+ serverId: string,
853
+ ): Promise<ConnectResult> {
854
+ try {
855
+ await client.connect();
856
+ await this.listPolicyFilteredTools(sessionId);
857
+ return { sessionId, success: true };
858
+ } catch (error) {
859
+ if (error instanceof UnauthorizedError) {
860
+ this.clients.delete(sessionId);
861
+ return { sessionId, success: true };
862
+ }
830
863
 
831
- if (this.heartbeatTimer) {
832
- clearInterval(this.heartbeatTimer);
864
+ this.sendEvent(connectionErrorEvent(sessionId, serverId, error, 'connection'));
865
+ this.clients.delete(sessionId);
866
+ throw error;
833
867
  }
868
+ }
834
869
 
835
- // Send HTTP DELETE to each Streamable HTTP server before closing, per spec.
836
- // Run in parallel so shutdown is not serialised across many sessions.
837
- await Promise.all(
838
- Array.from(this.clients.values()).map((client) => client.disconnect())
839
- );
840
-
841
- this.clients.clear();
870
+ /** Starts the periodic heartbeat timer. */
871
+ private startHeartbeat(): void {
872
+ const ms = this.options.heartbeatInterval ?? DEFAULT_HEARTBEAT_MS;
873
+ this.heartbeatTimer = setInterval(() => {
874
+ if (this.active) {
875
+ this.sendEvent({ level: 'debug', message: 'heartbeat', timestamp: Date.now() });
876
+ }
877
+ }, ms);
842
878
  }
843
879
  }
844
880
 
845
- // ============================================
881
+ // ---------------------------------------------------------------------------
846
882
  // SSE Handler Factory
847
- // ============================================
883
+ // ---------------------------------------------------------------------------
848
884
 
849
885
  /**
850
- * Create an SSE endpoint handler compatible with Node.js HTTP frameworks.
851
- * Handles both SSE streaming (GET) and RPC requests (POST).
886
+ * Creates a Node.js-compatible HTTP handler that serves an SSE stream (GET)
887
+ * and accepts RPC calls (POST) for a single browser client.
888
+ *
889
+ * @example
890
+ * ```ts
891
+ * import { createSSEHandler } from '@mcp-ts/sdk/server';
892
+ *
893
+ * const handler = createSSEHandler({ userId: 'user-123' });
894
+ * // Mount `handler` on both GET and POST for your HTTP framework.
895
+ * ```
896
+ *
897
+ * @param options - Handler configuration (userId, optional auth check, metadata).
898
+ * @returns An async function `(req, res) => void` suitable as an HTTP handler.
852
899
  */
853
900
  export function createSSEHandler(options: SSEHandlerOptions) {
854
- return async (req: { method?: string; on: Function }, res: { writeHead: Function; write: Function }) => {
855
- // Set SSE headers
901
+ return async (
902
+ req: { method?: string; on: (event: string, cb: (...args: any[]) => void) => void },
903
+ res: { writeHead: (status: number, headers: Record<string, string>) => void; write: (chunk: string) => void },
904
+ ) => {
856
905
  res.writeHead(200, {
857
- 'Content-Type': 'text/event-stream',
858
- 'Cache-Control': 'no-cache',
859
- 'Connection': 'keep-alive',
906
+ 'Content-Type': 'text/event-stream',
907
+ 'Cache-Control': 'no-cache',
908
+ 'Connection': 'keep-alive',
860
909
  'Access-Control-Allow-Origin': '*',
861
910
  });
862
911
 
863
- // Send initial connection acknowledgment
864
912
  writeSSEEvent(res, 'connected', { timestamp: Date.now() });
865
913
 
866
- // Create connection manager with event routing
867
914
  const manager = new SSEConnectionManager(options, (event) => {
868
915
  if (isRpcResponseEvent(event)) {
869
916
  writeSSEEvent(res, 'rpc-response', event);
@@ -874,41 +921,36 @@ export function createSSEHandler(options: SSEHandlerOptions) {
874
921
  }
875
922
  });
876
923
 
877
- // Cleanup on client disconnect
878
924
  req.on('close', () => manager.dispose());
879
925
 
880
- // Handle RPC requests via POST
881
926
  if (req.method === 'POST') {
882
927
  let body = '';
883
- req.on('data', (chunk: Buffer) => {
884
- body += chunk.toString();
885
- });
928
+ req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
886
929
  req.on('end', async () => {
887
930
  try {
888
- const request: McpRpcRequest = JSON.parse(body);
889
- await manager.handleRequest(request);
931
+ await manager.handleRequest(JSON.parse(body) as McpRpcRequest);
890
932
  } catch {
891
- // Request parsing/handling errors are sent via SSE error events
933
+ // Parsing / handling errors surface through SSE error events
892
934
  }
893
935
  });
894
936
  }
895
937
  };
896
938
  }
897
939
 
898
- // ============================================
899
- // Utilities
900
- // ============================================
940
+ // ---------------------------------------------------------------------------
941
+ // SSE Utilities
942
+ // ---------------------------------------------------------------------------
901
943
 
902
944
  /**
903
- * Write an SSE event to the response stream
945
+ * Writes a single SSE event frame onto the response stream.
946
+ *
947
+ * Format: `event: <type>\ndata: <json>\n\n`
904
948
  */
905
- function writeSSEEvent(res: { write: Function }, event: string, data: unknown): void {
949
+ function writeSSEEvent(
950
+ res: { write: (chunk: string) => void },
951
+ event: string,
952
+ data: unknown,
953
+ ): void {
906
954
  res.write(`event: ${event}\n`);
907
955
  res.write(`data: ${JSON.stringify(data)}\n\n`);
908
956
  }
909
-
910
-
911
-
912
-
913
-
914
-