@mcp-ts/sdk 2.5.3 → 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-BRZO_JyI.d.mts → index-BrDkbut5.d.ts} +2 -1
  31. package/dist/{index-C3YGagi2.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 -876
  35. package/dist/index.js.map +1 -1
  36. package/dist/index.mjs +1048 -871
  37. package/dist/index.mjs.map +1 -1
  38. package/dist/{multi-session-client-CuacvZaQ.d.ts → multi-session-client-CQzymvHl.d.ts} +183 -227
  39. package/dist/{multi-session-client-DqzT8Oo7.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 +1053 -876
  43. package/dist/server/index.js.map +1 -1
  44. package/dist/server/index.mjs +1045 -871
  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-B0qpui-V.d.mts → tool-router-C1n7OXbl.d.mts} +1 -1
  51. package/dist/{tool-router-2qUyZ-cP.d.ts → tool-router-CBIawFnt.d.ts} +1 -1
  52. package/dist/{types-BkJ_rgzo.d.mts → types-DX71u9gR.d.mts} +11 -5
  53. package/dist/{types-BkJ_rgzo.d.ts → types-DX71u9gR.d.ts} +11 -5
  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 -633
  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 +148 -41
  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 -3
@@ -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,816 +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);
286
+
287
+ await Promise.all(
288
+ Array.from(this.clients.values()).map((c) => c.disconnect()),
289
+ );
239
290
 
240
- // Also send via SSE for backwards compatibility
241
- this.sendEvent(errorResponse);
291
+ this.clients.clear();
292
+ }
293
+
294
+ // -----------------------------------------------------------------------
295
+ // RPC Dispatch (raw — called by handleRequest which adds timing)
296
+ // -----------------------------------------------------------------------
242
297
 
243
- return errorResponse;
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, clientId, clientSecret } = 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();
306
-
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
- clientId,
318
- clientSecret,
319
- ...clientMetadata, // Spread client metadata (clientName, clientUri, logoUri, policyUri)
320
- });
321
-
322
- // Note: Session will be created by MCPClient after successful connection
323
- // This ensures sessions only exist for successful or OAuth-pending connections
324
-
325
- // Store client
326
- this.clients.set(sessionId, client);
364
+ const client = this.restoreClient(session);
365
+ this.attachClientEvents(client);
327
366
 
328
- // Subscribe to client events
329
- client.onConnectionEvent((event) => {
330
- this.emitConnectionEvent(event);
331
- });
332
-
333
- client.onObservabilityEvent((event) => {
334
- this.sendEvent(event);
335
- });
336
-
337
- // Attempt connection
338
367
  await client.connect();
368
+ this.clients.set(params.sessionId, client);
339
369
 
340
- // Fetch policy-filtered tools for agent-facing discovery
341
- await this.listPolicyFilteredTools(sessionId);
342
-
343
- return {
344
- sessionId,
345
- success: true,
346
- };
370
+ const { result } = await this.listPolicyFilteredTools(params.sessionId);
371
+ return { success: true, toolCount: result.tools.length };
347
372
  } catch (error) {
348
- if (error instanceof UnauthorizedError) {
349
- // OAuth-required is a pending-auth state, not a failed connection.
350
- this.clients.delete(sessionId);
351
- return {
352
- sessionId,
353
- success: true,
354
- };
355
- }
356
-
357
- this.emitConnectionEvent({
358
- type: 'error',
359
- sessionId,
360
- serverId,
361
- error: error instanceof Error ? error.message : 'Connection failed',
362
- errorType: 'connection',
363
- timestamp: Date.now(),
364
- });
365
-
366
- // Clean up client
367
- this.clients.delete(sessionId);
368
-
373
+ this.sendEvent(connectionErrorEvent(params.sessionId, session.serverId, error, 'validation'));
369
374
  throw error;
370
375
  }
371
376
  }
372
377
 
378
+ // -----------------------------------------------------------------------
379
+ // Connection Lifecycle
380
+ // -----------------------------------------------------------------------
381
+
373
382
  /**
374
- * Reconnect to an MCP server — tears down the active client transport/connection
375
- * 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).
376
391
  */
377
- private async reconnect(params: ReconnectParams): Promise<ConnectResult> {
378
- const { serverId: rawServerId, serverName, serverUrl, callbackUrl, transportType, clientId, clientSecret } = params;
379
- const headers = normalizeHeaders(params.headers);
380
-
381
- // Normalize serverId the same way connect() does
382
- const serverId = rawServerId && rawServerId.length <= 12
383
- ? rawServerId
384
- : generateServerId();
385
-
386
- // Find existing session for the same server to reuse its session ID
387
- const existingSessions = await sessions.list(this.userId);
388
- const duplicate = existingSessions.find(s =>
389
- s.serverId === serverId || s.serverUrl === serverUrl
390
- );
391
-
392
- // Reuse the duplicate sessionId if present, otherwise generate a new one
393
- 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);
394
395
 
395
- if (duplicate) {
396
- // Disconnect any active in-memory client transport without deleting the database session
397
- const existingClient = this.clients.get(duplicate.sessionId);
398
- if (existingClient) {
399
- await existingClient.disconnect();
400
- 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
+ }));
401
403
  }
404
+ throw new Error(
405
+ `Connection already exists for server: ${existing.serverUrl ?? existing.serverId} (${existing.serverName})`,
406
+ );
402
407
  }
403
408
 
404
- try {
405
- const clientMetadata = await this.getResolvedClientMetadata();
406
-
407
- // Create a new client instantiating the reused session ID (which preserves DCR credentials and tokens)
408
- const client = new MCPClient({
409
- userId: this.userId,
410
- sessionId,
411
- serverId,
412
- serverName,
413
- serverUrl,
414
- callbackUrl,
415
- transportType,
416
- headers,
417
- clientId,
418
- clientSecret,
419
- ...clientMetadata,
420
- });
409
+ const sessionId = await sessions.generateSessionId();
410
+ const metadata = await this.getResolvedClientMetadata();
421
411
 
422
- 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
+ });
423
424
 
424
- client.onConnectionEvent((event) => {
425
- this.emitConnectionEvent(event);
426
- });
425
+ this.cacheClient(sessionId, client);
426
+ return this.connectAndDiscover(client, sessionId, serverId);
427
+ }
427
428
 
428
- client.onObservabilityEvent((event) => {
429
- this.sendEvent(event);
430
- });
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);
431
437
 
432
- await client.connect();
433
- await this.listPolicyFilteredTools(sessionId);
438
+ const existing = await this.findExistingSession(serverId, params.serverUrl);
439
+ const sessionId = existing ? existing.sessionId : await sessions.generateSessionId();
434
440
 
435
- return { sessionId, success: true };
436
- } catch (error) {
437
- if (error instanceof UnauthorizedError) {
438
- this.clients.delete(sessionId);
439
- 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);
440
446
  }
447
+ }
441
448
 
442
- this.emitConnectionEvent({
443
- type: 'error',
444
- sessionId,
445
- serverId,
446
- error: error instanceof Error ? error.message : 'Connection failed',
447
- errorType: 'connection',
448
- timestamp: Date.now(),
449
- });
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
+ });
450
462
 
451
- this.clients.delete(sessionId);
452
- throw error;
453
- }
463
+ this.cacheClient(sessionId, client);
464
+ return this.connectAndDiscover(client, sessionId, serverId);
454
465
  }
455
466
 
456
467
  /**
457
- * 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.
458
474
  */
459
475
  private async disconnect(params: DisconnectParams): Promise<DisconnectResult> {
460
- const { sessionId } = params;
461
- const client = this.clients.get(sessionId);
462
-
476
+ const client = this.clients.get(params.sessionId);
463
477
  if (client) {
464
- // clearSession() handles DELETE + local cleanup internally.
465
478
  await client.clearSession();
466
- this.clients.delete(sessionId);
479
+ this.clients.delete(params.sessionId);
467
480
  } else {
468
- // Handle orphaned sessions (e.g., OAuth flow failed before client was stored)
469
- // Directly remove from storage since there's no active client
470
- await sessions.delete(this.userId, sessionId);
481
+ await sessions.delete(this.userId, params.sessionId);
471
482
  }
472
-
473
483
  return { success: true };
474
484
  }
475
485
 
486
+ // -----------------------------------------------------------------------
487
+ // OAuth
488
+ // -----------------------------------------------------------------------
489
+
476
490
  /**
477
- * 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.
478
501
  */
479
- private async getOrCreateClient(sessionId: string): Promise<MCPClient> {
480
- const existing = this.clients.get(sessionId);
481
- if (existing) {
482
- return existing;
483
- }
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);
484
506
 
485
- const session = await sessions.get(this.userId, sessionId);
486
- if (!session) {
487
- throw new Error('Session not found');
488
- }
507
+ try {
508
+ const clientId = session.clientId ?? undefined;
509
+ const clientSecret = (session.clientInformation as any)?.client_secret ?? undefined;
489
510
 
490
- const client = new MCPClient({
491
- userId: this.userId,
492
- sessionId,
493
- // These fields are optional in MCPClient, but when rehydrating a known
494
- // stored session on the server we pass them explicitly to preserve the
495
- // original transport/connection metadata instead of relying on lazy
496
- // reloading during initialize().
497
- serverId: session.serverId,
498
- serverName: session.serverName,
499
- serverUrl: session.serverUrl,
500
- callbackUrl: session.callbackUrl,
501
- transportType: session.transportType,
502
- headers: session.headers,
503
- });
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
+ });
504
525
 
505
- // Subscribe to events before connecting
506
- client.onConnectionEvent((event) => this.emitConnectionEvent(event));
507
- client.onObservabilityEvent((event) => this.sendEvent(event));
526
+ this.attachClientEvents(client);
508
527
 
509
- await client.connect();
510
- this.clients.set(sessionId, client);
528
+ await runWithCodeVerifierState(session.codeVerifier ?? '', 'S256', () =>
529
+ client.finishAuth(params.code, params.state),
530
+ );
511
531
 
512
- 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
+ }
513
540
  }
514
541
 
542
+ // -----------------------------------------------------------------------
543
+ // Tool Discovery & Policy
544
+ // -----------------------------------------------------------------------
545
+
515
546
  /**
516
- * 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:
517
549
  *
518
- * Two lists are always published together:
519
- * - `tools` policy-filtered list that agents are allowed to call.
520
- * - `allTools` — the complete, unfiltered list used by the management UI so
521
- * 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)
522
552
  *
523
- * `fetchTools()` is called first (populates the in-memory cache), then
524
- * `gateway.listTools()` re-uses that cache internally — so only one remote
525
- * 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.
526
555
  *
527
- * @param sessionId - The session whose tools should be discovered.
528
- * @returns The session record and the policy-filtered tool list.
529
- * @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.
530
559
  */
531
- private async listPolicyFilteredTools(sessionId: string): Promise<{ session: Session; result: ListToolsRpcResult }> {
532
- const session = await sessions.get(this.userId, sessionId);
533
- if (!session) {
534
- throw new Error('Session not found');
535
- }
536
-
537
- const client = await this.getOrCreateClient(sessionId);
538
- const allTools = await client.fetchTools().catch(() => ({ tools: [] }));
539
- const gateway = createToolPolicyGateway(this.userId, sessionId, client);
540
- const result = await gateway.listTools();
541
-
542
- this.emitConnectionEvent({
543
- 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',
544
572
  sessionId,
545
- serverId: session.serverId ?? 'unknown',
546
- toolCount: result.tools.length,
547
- tools: result.tools,
548
- allTools: allTools.tools,
549
- 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(),
550
578
  });
551
579
 
552
580
  return { session, result };
553
581
  }
554
582
 
555
583
  /**
556
- * Returns the policy-filtered tool list for a session (agent-facing).
557
- * Internally re-uses `listPolicyFilteredTools` which also emits a
558
- * `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.
559
588
  */
560
589
  private async listTools(params: SessionParams): Promise<ListToolsRpcResult> {
561
- const { sessionId } = params;
562
- const { result } = await this.listPolicyFilteredTools(sessionId);
590
+ const { result } = await this.listPolicyFilteredTools(params.sessionId);
563
591
  return { tools: result.tools };
564
592
  }
565
593
 
566
594
  /**
567
- * 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.
568
597
  */
569
- private async getToolPolicy(params: GetToolPolicyParams): Promise<GetToolPolicyResult> {
570
- const { sessionId } = params;
571
- const session = await sessions.get(this.userId, sessionId);
572
- if (!session) {
573
- throw new Error('Session not found');
574
- }
575
-
576
- 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);
577
603
  const allTools = await client.fetchTools();
578
- const toolPolicy = session.toolPolicy ?? {
604
+
605
+ const policy = session.toolPolicy ?? {
579
606
  mode: 'all' as const,
580
607
  toolIds: [],
581
608
  updatedAt: session.updatedAt ?? session.createdAt,
582
609
  };
610
+
583
611
  const serverId = session.serverId ?? 'unknown';
584
- const tools = allTools.tools.map((tool) => ({
585
- ...tool,
586
- toolId: createToolId(serverId, tool.name),
587
- 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),
588
616
  }));
589
617
 
590
618
  return {
591
- toolPolicy,
619
+ toolPolicy: policy,
592
620
  tools,
593
- toolCount: tools.length,
594
- allowedToolCount: tools.filter((tool) => tool.allowed).length,
621
+ toolCount: tools.length,
622
+ allowedToolCount: tools.filter((t) => t.allowed).length,
595
623
  };
596
624
  }
597
625
 
598
626
  /**
599
- * Persists a new tool access policy for a session and broadcasts the updated
600
- * filtered tool list to all connected browser clients via a `tools_discovered` event.
601
- *
602
- * Both `tools` (policy-filtered) and `allTools` (complete list) are emitted
603
- * so the management UI can immediately reflect the new checkbox states without
604
- * an additional round-trip to the server.
627
+ * Persists a new tool access policy and broadcasts the updated filtered
628
+ * tool list via a `tools_discovered` SSE event.
605
629
  *
606
- * @param params - Session ID and the new `{ mode, toolIds }` policy to apply.
607
- * @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.
608
632
  */
609
- private async setToolPolicy(params: SetToolPolicyParams): Promise<SetToolPolicyResult> {
610
- const { sessionId } = params;
611
- const session = await sessions.get(this.userId, sessionId);
612
- if (!session) {
613
- throw new Error('Session not found');
614
- }
615
-
616
- 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);
617
638
  const allTools = await client.fetchTools();
618
- const toolPolicy = normalizeToolPolicyForUpdate(params.toolPolicy);
619
- validateToolPolicyAgainstTools(toolPolicy, allTools.tools, session.serverId);
620
-
621
- await sessions.update(this.userId, sessionId, { toolPolicy });
622
639
 
623
- const filteredTools = createToolPolicyGateway(this.userId, sessionId, client).filterTools({ ...session, toolPolicy }, allTools.tools);
624
- this.emitConnectionEvent({
625
- type: 'tools_discovered',
626
- sessionId,
627
- serverId: session.serverId ?? 'unknown',
628
- toolCount: filteredTools.length,
629
- tools: filteredTools,
630
- allTools: allTools.tools,
631
- 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(),
632
656
  });
633
657
 
634
- return {
635
- success: true,
636
- toolPolicy,
637
- tools: filteredTools,
638
- toolCount: filteredTools.length,
639
- };
658
+ return { success: true, toolPolicy: policy, tools: filtered, toolCount: filtered.length };
640
659
  }
641
660
 
642
661
  /**
643
- * Proxies a tool invocation to the remote MCP server.
644
- * 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.
645
671
  */
646
- private async callTool(params: CallToolParams): Promise<CallToolResult> {
647
- const { sessionId, toolName, toolArgs } = params;
648
- const client = await this.getOrCreateClient(sessionId);
649
- const result = await createToolPolicyGateway(this.userId, sessionId, client).callTool(toolName, toolArgs);
650
-
651
- // Inject sessionId into meta so client knows who handled it
652
- // This allows AppHost to auto-launch without scanning all sessions
653
- const meta = result._meta || {};
654
-
655
- return {
656
- ...result,
657
- _meta: {
658
- ...meta,
659
- sessionId,
660
- }
661
- };
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 };
662
676
  }
663
677
 
678
+ // -----------------------------------------------------------------------
679
+ // Tool Execution
680
+ // -----------------------------------------------------------------------
681
+
664
682
  /**
665
- * 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.
666
688
  */
667
- private async getSession(params: SessionParams): Promise<GetSessionResult> {
668
- 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);
669
694
 
670
- const session = await sessions.get(this.userId, sessionId);
671
- if (!session) {
672
- throw new Error('Session not found');
673
- }
695
+ return { ...result, _meta: { ...(result._meta ?? {}), sessionId: params.sessionId } };
696
+ }
674
697
 
675
- this.emitConnectionEvent({
676
- type: 'state_changed',
677
- sessionId,
678
- serverId: session.serverId ?? 'unknown',
679
- serverName: session.serverName ?? 'Unknown',
680
- serverUrl: session.serverUrl,
681
- state: 'VALIDATING',
682
- previousState: 'DISCONNECTED',
683
- timestamp: Date.now(),
684
- });
698
+ // -----------------------------------------------------------------------
699
+ // Prompts
700
+ // -----------------------------------------------------------------------
685
701
 
686
- try {
687
- 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
+ }
688
708
 
689
- const client = new MCPClient({
690
- userId: this.userId,
691
- sessionId,
692
- // These fields are optional in MCPClient, but when rehydrating a known
693
- // stored session on the server we pass them explicitly to preserve the
694
- // original transport/connection metadata instead of relying on lazy
695
- // reloading during initialize().
696
- serverId: session.serverId,
697
- serverName: session.serverName,
698
- serverUrl: session.serverUrl,
699
- callbackUrl: session.callbackUrl,
700
- transportType: session.transportType,
701
- headers: session.headers,
702
- ...clientMetadata,
703
- });
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
+ }
704
714
 
705
- client.onConnectionEvent((event) => this.emitConnectionEvent(event));
706
- client.onObservabilityEvent((event) => this.sendEvent(event));
715
+ // -----------------------------------------------------------------------
716
+ // Resources
717
+ // -----------------------------------------------------------------------
707
718
 
708
- await client.connect();
709
- 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
+ }
710
725
 
711
- 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
+ }
712
731
 
713
- return { success: true, toolCount: tools.tools.length };
714
- } catch (error) {
715
- this.emitConnectionEvent({
716
- type: 'error',
717
- sessionId,
718
- serverId: session.serverId ?? 'unknown',
719
- error: error instanceof Error ? error.message : 'Validation failed',
720
- errorType: 'validation',
721
- timestamp: Date.now(),
722
- });
732
+ // -----------------------------------------------------------------------
733
+ // Internal Helpers
734
+ // -----------------------------------------------------------------------
723
735
 
724
- throw error;
725
- }
726
- }
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
+ : {};
727
741
 
728
- /**
729
- * Complete OAuth authorization flow
730
- */
731
- private async finishAuth(params: FinishAuthParams): Promise<FinishAuthResult> {
732
- const { code } = params;
733
- const oauthState = params.state;
734
- const parsedState = parseOAuthState(oauthState);
735
- const sessionId = parsedState?.sessionId || oauthState;
736
-
737
- const session = await sessions.get(this.userId, sessionId);
738
- if (!session) {
739
- throw new Error('Session not found');
742
+ if (this.options.getClientMetadata) {
743
+ metadata = { ...metadata, ...(await this.options.getClientMetadata(request)) };
740
744
  }
745
+ return metadata;
746
+ }
741
747
 
742
- try {
743
- const client = new MCPClient({
744
- userId: this.userId,
745
- sessionId,
746
- // These fields are optional in MCPClient, but when rehydrating a known
747
- // stored session on the server we pass them explicitly to preserve the
748
- // original connection metadata instead of relying on lazy
749
- // reloading during initialize().
750
- serverId: session.serverId,
751
- serverName: session.serverName,
752
- serverUrl: session.serverUrl,
753
- callbackUrl: session.callbackUrl,
754
- // NOTE: transportType is intentionally omitted here.
755
- // The session's stored transportType is a placeholder ('streamable-http')
756
- // set before transport negotiation. Omitting it lets MCPClient auto-negotiate
757
- // (try streamable_http → SSE fallback), which is critical for servers like
758
- // Neon that only support SSE transport.
759
- headers: session.headers,
760
- });
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
+ }
761
754
 
762
- 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
+ }
763
763
 
764
- await client.finishAuth(code, oauthState);
765
- 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
+ }
766
768
 
767
- 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;
768
776
 
769
- return { success: true, toolCount: tools.tools.length };
770
- } catch (error) {
771
- this.emitConnectionEvent({
772
- type: 'error',
773
- sessionId,
774
- serverId: session.serverId ?? 'unknown',
775
- error: error instanceof Error ? error.message : 'OAuth completion failed',
776
- errorType: 'auth',
777
- timestamp: Date.now(),
778
- });
777
+ const session = await this.requireSession(sessionId);
779
778
 
780
- throw error;
779
+ if (session.enabled === false) {
780
+ throw new Error('Session is disabled — re-enable it via updateSession to access tools');
781
781
  }
782
- }
782
+ const client = this.restoreClient(session);
783
783
 
784
- /**
785
- * List prompts from a session
786
- */
787
- private async listPrompts(params: SessionParams): Promise<ListPromptsResult> {
788
- const { sessionId } = params;
789
- const client = await this.getOrCreateClient(sessionId);
790
- const result = await client.listPrompts();
791
- return { prompts: result.prompts };
792
- }
784
+ this.attachClientEvents(client);
793
785
 
794
- /**
795
- * Get a specific prompt
796
- */
797
- private async getPrompt(params: GetPromptParams): Promise<unknown> {
798
- const { sessionId, name, args } = params;
799
- const client = await this.getOrCreateClient(sessionId);
800
- return await client.getPrompt(name, args);
786
+ await client.connect();
787
+ this.clients.set(sessionId, client);
788
+ return client;
801
789
  }
802
790
 
803
791
  /**
804
- * 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.
805
797
  */
806
- private async listResources(params: SessionParams): Promise<ListResourcesResult> {
807
- const { sessionId } = params;
808
- const client = await this.getOrCreateClient(sessionId);
809
- const result = await client.listResources();
810
- 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
+ });
811
817
  }
812
818
 
813
819
  /**
814
- * 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).
815
824
  */
816
- private async readResource(params: ReadResourceParams): Promise<unknown> {
817
- const { sessionId, uri } = params;
818
- const client = await this.getOrCreateClient(sessionId);
819
- return client.readResource(uri);
825
+ private attachClientEvents(client: MCPClient): void {
826
+ client.onConnectionEvent((e) => this.sendEvent(e));
827
+ client.onObservabilityEvent((e) => this.sendEvent(e));
820
828
  }
821
829
 
822
830
  /**
823
- * Emit connection event
831
+ * Registers the client in the in-memory cache and attaches its event
832
+ * listeners to the unified observability channel.
824
833
  */
825
- private emitConnectionEvent(event: McpConnectionEvent): void {
826
- this.sendEvent(event);
834
+ private cacheClient(sessionId: string, client: MCPClient): void {
835
+ this.attachClientEvents(client);
836
+ this.clients.set(sessionId, client);
827
837
  }
828
838
 
829
839
  /**
830
- * 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.
831
848
  */
832
- async dispose(): Promise<void> {
833
- 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
+ }
834
863
 
835
- if (this.heartbeatTimer) {
836
- clearInterval(this.heartbeatTimer);
864
+ this.sendEvent(connectionErrorEvent(sessionId, serverId, error, 'connection'));
865
+ this.clients.delete(sessionId);
866
+ throw error;
837
867
  }
868
+ }
838
869
 
839
- // Send HTTP DELETE to each Streamable HTTP server before closing, per spec.
840
- // Run in parallel so shutdown is not serialised across many sessions.
841
- await Promise.all(
842
- Array.from(this.clients.values()).map((client) => client.disconnect())
843
- );
844
-
845
- 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);
846
878
  }
847
879
  }
848
880
 
849
- // ============================================
881
+ // ---------------------------------------------------------------------------
850
882
  // SSE Handler Factory
851
- // ============================================
883
+ // ---------------------------------------------------------------------------
852
884
 
853
885
  /**
854
- * Create an SSE endpoint handler compatible with Node.js HTTP frameworks.
855
- * 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.
856
899
  */
857
900
  export function createSSEHandler(options: SSEHandlerOptions) {
858
- return async (req: { method?: string; on: Function }, res: { writeHead: Function; write: Function }) => {
859
- // 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
+ ) => {
860
905
  res.writeHead(200, {
861
- 'Content-Type': 'text/event-stream',
862
- 'Cache-Control': 'no-cache',
863
- 'Connection': 'keep-alive',
906
+ 'Content-Type': 'text/event-stream',
907
+ 'Cache-Control': 'no-cache',
908
+ 'Connection': 'keep-alive',
864
909
  'Access-Control-Allow-Origin': '*',
865
910
  });
866
911
 
867
- // Send initial connection acknowledgment
868
912
  writeSSEEvent(res, 'connected', { timestamp: Date.now() });
869
913
 
870
- // Create connection manager with event routing
871
914
  const manager = new SSEConnectionManager(options, (event) => {
872
915
  if (isRpcResponseEvent(event)) {
873
916
  writeSSEEvent(res, 'rpc-response', event);
@@ -878,41 +921,36 @@ export function createSSEHandler(options: SSEHandlerOptions) {
878
921
  }
879
922
  });
880
923
 
881
- // Cleanup on client disconnect
882
924
  req.on('close', () => manager.dispose());
883
925
 
884
- // Handle RPC requests via POST
885
926
  if (req.method === 'POST') {
886
927
  let body = '';
887
- req.on('data', (chunk: Buffer) => {
888
- body += chunk.toString();
889
- });
928
+ req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
890
929
  req.on('end', async () => {
891
930
  try {
892
- const request: McpRpcRequest = JSON.parse(body);
893
- await manager.handleRequest(request);
931
+ await manager.handleRequest(JSON.parse(body) as McpRpcRequest);
894
932
  } catch {
895
- // Request parsing/handling errors are sent via SSE error events
933
+ // Parsing / handling errors surface through SSE error events
896
934
  }
897
935
  });
898
936
  }
899
937
  };
900
938
  }
901
939
 
902
- // ============================================
903
- // Utilities
904
- // ============================================
940
+ // ---------------------------------------------------------------------------
941
+ // SSE Utilities
942
+ // ---------------------------------------------------------------------------
905
943
 
906
944
  /**
907
- * 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`
908
948
  */
909
- 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 {
910
954
  res.write(`event: ${event}\n`);
911
955
  res.write(`data: ${JSON.stringify(data)}\n\n`);
912
956
  }
913
-
914
-
915
-
916
-
917
-
918
-