@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.
- package/README.md +3 -574
- package/dist/adapters/agui-adapter.d.mts +4 -4
- package/dist/adapters/agui-adapter.d.ts +4 -4
- package/dist/adapters/agui-middleware.d.mts +4 -4
- package/dist/adapters/agui-middleware.d.ts +4 -4
- package/dist/adapters/ai-adapter.d.mts +4 -4
- package/dist/adapters/ai-adapter.d.ts +4 -4
- package/dist/adapters/langchain-adapter.d.mts +4 -4
- package/dist/adapters/langchain-adapter.d.ts +4 -4
- package/dist/adapters/mastra-adapter.d.mts +3 -3
- package/dist/adapters/mastra-adapter.d.ts +3 -3
- package/dist/client/index.d.mts +2 -2
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +3 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +3 -0
- package/dist/client/index.mjs.map +1 -1
- package/dist/client/react.d.mts +10 -18
- package/dist/client/react.d.ts +10 -18
- package/dist/client/react.js +17 -0
- package/dist/client/react.js.map +1 -1
- package/dist/client/react.mjs +17 -0
- package/dist/client/react.mjs.map +1 -1
- package/dist/client/vue.d.mts +4 -4
- package/dist/client/vue.d.ts +4 -4
- package/dist/client/vue.js +3 -0
- package/dist/client/vue.js.map +1 -1
- package/dist/client/vue.mjs +3 -0
- package/dist/client/vue.mjs.map +1 -1
- package/dist/{index-B3GPsPsi.d.mts → index-BrDkbut5.d.ts} +2 -1
- package/dist/{index-gNdSQTSz.d.ts → index-DHQvlx4K.d.mts} +2 -1
- package/dist/index.d.mts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +1056 -863
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1048 -858
- package/dist/index.mjs.map +1 -1
- package/dist/{multi-session-client-CB4oDrQX.d.ts → multi-session-client-CQzymvHl.d.ts} +183 -227
- package/dist/{multi-session-client-xj3iQIv6.d.mts → multi-session-client-C_Ja2TuV.d.mts} +183 -227
- package/dist/server/index.d.mts +338 -88
- package/dist/server/index.d.ts +338 -88
- package/dist/server/index.js +1054 -864
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +1046 -859
- package/dist/server/index.mjs.map +1 -1
- package/dist/shared/index.d.mts +4 -4
- package/dist/shared/index.d.ts +4 -4
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/index.mjs.map +1 -1
- package/dist/{tool-router-CsKVXbQB.d.mts → tool-router-C1n7OXbl.d.mts} +1 -1
- package/dist/{tool-router-C-Mw1_BQ.d.ts → tool-router-CBIawFnt.d.ts} +1 -1
- package/dist/{types-6SmXege4.d.mts → types-DX71u9gR.d.mts} +11 -3
- package/dist/{types-6SmXege4.d.ts → types-DX71u9gR.d.ts} +11 -3
- package/migrations/neon/20260513010000_install_mcp_sessions.sql +4 -34
- package/migrations/supabase/20260330195700_install_mcp_sessions.sql +5 -62
- package/package.json +2 -1
- package/src/client/core/sse-client.ts +5 -0
- package/src/client/react/use-mcp.ts +27 -28
- package/src/server/handlers/sse-handler.ts +671 -629
- package/src/server/index.ts +2 -1
- package/src/server/mcp/multi-session-client.ts +34 -10
- package/src/server/mcp/oauth-client.ts +197 -205
- package/src/server/mcp/storage-oauth-provider.ts +149 -29
- package/src/server/mcp/tool-policy-gateway.ts +11 -1
- package/src/server/storage/file-backend.ts +25 -21
- package/src/server/storage/index.ts +71 -0
- package/src/server/storage/memory-backend.ts +24 -12
- package/src/server/storage/neon-backend.ts +70 -72
- package/src/server/storage/redis-backend.ts +26 -36
- package/src/server/storage/sqlite-backend.ts +25 -39
- package/src/server/storage/supabase-backend.ts +45 -54
- package/src/server/storage/types.ts +22 -2
- 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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
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 server → client events
|
|
7
|
+
* - POST → delivers client → server RPC requests with direct HTTP response
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
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 {
|
|
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
|
|
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
|
-
/**
|
|
72
|
+
/** Authenticated user / tenant identifier. */
|
|
63
73
|
userId: string;
|
|
64
74
|
|
|
65
|
-
/** Optional
|
|
75
|
+
/** Optional auth check — called per-request before any RPC dispatch. */
|
|
66
76
|
onAuth?: (userId: string) => Promise<boolean>;
|
|
67
77
|
|
|
68
|
-
/**
|
|
78
|
+
/** SSE heartbeat interval in milliseconds. @default 30000 */
|
|
69
79
|
heartbeatInterval?: number;
|
|
70
80
|
|
|
71
|
-
/** Static OAuth client metadata
|
|
81
|
+
/** Static OAuth client metadata applied to all connections. */
|
|
72
82
|
clientDefaults?: ClientMetadata;
|
|
73
83
|
|
|
74
|
-
/**
|
|
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
|
|
96
|
+
const DEFAULT_HEARTBEAT_MS = 30_000;
|
|
83
97
|
|
|
84
|
-
|
|
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(([
|
|
89
|
-
.filter(([
|
|
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
|
-
|
|
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
|
|
100
|
-
*
|
|
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: (
|
|
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
|
-
|
|
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
|
-
*
|
|
155
|
-
*
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
240
|
+
try {
|
|
241
|
+
const result = await this.dispatchImpl(request);
|
|
221
242
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
252
|
+
const response: McpRpcResponse = { id: request.id, result };
|
|
228
253
|
this.sendEvent(response);
|
|
229
|
-
|
|
230
254
|
return response;
|
|
231
255
|
} catch (error) {
|
|
232
|
-
|
|
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
|
-
|
|
241
|
-
this.
|
|
287
|
+
await Promise.all(
|
|
288
|
+
Array.from(this.clients.values()).map((c) => c.disconnect()),
|
|
289
|
+
);
|
|
242
290
|
|
|
243
|
-
|
|
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
|
-
*
|
|
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
|
|
252
|
-
|
|
335
|
+
const all = await sessions.list(this.userId);
|
|
253
336
|
return {
|
|
254
|
-
sessions:
|
|
255
|
-
sessionId:
|
|
256
|
-
serverId:
|
|
257
|
-
serverName:
|
|
258
|
-
serverUrl:
|
|
259
|
-
transport:
|
|
260
|
-
createdAt:
|
|
261
|
-
updatedAt:
|
|
262
|
-
status:
|
|
263
|
-
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
|
-
*
|
|
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
|
|
272
|
-
const
|
|
273
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
373
|
-
*
|
|
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
|
|
376
|
-
const
|
|
377
|
-
const
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
425
|
+
this.cacheClient(sessionId, client);
|
|
426
|
+
return this.connectAndDiscover(client, sessionId, serverId);
|
|
427
|
+
}
|
|
423
428
|
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
429
|
-
|
|
438
|
+
const existing = await this.findExistingSession(serverId, params.serverUrl);
|
|
439
|
+
const sessionId = existing ? existing.sessionId : await sessions.generateSessionId();
|
|
430
440
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if (
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
463
|
+
this.cacheClient(sessionId, client);
|
|
464
|
+
return this.connectAndDiscover(client, sessionId, serverId);
|
|
450
465
|
}
|
|
451
466
|
|
|
452
467
|
/**
|
|
453
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}
|
|
507
|
+
try {
|
|
508
|
+
const clientId = session.clientId ?? undefined;
|
|
509
|
+
const clientSecret = (session.clientInformation as any)?.client_secret ?? undefined;
|
|
485
510
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
502
|
-
client.onConnectionEvent((event) => this.emitConnectionEvent(event));
|
|
503
|
-
client.onObservabilityEvent((event) => this.sendEvent(event));
|
|
526
|
+
this.attachClientEvents(client);
|
|
504
527
|
|
|
505
|
-
|
|
506
|
-
|
|
528
|
+
await runWithCodeVerifierState(session.codeVerifier ?? '', 'S256', () =>
|
|
529
|
+
client.finishAuth(params.code, params.state),
|
|
530
|
+
);
|
|
507
531
|
|
|
508
|
-
|
|
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
|
|
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
|
-
*
|
|
515
|
-
* - `
|
|
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()`
|
|
520
|
-
* `gateway.listTools()`
|
|
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
|
|
524
|
-
* @returns The session record and the
|
|
525
|
-
* @throws {Error}
|
|
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(
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
const
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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:
|
|
542
|
-
toolCount:
|
|
543
|
-
tools:
|
|
544
|
-
allTools:
|
|
545
|
-
timestamp:
|
|
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
|
|
553
|
-
*
|
|
554
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
595
|
+
* Returns all raw tools annotated with their effective policy state
|
|
596
|
+
* for display in the management UI.
|
|
564
597
|
*/
|
|
565
|
-
private async getToolPolicy(
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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((
|
|
581
|
-
...
|
|
582
|
-
toolId:
|
|
583
|
-
allowed: isToolAllowed(
|
|
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:
|
|
590
|
-
allowedToolCount:
|
|
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
|
|
596
|
-
*
|
|
627
|
+
* Persists a new tool access policy and broadcasts the updated filtered
|
|
628
|
+
* tool list via a `tools_discovered` SSE event.
|
|
597
629
|
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
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(
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
*
|
|
640
|
-
*
|
|
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
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
*
|
|
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
|
|
664
|
-
const
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
throw new Error('Session not found');
|
|
669
|
-
}
|
|
695
|
+
return { ...result, _meta: { ...(result._meta ?? {}), sessionId: params.sessionId } };
|
|
696
|
+
}
|
|
670
697
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
683
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
702
|
-
|
|
715
|
+
// -----------------------------------------------------------------------
|
|
716
|
+
// Resources
|
|
717
|
+
// -----------------------------------------------------------------------
|
|
703
718
|
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
803
|
-
const
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
return {
|
|
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
|
-
*
|
|
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
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
*
|
|
831
|
+
* Registers the client in the in-memory cache and attaches its event
|
|
832
|
+
* listeners to the unified observability channel.
|
|
820
833
|
*/
|
|
821
|
-
private
|
|
822
|
-
this.
|
|
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
|
-
*
|
|
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
|
|
829
|
-
|
|
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
|
-
|
|
832
|
-
|
|
864
|
+
this.sendEvent(connectionErrorEvent(sessionId, serverId, error, 'connection'));
|
|
865
|
+
this.clients.delete(sessionId);
|
|
866
|
+
throw error;
|
|
833
867
|
}
|
|
868
|
+
}
|
|
834
869
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
*
|
|
851
|
-
*
|
|
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 (
|
|
855
|
-
|
|
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':
|
|
858
|
-
'Cache-Control':
|
|
859
|
-
'Connection':
|
|
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
|
-
|
|
889
|
-
await manager.handleRequest(request);
|
|
931
|
+
await manager.handleRequest(JSON.parse(body) as McpRpcRequest);
|
|
890
932
|
} catch {
|
|
891
|
-
//
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
|