@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.
- 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-BRZO_JyI.d.mts → index-BrDkbut5.d.ts} +2 -1
- package/dist/{index-C3YGagi2.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 -876
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1048 -871
- package/dist/index.mjs.map +1 -1
- package/dist/{multi-session-client-CuacvZaQ.d.ts → multi-session-client-CQzymvHl.d.ts} +183 -227
- package/dist/{multi-session-client-DqzT8Oo7.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 +1053 -876
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +1045 -871
- 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-B0qpui-V.d.mts → tool-router-C1n7OXbl.d.mts} +1 -1
- package/dist/{tool-router-2qUyZ-cP.d.ts → tool-router-CBIawFnt.d.ts} +1 -1
- package/dist/{types-BkJ_rgzo.d.mts → types-DX71u9gR.d.mts} +11 -5
- package/dist/{types-BkJ_rgzo.d.ts → types-DX71u9gR.d.ts} +11 -5
- 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 -633
- 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 +148 -41
- 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 -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
|
-
*
|
|
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,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
|
-
/**
|
|
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);
|
|
286
|
+
|
|
287
|
+
await Promise.all(
|
|
288
|
+
Array.from(this.clients.values()).map((c) => c.disconnect()),
|
|
289
|
+
);
|
|
239
290
|
|
|
240
|
-
|
|
241
|
-
|
|
291
|
+
this.clients.clear();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// -----------------------------------------------------------------------
|
|
295
|
+
// RPC Dispatch (raw — called by handleRequest which adds timing)
|
|
296
|
+
// -----------------------------------------------------------------------
|
|
242
297
|
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
375
|
-
*
|
|
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
|
|
378
|
-
const
|
|
379
|
-
const
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
425
|
+
this.cacheClient(sessionId, client);
|
|
426
|
+
return this.connectAndDiscover(client, sessionId, serverId);
|
|
427
|
+
}
|
|
427
428
|
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
|
|
438
|
+
const existing = await this.findExistingSession(serverId, params.serverUrl);
|
|
439
|
+
const sessionId = existing ? existing.sessionId : await sessions.generateSessionId();
|
|
434
440
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
463
|
+
this.cacheClient(sessionId, client);
|
|
464
|
+
return this.connectAndDiscover(client, sessionId, serverId);
|
|
454
465
|
}
|
|
455
466
|
|
|
456
467
|
/**
|
|
457
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
507
|
+
try {
|
|
508
|
+
const clientId = session.clientId ?? undefined;
|
|
509
|
+
const clientSecret = (session.clientInformation as any)?.client_secret ?? undefined;
|
|
489
510
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
506
|
-
client.onConnectionEvent((event) => this.emitConnectionEvent(event));
|
|
507
|
-
client.onObservabilityEvent((event) => this.sendEvent(event));
|
|
526
|
+
this.attachClientEvents(client);
|
|
508
527
|
|
|
509
|
-
|
|
510
|
-
|
|
528
|
+
await runWithCodeVerifierState(session.codeVerifier ?? '', 'S256', () =>
|
|
529
|
+
client.finishAuth(params.code, params.state),
|
|
530
|
+
);
|
|
511
531
|
|
|
512
|
-
|
|
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
|
|
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
|
-
*
|
|
519
|
-
* - `
|
|
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()`
|
|
524
|
-
* `gateway.listTools()`
|
|
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
|
|
528
|
-
* @returns The session record and the
|
|
529
|
-
* @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.
|
|
530
559
|
*/
|
|
531
|
-
private async listPolicyFilteredTools(
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const
|
|
538
|
-
const
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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:
|
|
546
|
-
toolCount:
|
|
547
|
-
tools:
|
|
548
|
-
allTools:
|
|
549
|
-
timestamp:
|
|
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
|
|
557
|
-
*
|
|
558
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
595
|
+
* Returns all raw tools annotated with their effective policy state
|
|
596
|
+
* for display in the management UI.
|
|
568
597
|
*/
|
|
569
|
-
private async getToolPolicy(
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
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((
|
|
585
|
-
...
|
|
586
|
-
toolId:
|
|
587
|
-
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),
|
|
588
616
|
}));
|
|
589
617
|
|
|
590
618
|
return {
|
|
591
|
-
toolPolicy,
|
|
619
|
+
toolPolicy: policy,
|
|
592
620
|
tools,
|
|
593
|
-
toolCount:
|
|
594
|
-
allowedToolCount:
|
|
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
|
|
600
|
-
*
|
|
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
|
-
* @
|
|
607
|
-
*
|
|
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(
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
*
|
|
644
|
-
*
|
|
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
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
*
|
|
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
|
|
668
|
-
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);
|
|
669
694
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
throw new Error('Session not found');
|
|
673
|
-
}
|
|
695
|
+
return { ...result, _meta: { ...(result._meta ?? {}), sessionId: params.sessionId } };
|
|
696
|
+
}
|
|
674
697
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
687
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
706
|
-
|
|
715
|
+
// -----------------------------------------------------------------------
|
|
716
|
+
// Resources
|
|
717
|
+
// -----------------------------------------------------------------------
|
|
707
718
|
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
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
|
-
|
|
765
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
807
|
-
const
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
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
|
+
});
|
|
811
817
|
}
|
|
812
818
|
|
|
813
819
|
/**
|
|
814
|
-
*
|
|
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
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
*
|
|
831
|
+
* Registers the client in the in-memory cache and attaches its event
|
|
832
|
+
* listeners to the unified observability channel.
|
|
824
833
|
*/
|
|
825
|
-
private
|
|
826
|
-
this.
|
|
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
|
-
*
|
|
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
|
|
833
|
-
|
|
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
|
-
|
|
836
|
-
|
|
864
|
+
this.sendEvent(connectionErrorEvent(sessionId, serverId, error, 'connection'));
|
|
865
|
+
this.clients.delete(sessionId);
|
|
866
|
+
throw error;
|
|
837
867
|
}
|
|
868
|
+
}
|
|
838
869
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
*
|
|
855
|
-
*
|
|
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 (
|
|
859
|
-
|
|
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':
|
|
862
|
-
'Cache-Control':
|
|
863
|
-
'Connection':
|
|
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
|
-
|
|
893
|
-
await manager.handleRequest(request);
|
|
931
|
+
await manager.handleRequest(JSON.parse(body) as McpRpcRequest);
|
|
894
932
|
} catch {
|
|
895
|
-
//
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
|