@fitchmultz/pi-mcp-adapter 6.0.0 → 7.0.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/CHANGELOG.md +26 -0
- package/README.md +12 -8
- package/dist/app-bridge.bundle.js +14 -14
- package/dist/config.js +41 -16
- package/dist/gateway-arguments.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +2 -2
- package/dist/lifecycle.js +2 -2
- package/dist/mcp-auth-flow.js +2 -2
- package/dist/mcp-auth.d.ts +1 -1
- package/dist/mcp-auth.js +3 -3
- package/dist/mcp-code.d.ts +6 -6
- package/dist/mcp-code.js +11 -10
- package/dist/mcp-oauth-provider.d.ts +5 -5
- package/dist/mcp-oauth-provider.js +5 -5
- package/dist/mcp-output-guard.d.ts +1 -1
- package/dist/mcp-panel.d.ts +2 -2
- package/dist/mcp-panel.js +2 -2
- package/dist/mcp-setup-panel.d.ts +5 -4
- package/dist/mcp-setup-panel.js +15 -7
- package/dist/mcp-trace.d.ts +1 -1
- package/dist/mcp-trace.js +1 -1
- package/dist/npx-resolver.js +8 -4
- package/dist/proxy-modes.d.ts +2 -2
- package/dist/proxy-modes.js +9 -9
- package/dist/sampling-handler.js +1 -0
- package/dist/search-ranking.js +6 -2
- package/dist/server-manager.d.ts +2 -2
- package/dist/server-manager.js +44 -27
- package/dist/session-recovery.d.ts +7 -4
- package/dist/session-recovery.js +76 -10
- package/dist/tool-metadata.d.ts +43 -43
- package/dist/tool-result-renderer.d.ts +3 -3
- package/dist/tool-result-renderer.js +3 -3
- package/dist/ui-resource-handler.d.ts +2 -2
- package/dist/ui-resource-handler.js +2 -2
- package/dist/ui-server.js +4 -3
- package/dist/ui-stream-types.d.ts +8 -8
- package/dist/unix-socket-transport.d.ts +1 -1
- package/dist/unix-socket-transport.js +1 -1
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +14 -0
- package/package.json +34 -98
- package/abort.ts +0 -34
- package/agent-dir.ts +0 -24
- package/app-bridge.bundle.js +0 -109
- package/banner.png +0 -0
- package/checkpoint.ts +0 -55
- package/commands.ts +0 -622
- package/config.ts +0 -1204
- package/consent-manager.ts +0 -25
- package/direct-tools.ts +0 -75
- package/elicitation-handler.ts +0 -349
- package/error-signal.ts +0 -21
- package/gateway-arguments.ts +0 -73
- package/glimpse-ui.ts +0 -80
- package/host-html-template.ts +0 -456
- package/index.ts +0 -767
- package/init.ts +0 -613
- package/legacy-migration.ts +0 -47
- package/lifecycle.ts +0 -165
- package/logger.ts +0 -127
- package/mcp-auth-flow.ts +0 -975
- package/mcp-auth.ts +0 -793
- package/mcp-callback-server.ts +0 -450
- package/mcp-code.ts +0 -412
- package/mcp-keyring-helper.cjs +0 -89
- package/mcp-oauth-provider.ts +0 -774
- package/mcp-output-guard.ts +0 -530
- package/mcp-panel.ts +0 -907
- package/mcp-script-worker.mjs +0 -131
- package/mcp-setup-panel.ts +0 -666
- package/mcp-status.ts +0 -110
- package/mcp-trace.ts +0 -295
- package/metadata-cache.ts +0 -271
- package/npx-resolver.ts +0 -488
- package/onboarding-state.ts +0 -58
- package/panel-keys.ts +0 -37
- package/prompts.ts +0 -337
- package/proxy-modes.ts +0 -1109
- package/resource-tools.ts +0 -52
- package/runtime-owner.ts +0 -123
- package/sampling-handler.ts +0 -276
- package/scripts/build-app-bridge.mjs +0 -15
- package/scripts/build.mjs +0 -132
- package/scripts/prepare.mjs +0 -103
- package/search-ranking.ts +0 -132
- package/server-manager.ts +0 -1320
- package/session-recovery.ts +0 -316
- package/state.ts +0 -64
- package/tool-approval.ts +0 -78
- package/tool-loader.ts +0 -153
- package/tool-metadata.ts +0 -240
- package/tool-registrar.ts +0 -74
- package/tool-result-renderer.ts +0 -294
- package/tsconfig.build.json +0 -13
- package/tsconfig.json +0 -16
- package/types.ts +0 -785
- package/ui-resource-handler.ts +0 -249
- package/ui-server.ts +0 -808
- package/ui-session.ts +0 -568
- package/ui-stream-types.ts +0 -89
- package/ui-tool-visibility.ts +0 -25
- package/unix-socket-transport.ts +0 -85
- package/utils.ts +0 -307
package/server-manager.ts
DELETED
|
@@ -1,1320 +0,0 @@
|
|
|
1
|
-
import type { McpCheckpointEvent } from "./checkpoint.ts";
|
|
2
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
-
import {
|
|
4
|
-
Client,
|
|
5
|
-
StreamableHTTPClientTransport,
|
|
6
|
-
SSEClientTransport,
|
|
7
|
-
SdkHttpError,
|
|
8
|
-
auth,
|
|
9
|
-
extractWWWAuthenticateParams,
|
|
10
|
-
UnauthorizedError,
|
|
11
|
-
type AuthProvider,
|
|
12
|
-
type RequestOptions,
|
|
13
|
-
type FetchLike,
|
|
14
|
-
type GetPromptResult,
|
|
15
|
-
type ReadResourceResult,
|
|
16
|
-
type UrlElicitationRequiredError,
|
|
17
|
-
} from "@modelcontextprotocol/client";
|
|
18
|
-
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
19
|
-
import { UnixSocketClientTransport } from "./unix-socket-transport.ts";
|
|
20
|
-
import { trackToolHttpFailures, trackToolTransportFailure } from "./session-recovery.ts";
|
|
21
|
-
import {
|
|
22
|
-
isServerDisabled,
|
|
23
|
-
isNonInteractiveOAuth,
|
|
24
|
-
validateServerProtocolConfig,
|
|
25
|
-
type McpTool,
|
|
26
|
-
type McpResource,
|
|
27
|
-
type McpPrompt,
|
|
28
|
-
type ServerDefinition,
|
|
29
|
-
type ServerStreamResultPatchNotification,
|
|
30
|
-
type Transport,
|
|
31
|
-
type McpTraceSettings,
|
|
32
|
-
serverStreamResultPatchNotificationSchema,
|
|
33
|
-
} from "./types.ts";
|
|
34
|
-
import { resolveNpxBinary } from "./npx-resolver.ts";
|
|
35
|
-
import { logger } from "./logger.ts";
|
|
36
|
-
import { McpOAuthProvider } from "./mcp-oauth-provider.ts";
|
|
37
|
-
import { extractOAuthConfig, supportsOAuth, isOAuthChallenge, recordOAuthChallenge, getOAuthRequest, bindOAuthRequestIssuer, type McpOAuthRuntime } from "./mcp-auth-flow.ts";
|
|
38
|
-
import { getAuthBaseDir, type AuthStorageOptions } from "./mcp-auth.ts";
|
|
39
|
-
import { registerSamplingHandler, type ServerSamplingConfig } from "./sampling-handler.ts";
|
|
40
|
-
import {
|
|
41
|
-
handleUrlElicitation,
|
|
42
|
-
registerElicitationHandler,
|
|
43
|
-
type ServerElicitationConfig,
|
|
44
|
-
} from "./elicitation-handler.ts";
|
|
45
|
-
import {
|
|
46
|
-
normalizeRequestTimeoutMs,
|
|
47
|
-
resolveBearerToken,
|
|
48
|
-
resolveCommandSecret,
|
|
49
|
-
resolveCommandSecretsRecord,
|
|
50
|
-
resolveConfigPath,
|
|
51
|
-
resolveServerUrl,
|
|
52
|
-
} from "./utils.ts";
|
|
53
|
-
import { abortable, throwIfAborted } from "./abort.ts";
|
|
54
|
-
import { combineAbortSignals } from "./runtime-owner.ts";
|
|
55
|
-
import {
|
|
56
|
-
createMcpTraceWriter,
|
|
57
|
-
isMcpTraceEnabled,
|
|
58
|
-
McpTraceWriter,
|
|
59
|
-
type McpTraceObserver,
|
|
60
|
-
traceTransportKind,
|
|
61
|
-
wrapTransportWithMcpTrace,
|
|
62
|
-
} from "./mcp-trace.ts";
|
|
63
|
-
|
|
64
|
-
const MAX_CAPTURED_STDERR_BYTES = 8 * 1024;
|
|
65
|
-
const MAX_CAPTURED_STDERR_LINES = 3;
|
|
66
|
-
const abortCleanupPromises = new WeakMap<object, Promise<void>>();
|
|
67
|
-
type ClientOperation = "request/refresh" | "sampling callback" | "elicitation callback" | "subscription";
|
|
68
|
-
const clientOperations = new WeakMap<Client, Map<Promise<unknown>, ClientOperation>>();
|
|
69
|
-
|
|
70
|
-
async function drainClientCallbacks(client: Client): Promise<void> {
|
|
71
|
-
for (;;) {
|
|
72
|
-
const pending = [...(clientOperations.get(client) ?? [])]
|
|
73
|
-
.filter(([, kind]) => kind === "sampling callback" || kind === "elicitation callback")
|
|
74
|
-
.map(([operation]) => operation);
|
|
75
|
-
if (!pending.length) return;
|
|
76
|
-
await Promise.allSettled(pending);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function drainClientOperations(client: Client): Promise<void> {
|
|
81
|
-
for (;;) {
|
|
82
|
-
const pending = [...(clientOperations.get(client)?.keys() ?? [])];
|
|
83
|
-
if (!pending.length) return;
|
|
84
|
-
await Promise.allSettled(pending);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Track complete SDK operations, including pagination and pre-request cache awaits.
|
|
89
|
-
class ManagedClient extends Client {
|
|
90
|
-
serverName = "";
|
|
91
|
-
oauthProvider: McpOAuthProvider | undefined;
|
|
92
|
-
beforeActivity: () => void = () => {};
|
|
93
|
-
readonly resourceSubscriptions = new Set<string>();
|
|
94
|
-
|
|
95
|
-
private own<T>(operation: Promise<T>, kind: ClientOperation): Promise<T> {
|
|
96
|
-
let pending = clientOperations.get(this);
|
|
97
|
-
if (!pending) clientOperations.set(this, pending = new Map());
|
|
98
|
-
pending.set(operation, kind);
|
|
99
|
-
const settled = () => { pending.delete(operation); };
|
|
100
|
-
void operation.then(settled, settled);
|
|
101
|
-
return operation;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
ownNative<T>(run: () => Promise<T>): Promise<T> {
|
|
105
|
-
this.beforeActivity();
|
|
106
|
-
return this.own(run(), "request/refresh");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
ownSend(run: () => Promise<void>): Promise<void> {
|
|
110
|
-
this.beforeActivity();
|
|
111
|
-
// SDK-generated callback replies also perform HTTP/auth work, without calling callTool().
|
|
112
|
-
return this.own(Promise.resolve().then(run), "request/refresh");
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
ownCallback<T>(kind: "sampling callback" | "elicitation callback", run: () => Promise<T>): Promise<T> {
|
|
116
|
-
this.beforeActivity();
|
|
117
|
-
// Insert before dispatch. Cancellation/close of the SDK response does not settle this promise.
|
|
118
|
-
return this.own(Promise.resolve().then(run), kind);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
private async track<T>(start: (signal?: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
122
|
-
this.beforeActivity();
|
|
123
|
-
const controller = this.oauthProvider ? new AbortController() : undefined;
|
|
124
|
-
let removeAbortListener = () => {};
|
|
125
|
-
const operation = this.oauthProvider
|
|
126
|
-
? this.oauthProvider.runWithSignal(controller!.signal, () => {
|
|
127
|
-
// Native legacy cancellation notifications must retain their operation's auth context.
|
|
128
|
-
const abort = AsyncLocalStorage.bind(() => controller!.abort(signal?.reason));
|
|
129
|
-
if (signal?.aborted) abort();
|
|
130
|
-
else signal?.addEventListener("abort", abort, { once: true });
|
|
131
|
-
removeAbortListener = () => signal?.removeEventListener("abort", abort);
|
|
132
|
-
return start(controller!.signal);
|
|
133
|
-
})
|
|
134
|
-
: start(signal);
|
|
135
|
-
void operation.then(removeAbortListener, () => { controller?.abort(); removeAbortListener(); });
|
|
136
|
-
return this.own(operation, "request/refresh");
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
override callTool(...args: Parameters<Client["callTool"]>) {
|
|
140
|
-
return this.track(signal => super.callTool(args[0], { ...args[1], ...(signal ? { signal } : {}) }), args[1]?.signal);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
override readResource(...args: Parameters<Client["readResource"]>) {
|
|
144
|
-
return this.track(signal => super.readResource(args[0], { ...args[1], ...(signal ? { signal } : {}) }), args[1]?.signal);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
override getPrompt(...args: Parameters<Client["getPrompt"]>) {
|
|
148
|
-
return this.track(signal => super.getPrompt(args[0], { ...args[1], ...(signal ? { signal } : {}) }), args[1]?.signal);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
override listTools(...args: Parameters<Client["listTools"]>) {
|
|
152
|
-
return this.track(signal => super.listTools(args[0], { ...args[1], ...(signal ? { signal } : {}) }), args[1]?.signal);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
override listResources(...args: Parameters<Client["listResources"]>) {
|
|
156
|
-
return this.track(signal => super.listResources(args[0], { ...args[1], ...(signal ? { signal } : {}) }), args[1]?.signal);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
override listPrompts(...args: Parameters<Client["listPrompts"]>) {
|
|
160
|
-
return this.track(signal => super.listPrompts(args[0], { ...args[1], ...(signal ? { signal } : {}) }), args[1]?.signal);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
override complete(...args: Parameters<Client["complete"]>) {
|
|
164
|
-
return this.track(signal => super.complete(args[0], { ...args[1], ...(signal ? { signal } : {}) }), args[1]?.signal);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
override setLoggingLevel(...args: Parameters<Client["setLoggingLevel"]>) {
|
|
168
|
-
return this.track(signal => super.setLoggingLevel(args[0], { ...args[1], ...(signal ? { signal } : {}) }), args[1]?.signal);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
override listen(...args: Parameters<Client["listen"]>) {
|
|
172
|
-
return this.track(async signal => {
|
|
173
|
-
const subscription = await super.listen(args[0], { ...args[1], ...(signal ? { signal } : {}) });
|
|
174
|
-
this.own(subscription.closed, "subscription");
|
|
175
|
-
return subscription;
|
|
176
|
-
}, args[1]?.signal);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
override subscribeResource(...args: Parameters<Client["subscribeResource"]>) {
|
|
180
|
-
return this.track(async signal => {
|
|
181
|
-
throwIfAborted(signal);
|
|
182
|
-
// A cancelled/lost response does not prove the server rejected the subscription.
|
|
183
|
-
// Retain the attempted URI until an explicit unsubscribe succeeds or the connection is discarded.
|
|
184
|
-
if (this.getServerCapabilities()?.resources?.subscribe) this.resourceSubscriptions.add(args[0].uri);
|
|
185
|
-
return super.subscribeResource(args[0], { ...args[1], ...(signal ? { signal } : {}) });
|
|
186
|
-
}, args[1]?.signal);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
override unsubscribeResource(...args: Parameters<Client["unsubscribeResource"]>) {
|
|
190
|
-
return this.track(async signal => {
|
|
191
|
-
const result = await super.unsubscribeResource(args[0], { ...args[1], ...(signal ? { signal } : {}) });
|
|
192
|
-
this.resourceSubscriptions.delete(args[0].uri);
|
|
193
|
-
return result;
|
|
194
|
-
}, args[1]?.signal);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function boundedStderrChunk(chunk: Buffer | string): Buffer {
|
|
199
|
-
if (Buffer.isBuffer(chunk)) {
|
|
200
|
-
const start = Math.max(0, chunk.byteLength - MAX_CAPTURED_STDERR_BYTES);
|
|
201
|
-
return Buffer.from(chunk.subarray(start));
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Limit string conversion before encoding; Buffer.from(largeString) would
|
|
205
|
-
// otherwise allocate the entire stderr event before applying the cap.
|
|
206
|
-
const suffix = chunk.length > MAX_CAPTURED_STDERR_BYTES
|
|
207
|
-
? chunk.slice(-MAX_CAPTURED_STDERR_BYTES)
|
|
208
|
-
: chunk;
|
|
209
|
-
const bytes = Buffer.from(suffix, "utf8");
|
|
210
|
-
return bytes.byteLength > MAX_CAPTURED_STDERR_BYTES
|
|
211
|
-
? Buffer.from(bytes.subarray(bytes.byteLength - MAX_CAPTURED_STDERR_BYTES))
|
|
212
|
-
: bytes;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function appendStderrTail(tail: Buffer, chunk: Buffer | string): Buffer {
|
|
216
|
-
const bytes = boundedStderrChunk(chunk);
|
|
217
|
-
if (bytes.length === 0) return tail;
|
|
218
|
-
if (tail.length === 0) return bytes;
|
|
219
|
-
const combined = Buffer.concat([tail, bytes]);
|
|
220
|
-
return combined.length > MAX_CAPTURED_STDERR_BYTES
|
|
221
|
-
? Buffer.from(combined.subarray(combined.length - MAX_CAPTURED_STDERR_BYTES))
|
|
222
|
-
: combined;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export interface ServerConnection {
|
|
226
|
-
client: Client;
|
|
227
|
-
transport: Transport;
|
|
228
|
-
definition: ServerDefinition;
|
|
229
|
-
tools: McpTool[];
|
|
230
|
-
resources: McpResource[];
|
|
231
|
-
prompts: McpPrompt[];
|
|
232
|
-
/** True when prompts were advertised but prompts/list failed. */
|
|
233
|
-
promptDiscoveryFailed?: boolean;
|
|
234
|
-
instructions?: string;
|
|
235
|
-
lastUsedAt: number;
|
|
236
|
-
inFlight: number;
|
|
237
|
-
status: "connected" | "closed" | "needs-auth";
|
|
238
|
-
/** Whether this connection was constructed with the native OAuth provider. */
|
|
239
|
-
oauthProvider?: boolean;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
type UiStreamListener = (serverName: string, notification: ServerStreamResultPatchNotification["params"]) => void;
|
|
243
|
-
type MetadataListChangedListener = (serverName: string, reason: string) => void;
|
|
244
|
-
|
|
245
|
-
export class McpServerManager {
|
|
246
|
-
// Includes native clients whose public connect/close facade ended before their send/auth tail.
|
|
247
|
-
// Their operation promises remain in the same clientOperations authority until settlement.
|
|
248
|
-
private ownedClients = new Set<ManagedClient>();
|
|
249
|
-
private connections = new Map<string, ServerConnection>();
|
|
250
|
-
private connectPromises = new Map<string, Promise<ServerConnection>>();
|
|
251
|
-
private reconnectPromises = new Map<string, Promise<ServerConnection>>();
|
|
252
|
-
private retiredConnections = new Map<string, Set<ServerConnection>>();
|
|
253
|
-
private disposePromises = new WeakMap<ServerConnection, Promise<void>>();
|
|
254
|
-
private uiStreamListeners = new Map<string, UiStreamListener>();
|
|
255
|
-
private samplingConfig: ServerSamplingConfig | undefined;
|
|
256
|
-
private metadataListChangedListener: MetadataListChangedListener | undefined;
|
|
257
|
-
private elicitationConfig: ServerElicitationConfig | undefined;
|
|
258
|
-
private authStorageOptions: AuthStorageOptions = {};
|
|
259
|
-
private oauthRuntime: McpOAuthRuntime | undefined;
|
|
260
|
-
private acceptedUrlElicitations = new Map<string, Set<string>>();
|
|
261
|
-
private defaultRequestTimeoutMs: number | undefined;
|
|
262
|
-
private runtimeSignal: AbortSignal | undefined;
|
|
263
|
-
private closePromises = new Map<string, Promise<void>>();
|
|
264
|
-
private closeGenerations = new Map<string, number>();
|
|
265
|
-
private connectAttempts = new Map<string, AbortController>();
|
|
266
|
-
private traceSettings: McpTraceSettings | undefined;
|
|
267
|
-
private traceWriter: McpTraceWriter | undefined;
|
|
268
|
-
private stopped = false;
|
|
269
|
-
private checkpoint: McpCheckpointEvent | undefined;
|
|
270
|
-
private readonly beforeActivity = () => this.checkpoint?.invalidate();
|
|
271
|
-
|
|
272
|
-
holdCheckpoint(event: McpCheckpointEvent): () => void {
|
|
273
|
-
if (this.checkpoint) throw new Error("MCP manager checkpoint is already held");
|
|
274
|
-
this.checkpoint = event;
|
|
275
|
-
return () => { if (this.checkpoint === event) this.checkpoint = undefined; };
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
getCheckpointBlocker(): string | undefined {
|
|
279
|
-
if (this.stopped) return "MCP manager is stopped";
|
|
280
|
-
if (this.connectPromises.size || this.reconnectPromises.size || this.closePromises.size || this.retiredConnections.size)
|
|
281
|
-
return "MCP connection/discovery/retirement is active";
|
|
282
|
-
if (this.acceptedUrlElicitations.size || this.uiStreamListeners.size)
|
|
283
|
-
return "MCP browser elicitation/UI stream is pending";
|
|
284
|
-
for (const client of this.ownedClients) {
|
|
285
|
-
// Only the SDK's exact auto-opened catalog handle reconstructs during connect().
|
|
286
|
-
// An explicit consumer listen(), even with the same filter, still owns private state.
|
|
287
|
-
const kinds = [...(clientOperations.get(client) ?? [])]
|
|
288
|
-
.filter(([operation, kind]) => kind !== "subscription" || operation !== client.autoOpenedSubscription?.closed)
|
|
289
|
-
.map(([, kind]) => kind);
|
|
290
|
-
const callback = kinds.find(kind => kind === "sampling callback" || kind === "elicitation callback");
|
|
291
|
-
if (callback) return `MCP ${client.serverName}: ${callback} is active`;
|
|
292
|
-
if (kinds.includes("request/refresh")) return `MCP ${client.serverName}: request/refresh is active`;
|
|
293
|
-
if (kinds.includes("subscription")) return `MCP ${client.serverName}: subscription is active`;
|
|
294
|
-
}
|
|
295
|
-
for (const [name, connection] of this.connections) {
|
|
296
|
-
if (connection.inFlight) return `MCP ${name}: request/refresh is active`;
|
|
297
|
-
if (connection.client instanceof ManagedClient && connection.client.resourceSubscriptions.size)
|
|
298
|
-
return `MCP ${name}: resource subscription is active or unresolved`;
|
|
299
|
-
if (!(connection.transport instanceof StreamableHTTPClientTransport)) return `MCP ${name}: only stateless Streamable HTTP is checkpoint-supported`;
|
|
300
|
-
if (connection.transport.sessionId !== undefined) return `MCP ${name}: HTTP session state is not reconstructible`;
|
|
301
|
-
const capabilities = connection.client.getServerCapabilities();
|
|
302
|
-
if (capabilities?.tasks) return `MCP ${name}: negotiated remote task state is not checkpoint-supported`;
|
|
303
|
-
const opaque = Object.keys(capabilities ?? {}).find(key =>
|
|
304
|
-
!["tools", "resources", "prompts", "logging", "completions"].includes(key)
|
|
305
|
-
&& !(key === "experimental" && Object.keys(capabilities?.experimental ?? {}).length === 0));
|
|
306
|
-
if (opaque) return `MCP ${name}: unsupported negotiated capability ${opaque}`;
|
|
307
|
-
}
|
|
308
|
-
return undefined;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Diagnostics are best-effort, not session authority or a clean-exit prerequisite.
|
|
312
|
-
async flushForCheckpoint(): Promise<void> { await this.traceWriter?.flush(); }
|
|
313
|
-
|
|
314
|
-
private releaseClientWhenSettled(client: Client): void {
|
|
315
|
-
if (client instanceof ManagedClient) void drainClientOperations(client).then(() => this.ownedClients.delete(client));
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/** Fence before SDK callbacks AND before trace observers; retain native transport identity.
|
|
319
|
-
* Preserve existing property setters (including tracing) rather than replacing their behavior. */
|
|
320
|
-
private fenceTransport(transport: Transport, client: Client): void {
|
|
321
|
-
for (const key of ["onmessage", "onerror", "onclose"] as const) {
|
|
322
|
-
const descriptor = Object.getOwnPropertyDescriptor(transport, key);
|
|
323
|
-
let value = transport[key];
|
|
324
|
-
Object.defineProperty(transport, key, {
|
|
325
|
-
configurable: true,
|
|
326
|
-
get: () => {
|
|
327
|
-
const handler = descriptor?.get ? descriptor.get.call(transport) : value;
|
|
328
|
-
return handler ? (...args: unknown[]) => { this.beforeActivity(); return handler(...args); } : undefined;
|
|
329
|
-
},
|
|
330
|
-
set: next => { if (descriptor?.set) descriptor.set.call(transport, next); else value = next; },
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
if (typeof transport.send === "function") {
|
|
334
|
-
const send = transport.send.bind(transport);
|
|
335
|
-
transport.send = (...args) => {
|
|
336
|
-
this.beforeActivity();
|
|
337
|
-
return client instanceof ManagedClient ? client.ownSend(() => send(...args)) : send(...args);
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/** Default cwd for stdio servers without an explicit config `cwd`. */
|
|
343
|
-
constructor(private readonly defaultCwd?: string) {}
|
|
344
|
-
|
|
345
|
-
setSamplingConfig(config: ServerSamplingConfig | undefined): void {
|
|
346
|
-
this.samplingConfig = config;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
setMetadataListChangedListener(listener: MetadataListChangedListener | undefined): void {
|
|
350
|
-
this.metadataListChangedListener = listener;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
setElicitationConfig(config: ServerElicitationConfig | undefined): void {
|
|
354
|
-
this.elicitationConfig = config;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
setRuntimeSignal(signal: AbortSignal | undefined): void {
|
|
358
|
-
this.runtimeSignal = signal;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
setDefaultRequestTimeoutMs(timeoutMs: number | undefined): void {
|
|
362
|
-
this.defaultRequestTimeoutMs = normalizeRequestTimeoutMs(timeoutMs);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
setTraceConfig(settings: McpTraceSettings | undefined): void {
|
|
366
|
-
this.traceSettings = settings;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
setAuthStorageOptions(options: AuthStorageOptions): void {
|
|
370
|
-
this.authStorageOptions = options;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
setOAuthRuntime(runtime: McpOAuthRuntime): void {
|
|
374
|
-
this.oauthRuntime = runtime;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
getRequestOptions(name: string, signal?: AbortSignal): RequestOptions | undefined {
|
|
378
|
-
const connection = this.connections.get(name);
|
|
379
|
-
return this.buildRequestOptions(connection?.definition, signal);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
private getResolvedRequestTimeoutMs(definition?: ServerDefinition): number | undefined {
|
|
383
|
-
if (definition?.requestTimeoutMs !== undefined) {
|
|
384
|
-
return normalizeRequestTimeoutMs(definition.requestTimeoutMs);
|
|
385
|
-
}
|
|
386
|
-
return this.defaultRequestTimeoutMs;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
private buildRequestOptions(
|
|
390
|
-
definition?: ServerDefinition,
|
|
391
|
-
signal?: AbortSignal,
|
|
392
|
-
): RequestOptions | undefined {
|
|
393
|
-
const timeout = this.getResolvedRequestTimeoutMs(definition);
|
|
394
|
-
const ownedSignal = combineAbortSignals(this.runtimeSignal, signal);
|
|
395
|
-
|
|
396
|
-
if (!ownedSignal && timeout === undefined) {
|
|
397
|
-
return undefined;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
return {
|
|
401
|
-
...(ownedSignal ? { signal: ownedSignal } : {}),
|
|
402
|
-
...(timeout !== undefined ? { timeout } : {}),
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
async connect(name: string, definition: ServerDefinition, signal?: AbortSignal): Promise<ServerConnection> {
|
|
407
|
-
this.beforeActivity();
|
|
408
|
-
if (isServerDisabled(definition)) throw new Error(`MCP server "${name}" is disabled`);
|
|
409
|
-
if (this.stopped) throw new Error("MCP server manager is closed");
|
|
410
|
-
const ownedSignal = combineAbortSignals(this.runtimeSignal, signal);
|
|
411
|
-
throwIfAborted(ownedSignal);
|
|
412
|
-
const closing = this.closePromises.get(name);
|
|
413
|
-
if (closing) await abortable(closing, ownedSignal);
|
|
414
|
-
throwIfAborted(ownedSignal);
|
|
415
|
-
if (this.stopped) throw new Error("MCP server manager is closed");
|
|
416
|
-
|
|
417
|
-
// Dedupe concurrent connection attempts.
|
|
418
|
-
if (this.connectPromises.has(name)) {
|
|
419
|
-
return abortable(this.connectPromises.get(name)!, ownedSignal);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const existing = this.connections.get(name);
|
|
423
|
-
if (existing?.status === "connected") {
|
|
424
|
-
existing.lastUsedAt = Date.now();
|
|
425
|
-
return existing;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const generation = this.closeGenerations.get(name) ?? 0;
|
|
429
|
-
const attemptController = new AbortController();
|
|
430
|
-
const attemptSignal = combineAbortSignals(ownedSignal, attemptController.signal);
|
|
431
|
-
const promise = this.createConnection(name, definition, attemptSignal, ownedSignal).then(async connection => {
|
|
432
|
-
if (attemptController.signal.aborted || (this.closeGenerations.get(name) ?? 0) !== generation) {
|
|
433
|
-
await this.disposeConnection(connection);
|
|
434
|
-
throwIfAborted(attemptSignal);
|
|
435
|
-
throw new Error(`MCP connection for ${name} was closed while connecting`);
|
|
436
|
-
}
|
|
437
|
-
connection.inFlight = this.connections.get(name)?.inFlight ?? 0;
|
|
438
|
-
this.connections.set(name, connection);
|
|
439
|
-
return connection;
|
|
440
|
-
});
|
|
441
|
-
this.connectPromises.set(name, promise);
|
|
442
|
-
this.connectAttempts.set(name, attemptController);
|
|
443
|
-
|
|
444
|
-
try {
|
|
445
|
-
return await promise;
|
|
446
|
-
} finally {
|
|
447
|
-
if (this.connectPromises.get(name) === promise) this.connectPromises.delete(name);
|
|
448
|
-
if (this.connectAttempts.get(name) === attemptController) this.connectAttempts.delete(name);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* Reconnect a server whose connection was proven stale (e.g. by a 404
|
|
454
|
-
* "session no longer exists" response). Single-flight per server name —
|
|
455
|
-
* concurrent callers that raced to the same failure share one reconnect —
|
|
456
|
-
* and identity-guarded: `staleConnection` is only retired if it is
|
|
457
|
-
* still the manager's current connection for `name`. If a concurrent
|
|
458
|
-
* reconnect (or an unrelated connect()) already replaced it with a fresh
|
|
459
|
-
* connection, that fresh connection is returned untouched.
|
|
460
|
-
*/
|
|
461
|
-
async reconnect(
|
|
462
|
-
name: string,
|
|
463
|
-
definition: ServerDefinition,
|
|
464
|
-
staleConnection: ServerConnection,
|
|
465
|
-
signal?: AbortSignal,
|
|
466
|
-
): Promise<ServerConnection> {
|
|
467
|
-
if (isServerDisabled(definition)) throw new Error(`MCP server "${name}" is disabled`);
|
|
468
|
-
if (this.stopped) throw new Error("MCP server manager is closed");
|
|
469
|
-
const ownedSignal = combineAbortSignals(this.runtimeSignal, signal);
|
|
470
|
-
throwIfAborted(ownedSignal);
|
|
471
|
-
const inFlight = this.reconnectPromises.get(name);
|
|
472
|
-
if (inFlight) {
|
|
473
|
-
return abortable(inFlight, ownedSignal);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
const promise = this.doReconnect(name, definition, staleConnection, this.runtimeSignal).finally(() => {
|
|
477
|
-
if (this.reconnectPromises.get(name) === promise) {
|
|
478
|
-
this.reconnectPromises.delete(name);
|
|
479
|
-
}
|
|
480
|
-
});
|
|
481
|
-
this.reconnectPromises.set(name, promise);
|
|
482
|
-
return abortable(promise, ownedSignal);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
private async doReconnect(
|
|
486
|
-
name: string,
|
|
487
|
-
definition: ServerDefinition,
|
|
488
|
-
staleConnection: ServerConnection,
|
|
489
|
-
signal?: AbortSignal,
|
|
490
|
-
): Promise<ServerConnection> {
|
|
491
|
-
throwIfAborted(signal);
|
|
492
|
-
const current = this.connections.get(name);
|
|
493
|
-
|
|
494
|
-
// Never tear down a connection we didn't prove stale: if the map no
|
|
495
|
-
// longer holds the connection we were asked to replace, someone else
|
|
496
|
-
// already reconnected (or connected) first.
|
|
497
|
-
if (current !== staleConnection) {
|
|
498
|
-
if (!current) throw new Error(`Server "${name}" is not connected`);
|
|
499
|
-
return current;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
this.retire(name, staleConnection);
|
|
503
|
-
return this.connect(name, definition, signal);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/** Retire only the expected current client; accepted native work drains before close. */
|
|
507
|
-
retire(name: string, connection: ServerConnection): void {
|
|
508
|
-
this.beforeActivity();
|
|
509
|
-
if (this.connections.get(name) !== connection) return;
|
|
510
|
-
connection.status = "closed";
|
|
511
|
-
let retired = this.retiredConnections.get(name);
|
|
512
|
-
if (!retired) this.retiredConnections.set(name, retired = new Set());
|
|
513
|
-
if (retired.has(connection)) return;
|
|
514
|
-
retired.add(connection);
|
|
515
|
-
void (async () => {
|
|
516
|
-
for (;;) {
|
|
517
|
-
const pending = [...(clientOperations.get(connection.client) ?? [])]
|
|
518
|
-
.filter(([, kind]) => kind !== "subscription").map(([operation]) => operation);
|
|
519
|
-
if (!pending.length) break;
|
|
520
|
-
await Promise.allSettled(pending);
|
|
521
|
-
}
|
|
522
|
-
await this.disposeConnection(connection);
|
|
523
|
-
retired.delete(connection);
|
|
524
|
-
if (retired.size === 0 && this.retiredConnections.get(name) === retired) this.retiredConnections.delete(name);
|
|
525
|
-
})().catch(error => {
|
|
526
|
-
// Keep failed retirement owned so explicit shutdown also reports it.
|
|
527
|
-
logger.debug(`MCP retired connection cleanup failed for ${name}: ${String(error)}`);
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
private async createConnection(
|
|
532
|
-
name: string,
|
|
533
|
-
definition: ServerDefinition,
|
|
534
|
-
signal?: AbortSignal,
|
|
535
|
-
requestSignal?: AbortSignal,
|
|
536
|
-
): Promise<ServerConnection> {
|
|
537
|
-
throwIfAborted(signal);
|
|
538
|
-
validateServerProtocolConfig(definition);
|
|
539
|
-
let client = this.createClient(name, definition);
|
|
540
|
-
|
|
541
|
-
const tracingEnabled = isMcpTraceEnabled(definition, this.traceSettings);
|
|
542
|
-
const traceWriter = tracingEnabled
|
|
543
|
-
? (this.traceWriter ??= createMcpTraceWriter(this.defaultCwd, this.traceSettings ?? {}))
|
|
544
|
-
: undefined;
|
|
545
|
-
const traceObserver: McpTraceObserver | undefined = traceWriter
|
|
546
|
-
? { record: event => traceWriter.write(event) }
|
|
547
|
-
: undefined;
|
|
548
|
-
|
|
549
|
-
let transport: Transport;
|
|
550
|
-
let implicitOAuth = Boolean(definition.url && supportsOAuth(definition)
|
|
551
|
-
&& getOAuthRequest(name, resolveServerUrl(definition)!, getAuthBaseDir(this.authStorageOptions), this.oauthRuntime));
|
|
552
|
-
let oauthProvider = false;
|
|
553
|
-
let authError: Error | undefined;
|
|
554
|
-
const onAuthChallenge = (error: Error) => { authError = error; };
|
|
555
|
-
let stderrTail: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
|
556
|
-
const configuredTransports = [definition.command, definition.url, definition.socket]
|
|
557
|
-
.filter(value => typeof value === "string" && value.length > 0);
|
|
558
|
-
if (configuredTransports.length !== 1) {
|
|
559
|
-
throw new Error(`Server ${name} must configure exactly one of command, url, or socket`);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
if (definition.command) {
|
|
563
|
-
let command = definition.command;
|
|
564
|
-
let args = definition.args ?? [];
|
|
565
|
-
|
|
566
|
-
if (command === "npx" || command === "npm") {
|
|
567
|
-
const resolved = await resolveNpxBinary(command, args, signal);
|
|
568
|
-
if (resolved) {
|
|
569
|
-
command = resolved.isJs ? "node" : resolved.binPath;
|
|
570
|
-
args = resolved.isJs ? [resolved.binPath, ...resolved.extraArgs] : resolved.extraArgs;
|
|
571
|
-
logger.debug(`${name} resolved to ${resolved.binPath} (skipping npm parent)`);
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
throwIfAborted(signal);
|
|
575
|
-
|
|
576
|
-
const cwd = resolveConfigPath(definition.cwd) ?? this.defaultCwd;
|
|
577
|
-
const stdioTransport = new StdioClientTransport({
|
|
578
|
-
command,
|
|
579
|
-
args,
|
|
580
|
-
env: resolveEnv(definition.env, name),
|
|
581
|
-
...(cwd !== undefined ? { cwd } : {}),
|
|
582
|
-
stderr: definition.debug ? "inherit" : "pipe",
|
|
583
|
-
});
|
|
584
|
-
// Keep non-debug child diagnostics available for connection failures without
|
|
585
|
-
// retaining an unbounded stream or changing the existing debug behavior.
|
|
586
|
-
if (stdioTransport.stderr) {
|
|
587
|
-
stdioTransport.stderr.on("data", (chunk: Buffer | string) => {
|
|
588
|
-
stderrTail = appendStderrTail(stderrTail, chunk);
|
|
589
|
-
});
|
|
590
|
-
}
|
|
591
|
-
transport = stdioTransport;
|
|
592
|
-
} else if (definition.url) {
|
|
593
|
-
// HTTP transport with fallback
|
|
594
|
-
({ transport, oauthProvider } = this.createHttpTransport(definition, name, client, false, implicitOAuth, onAuthChallenge));
|
|
595
|
-
} else {
|
|
596
|
-
transport = new UnixSocketClientTransport(resolveConfigPath(definition.socket!)!);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
if (traceObserver) {
|
|
600
|
-
const traceTransportKindValue = traceTransportKind(definition, transport);
|
|
601
|
-
transport = wrapTransportWithMcpTrace(transport, name, traceTransportKindValue, traceObserver);
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
throwIfAborted(signal);
|
|
605
|
-
const requestOptions = this.buildRequestOptions(definition, requestSignal);
|
|
606
|
-
|
|
607
|
-
try {
|
|
608
|
-
let legacySse = false;
|
|
609
|
-
for (;;) {
|
|
610
|
-
authError = undefined;
|
|
611
|
-
try {
|
|
612
|
-
try {
|
|
613
|
-
await this.connectClientWithAbort(client, transport, requestOptions, signal);
|
|
614
|
-
} catch (error) {
|
|
615
|
-
// Only handshake failures establish that the HTTP endpoint is unsupported.
|
|
616
|
-
if (!definition.url || signal?.aborted || legacySse || !(error instanceof SdkHttpError)
|
|
617
|
-
|| ![404, 405, 406, 415].includes(error.status)) throw error;
|
|
618
|
-
legacySse = true;
|
|
619
|
-
await client.close();
|
|
620
|
-
this.releaseClientWhenSettled(client);
|
|
621
|
-
client = this.createClient(name, { ...definition, protocolVersion: "legacy" });
|
|
622
|
-
({ transport, oauthProvider } = this.createHttpTransport(definition, name, client, legacySse, implicitOAuth, onAuthChallenge));
|
|
623
|
-
if (traceObserver) transport = wrapTransportWithMcpTrace(transport, name, traceTransportKind(definition, transport), traceObserver);
|
|
624
|
-
continue;
|
|
625
|
-
}
|
|
626
|
-
this.attachAdapterNotificationHandlers(name, client);
|
|
627
|
-
|
|
628
|
-
const instructions = client.getInstructions?.();
|
|
629
|
-
const connection: ServerConnection = {
|
|
630
|
-
client,
|
|
631
|
-
transport,
|
|
632
|
-
definition,
|
|
633
|
-
tools: [],
|
|
634
|
-
resources: [],
|
|
635
|
-
prompts: [],
|
|
636
|
-
...(instructions !== undefined ? { instructions } : {}),
|
|
637
|
-
lastUsedAt: Date.now(),
|
|
638
|
-
inFlight: 0,
|
|
639
|
-
status: "connected",
|
|
640
|
-
oauthProvider,
|
|
641
|
-
};
|
|
642
|
-
|
|
643
|
-
// The public client hook preserves native transport cleanup. An old
|
|
644
|
-
// client's late close must never change its replacement's status.
|
|
645
|
-
client.onclose = () => {
|
|
646
|
-
if (this.connections.get(name) === connection) connection.status = "closed";
|
|
647
|
-
};
|
|
648
|
-
|
|
649
|
-
const discoveryOptions = this.buildRequestOptions(definition, signal);
|
|
650
|
-
const catalogs = [
|
|
651
|
-
this.fetchAllTools(client, discoveryOptions),
|
|
652
|
-
this.fetchAllResources(client, discoveryOptions),
|
|
653
|
-
this.fetchAllPrompts(client, discoveryOptions),
|
|
654
|
-
] as const;
|
|
655
|
-
const [toolResult] = await Promise.allSettled(catalogs);
|
|
656
|
-
throwIfAborted(signal);
|
|
657
|
-
if (toolResult.status === "rejected" && !isOAuthChallenge(toolResult.reason)) throw toolResult.reason;
|
|
658
|
-
// Optional catalogs catch their errors; the native observer still sees auth challenges.
|
|
659
|
-
if (authError) throw authError;
|
|
660
|
-
const [tools, resources, promptResult] = await Promise.all(catalogs);
|
|
661
|
-
connection.tools = tools;
|
|
662
|
-
connection.resources = resources;
|
|
663
|
-
connection.prompts = promptResult.prompts;
|
|
664
|
-
connection.promptDiscoveryFailed = promptResult.failed;
|
|
665
|
-
return connection;
|
|
666
|
-
} catch (error) {
|
|
667
|
-
if (!definition.url || signal?.aborted || implicitOAuth || definition.auth !== undefined
|
|
668
|
-
|| !supportsOAuth(definition) || !isOAuthChallenge(error)) throw error;
|
|
669
|
-
implicitOAuth = true;
|
|
670
|
-
await client.close();
|
|
671
|
-
this.releaseClientWhenSettled(client);
|
|
672
|
-
client = this.createClient(name, legacySse ? { ...definition, protocolVersion: "legacy" } : definition);
|
|
673
|
-
({ transport, oauthProvider } = this.createHttpTransport(definition, name, client, legacySse, implicitOAuth, onAuthChallenge));
|
|
674
|
-
if (traceObserver) transport = wrapTransportWithMcpTrace(transport, name, traceTransportKind(definition, transport), traceObserver);
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
} catch (error) {
|
|
678
|
-
// If connectClientWithAbort closed the transport, await that exact close.
|
|
679
|
-
// Otherwise the SDK client owns its transport, so client.close() is the
|
|
680
|
-
// single cleanup operation rather than closing the transport twice.
|
|
681
|
-
const abortCleanup = abortCleanupPromises.get(transport);
|
|
682
|
-
const abortCleanupFailed = error instanceof AggregateError && error.message === "MCP connection abort cleanup failed";
|
|
683
|
-
const cleanupResults = abortCleanupFailed
|
|
684
|
-
? []
|
|
685
|
-
: await Promise.allSettled([
|
|
686
|
-
abortCleanup ?? Promise.resolve().then(() => client.close()),
|
|
687
|
-
]);
|
|
688
|
-
await drainClientCallbacks(client);
|
|
689
|
-
this.releaseClientWhenSettled(client);
|
|
690
|
-
const cleanupFailures = cleanupResults.flatMap(result => result.status === "rejected" ? [result.reason] : []);
|
|
691
|
-
let reportedError: unknown = error;
|
|
692
|
-
if (cleanupFailures.length > 0) {
|
|
693
|
-
reportedError = new AggregateError([error, ...cleanupFailures], "MCP connection setup failed");
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// Check for UnauthorizedError - server requires OAuth. A cleanup failure
|
|
697
|
-
// remains a setup failure rather than being hidden behind needs-auth.
|
|
698
|
-
if (isOAuthChallenge(error) && supportsOAuth(definition) && cleanupFailures.length === 0) {
|
|
699
|
-
const connection: ServerConnection = {
|
|
700
|
-
client,
|
|
701
|
-
transport,
|
|
702
|
-
definition,
|
|
703
|
-
tools: [],
|
|
704
|
-
resources: [],
|
|
705
|
-
prompts: [],
|
|
706
|
-
lastUsedAt: Date.now(),
|
|
707
|
-
inFlight: 0,
|
|
708
|
-
status: "needs-auth",
|
|
709
|
-
oauthProvider,
|
|
710
|
-
};
|
|
711
|
-
this.disposePromises.set(connection, Promise.resolve());
|
|
712
|
-
return connection;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
if (stderrTail.length > 0) {
|
|
716
|
-
const stderrText = stderrTail.toString("utf8").trim();
|
|
717
|
-
const lines = stderrText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
718
|
-
if (lines.length > 0) {
|
|
719
|
-
const baseMessage = reportedError instanceof Error ? reportedError.message : String(reportedError);
|
|
720
|
-
const detail = lines.slice(-MAX_CAPTURED_STDERR_LINES).join(" — ");
|
|
721
|
-
throw new Error(`${baseMessage} (${detail})`, { cause: reportedError });
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
throw reportedError;
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
private async connectClientWithAbort(
|
|
729
|
-
client: Client,
|
|
730
|
-
transport: Transport,
|
|
731
|
-
requestOptions?: RequestOptions,
|
|
732
|
-
signal?: AbortSignal,
|
|
733
|
-
): Promise<void> {
|
|
734
|
-
throwIfAborted(signal);
|
|
735
|
-
this.fenceTransport(transport, client);
|
|
736
|
-
let abortCleanup: Promise<void> | undefined;
|
|
737
|
-
const closeTransport = () => {
|
|
738
|
-
abortCleanup = Promise.resolve().then(() => transport.close());
|
|
739
|
-
abortCleanupPromises.set(transport, abortCleanup);
|
|
740
|
-
};
|
|
741
|
-
signal?.addEventListener("abort", closeTransport, { once: true });
|
|
742
|
-
try {
|
|
743
|
-
await abortable(client.connect(transport, requestOptions), signal);
|
|
744
|
-
await abortCleanup;
|
|
745
|
-
} catch (error) {
|
|
746
|
-
if (abortCleanup) {
|
|
747
|
-
try {
|
|
748
|
-
await abortCleanup;
|
|
749
|
-
} catch (cleanupError) {
|
|
750
|
-
throw new AggregateError([error, cleanupError], "MCP connection abort cleanup failed");
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
throw error;
|
|
754
|
-
} finally {
|
|
755
|
-
signal?.removeEventListener("abort", closeTransport);
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
private buildClientCapabilities(definition: ServerDefinition) {
|
|
760
|
-
return {
|
|
761
|
-
...(supportsOAuth(definition) && isNonInteractiveOAuth(definition.oauth)
|
|
762
|
-
? { extensions: { [definition.oauth && definition.oauth.crossAppAccess
|
|
763
|
-
? "io.modelcontextprotocol/enterprise-managed-authorization" : "io.modelcontextprotocol/oauth-client-credentials"]: {} } } : {}),
|
|
764
|
-
...(this.samplingConfig ? { sampling: {} } : {}),
|
|
765
|
-
...(this.elicitationConfig
|
|
766
|
-
? {
|
|
767
|
-
elicitation: {
|
|
768
|
-
form: {},
|
|
769
|
-
...(this.elicitationConfig.allowUrl ? { url: {} } : {}),
|
|
770
|
-
},
|
|
771
|
-
}
|
|
772
|
-
: {}),
|
|
773
|
-
};
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
private createClient(serverName: string, definition: ServerDefinition): ManagedClient {
|
|
777
|
-
const capabilities = this.buildClientCapabilities(definition);
|
|
778
|
-
let client: ManagedClient;
|
|
779
|
-
client = new ManagedClient(
|
|
780
|
-
{ name: `pi-mcp-${serverName}`, version: "4.3.1" },
|
|
781
|
-
{
|
|
782
|
-
versionNegotiation: { mode: definition.protocolVersion ?? (definition.url ? "auto" : "legacy") },
|
|
783
|
-
listMaxPages: 0,
|
|
784
|
-
...(Object.keys(capabilities).length > 0 ? { capabilities } : {}),
|
|
785
|
-
listChanged: {
|
|
786
|
-
tools: {
|
|
787
|
-
onChanged: (error: Error | null, tools: McpTool[] | null) => {
|
|
788
|
-
this.handleToolsListChanged(serverName, client, error, tools);
|
|
789
|
-
},
|
|
790
|
-
},
|
|
791
|
-
resources: {
|
|
792
|
-
onChanged: (error: Error | null, resources: McpResource[] | null) => {
|
|
793
|
-
this.handleResourcesListChanged(serverName, client, error, resources);
|
|
794
|
-
},
|
|
795
|
-
},
|
|
796
|
-
prompts: {
|
|
797
|
-
onChanged: (error: Error | null, prompts: McpPrompt[] | null) => {
|
|
798
|
-
this.handlePromptsListChanged(serverName, client, error, prompts);
|
|
799
|
-
},
|
|
800
|
-
},
|
|
801
|
-
},
|
|
802
|
-
},
|
|
803
|
-
);
|
|
804
|
-
client.serverName = serverName;
|
|
805
|
-
this.ownedClients.add(client);
|
|
806
|
-
client.beforeActivity = this.beforeActivity;
|
|
807
|
-
if (this.samplingConfig) {
|
|
808
|
-
registerSamplingHandler(client, { ...this.samplingConfig, serverName }, run => client.ownCallback("sampling callback", run));
|
|
809
|
-
}
|
|
810
|
-
if (this.elicitationConfig) {
|
|
811
|
-
registerElicitationHandler(client, {
|
|
812
|
-
...this.elicitationConfig,
|
|
813
|
-
serverName,
|
|
814
|
-
onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId),
|
|
815
|
-
}, run => client.ownCallback("elicitation callback", run));
|
|
816
|
-
if (this.elicitationConfig.allowUrl) {
|
|
817
|
-
client.setNotificationHandler("notifications/elicitation/complete", notification => {
|
|
818
|
-
if (this.runtimeSignal?.aborted) return;
|
|
819
|
-
const accepted = this.acceptedUrlElicitations.get(serverName);
|
|
820
|
-
if (!accepted?.delete(notification.params.elicitationId)) return;
|
|
821
|
-
if (accepted.size === 0) this.acceptedUrlElicitations.delete(serverName);
|
|
822
|
-
this.elicitationConfig?.ui.notify(
|
|
823
|
-
`MCP browser interaction for ${serverName} completed. You can retry the tool now.`,
|
|
824
|
-
"info",
|
|
825
|
-
);
|
|
826
|
-
});
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
return client;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
private handleToolsListChanged(
|
|
833
|
-
serverName: string,
|
|
834
|
-
client: Client,
|
|
835
|
-
error: Error | null,
|
|
836
|
-
tools: McpTool[] | null,
|
|
837
|
-
): void {
|
|
838
|
-
this.beforeActivity();
|
|
839
|
-
if (error) {
|
|
840
|
-
logger.debug(`MCP: tools/list_changed refresh failed for ${serverName}: ${error.message}`);
|
|
841
|
-
return;
|
|
842
|
-
}
|
|
843
|
-
if (!tools) return;
|
|
844
|
-
const connection = this.connections.get(serverName);
|
|
845
|
-
if (!connection || connection.client !== client || connection.status !== "connected") return;
|
|
846
|
-
connection.tools = tools;
|
|
847
|
-
this.metadataListChangedListener?.(serverName, "tools-list-changed");
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
private handlePromptsListChanged(
|
|
851
|
-
serverName: string,
|
|
852
|
-
client: Client,
|
|
853
|
-
error: Error | null,
|
|
854
|
-
prompts: McpPrompt[] | null,
|
|
855
|
-
): void {
|
|
856
|
-
this.beforeActivity();
|
|
857
|
-
if (error) {
|
|
858
|
-
logger.debug(`MCP: prompts/list_changed refresh failed for ${serverName}: ${error.message}`);
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
if (!prompts) return;
|
|
862
|
-
const connection = this.connections.get(serverName);
|
|
863
|
-
if (!connection || connection.client !== client || connection.status !== "connected") return;
|
|
864
|
-
connection.prompts = prompts;
|
|
865
|
-
connection.promptDiscoveryFailed = false;
|
|
866
|
-
this.metadataListChangedListener?.(serverName, "prompts-list-changed");
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
private handleResourcesListChanged(
|
|
870
|
-
serverName: string,
|
|
871
|
-
client: Client,
|
|
872
|
-
error: Error | null,
|
|
873
|
-
resources: McpResource[] | null,
|
|
874
|
-
): void {
|
|
875
|
-
this.beforeActivity();
|
|
876
|
-
if (error) {
|
|
877
|
-
logger.debug(`MCP: resources/list_changed refresh failed for ${serverName}: ${error.message}`);
|
|
878
|
-
return;
|
|
879
|
-
}
|
|
880
|
-
if (!resources) return;
|
|
881
|
-
const connection = this.connections.get(serverName);
|
|
882
|
-
if (!connection || connection.client !== client || connection.status !== "connected") return;
|
|
883
|
-
connection.resources = resources;
|
|
884
|
-
this.metadataListChangedListener?.(serverName, "resources-list-changed");
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
async handleUrlElicitationRequired(
|
|
888
|
-
serverName: string,
|
|
889
|
-
error: UrlElicitationRequiredError,
|
|
890
|
-
): Promise<"accept" | "decline" | "cancel"> {
|
|
891
|
-
if (this.runtimeSignal?.aborted || !this.elicitationConfig?.allowUrl) return "cancel";
|
|
892
|
-
for (const params of error.elicitations) {
|
|
893
|
-
const result = await handleUrlElicitation({
|
|
894
|
-
...this.elicitationConfig,
|
|
895
|
-
serverName,
|
|
896
|
-
onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId),
|
|
897
|
-
}, params);
|
|
898
|
-
if (result.action !== "accept") return result.action;
|
|
899
|
-
}
|
|
900
|
-
return "accept";
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
private rememberUrlElicitation(serverName: string, elicitationId: string): void {
|
|
904
|
-
if (this.runtimeSignal?.aborted) return;
|
|
905
|
-
let accepted = this.acceptedUrlElicitations.get(serverName);
|
|
906
|
-
if (!accepted) {
|
|
907
|
-
accepted = new Set();
|
|
908
|
-
this.acceptedUrlElicitations.set(serverName, accepted);
|
|
909
|
-
}
|
|
910
|
-
accepted.add(elicitationId);
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
private createHttpTransport(
|
|
914
|
-
definition: ServerDefinition,
|
|
915
|
-
serverName: string,
|
|
916
|
-
client: ManagedClient,
|
|
917
|
-
legacySse = false,
|
|
918
|
-
implicitOAuth = false,
|
|
919
|
-
onAuthChallenge?: (error: Error) => void,
|
|
920
|
-
): { transport: Transport; oauthProvider: boolean } {
|
|
921
|
-
const serverUrl = resolveServerUrl(definition)!;
|
|
922
|
-
const url = new URL(serverUrl);
|
|
923
|
-
const storageBase = getAuthBaseDir(this.authStorageOptions);
|
|
924
|
-
const runtime = this.oauthRuntime;
|
|
925
|
-
const generation = this.closeGenerations.get(serverName) ?? 0;
|
|
926
|
-
const active = () => !this.stopped && !this.runtimeSignal?.aborted && !runtime?.signal.aborted
|
|
927
|
-
&& (this.closeGenerations.get(serverName) ?? 0) === generation;
|
|
928
|
-
let issuer: string | undefined;
|
|
929
|
-
|
|
930
|
-
// Resolve secret commands only for this connection attempt, without mutating config.
|
|
931
|
-
const hasCommandHeader = Object.values(definition.headers ?? {})
|
|
932
|
-
.some(value => value.startsWith("!") && !value.startsWith("!!"));
|
|
933
|
-
const headers = resolveCommandSecretsRecord(
|
|
934
|
-
definition.headers,
|
|
935
|
-
key => `MCP server "${serverName}" HTTP header "${key}"`,
|
|
936
|
-
) ?? {};
|
|
937
|
-
|
|
938
|
-
// For bearer auth, add the token to headers BEFORE creating requestInit
|
|
939
|
-
const commandBearer = definition.bearerToken?.startsWith("!") && !definition.bearerToken.startsWith("!!")
|
|
940
|
-
? definition.bearerToken
|
|
941
|
-
: undefined;
|
|
942
|
-
if (definition.auth === "bearer") {
|
|
943
|
-
const token = commandBearer
|
|
944
|
-
? resolveCommandSecret(commandBearer, `MCP server "${serverName}" HTTP bearer token`)
|
|
945
|
-
: resolveBearerToken(definition);
|
|
946
|
-
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
if (hasCommandHeader || commandBearer) {
|
|
950
|
-
try {
|
|
951
|
-
new Headers(headers);
|
|
952
|
-
} catch {
|
|
953
|
-
throw new Error(`Failed to resolve MCP server "${serverName}" HTTP command secret: command returned an invalid header value`);
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// Create request init with headers (Authorization now included for bearer auth)
|
|
958
|
-
const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined;
|
|
959
|
-
const createAuthProvider = (): McpOAuthProvider => new McpOAuthProvider(
|
|
960
|
-
serverName,
|
|
961
|
-
serverUrl,
|
|
962
|
-
extractOAuthConfig(definition),
|
|
963
|
-
{
|
|
964
|
-
onRedirect: async (_authUrl) => {
|
|
965
|
-
// URL is captured by startAuth, no need to log
|
|
966
|
-
},
|
|
967
|
-
onDiscoveryState: state => {
|
|
968
|
-
issuer = state.authorizationServerMetadata?.issuer ?? state.authorizationServerUrl;
|
|
969
|
-
if (active()) bindOAuthRequestIssuer(serverName, serverUrl, storageBase, issuer, runtime);
|
|
970
|
-
},
|
|
971
|
-
},
|
|
972
|
-
this.authStorageOptions,
|
|
973
|
-
this.oauthRuntime?.signal,
|
|
974
|
-
);
|
|
975
|
-
|
|
976
|
-
const authProvider = supportsOAuth(definition) && (definition.auth === "oauth" || implicitOAuth)
|
|
977
|
-
? createAuthProvider() : undefined;
|
|
978
|
-
client.oauthProvider = authProvider;
|
|
979
|
-
let transportClosed = false;
|
|
980
|
-
// The SDK launches legacy GET bootstrap/reconnect outside send(). Fence every
|
|
981
|
-
// provider entry and own its ACTUAL promise (including signing/storage after
|
|
982
|
-
// cancellation). Keep the OAuthClientProvider shape: native 401/403 behavior,
|
|
983
|
-
// issuer validation and noninteractive grants remain SDK-owned.
|
|
984
|
-
const ownedProvider = authProvider && new Proxy(authProvider, {
|
|
985
|
-
get: (provider, key) => {
|
|
986
|
-
const value = Reflect.get(provider, key, provider);
|
|
987
|
-
if (typeof value !== "function") return value;
|
|
988
|
-
return (...args: unknown[]) => client.ownNative(async () => {
|
|
989
|
-
provider.lifetimeSignal.throwIfAborted();
|
|
990
|
-
return Reflect.apply(value, provider, args);
|
|
991
|
-
});
|
|
992
|
-
},
|
|
993
|
-
});
|
|
994
|
-
const fetch: FetchLike = (input, init) => client.ownNative(async () => {
|
|
995
|
-
if (transportClosed) throw new Error("MCP transport is closed");
|
|
996
|
-
const response = await trackToolTransportFailure(input, authProvider ? {
|
|
997
|
-
...init, signal: combineAbortSignals(init?.signal ?? undefined,
|
|
998
|
-
init?.signal ? authProvider.lifetimeSignal : authProvider.signal)!,
|
|
999
|
-
} : init);
|
|
1000
|
-
// Fetch resolves at headers. Native auth/discovery subsequently consumes
|
|
1001
|
-
// text/JSON bodies; keep those real reads owned too. Never count the idle
|
|
1002
|
-
// SSE body as a perpetual request: its messages use the ingress fence.
|
|
1003
|
-
for (const method of ["text", "json"] as const) {
|
|
1004
|
-
const read = response[method].bind(response);
|
|
1005
|
-
response[method] = () => client.ownNative(read);
|
|
1006
|
-
}
|
|
1007
|
-
return response;
|
|
1008
|
-
});
|
|
1009
|
-
// Interactive transports already throw on insufficient_scope rather than
|
|
1010
|
-
// running SDK step-up. The supported minimal AuthProvider seam lets us own
|
|
1011
|
-
// the ENTIRE native 401 auth promise, including PKCE computation between
|
|
1012
|
-
// provider calls. Noninteractive transports retain OAuthClientProvider so
|
|
1013
|
-
// the SDK's 403 scope-union/retry policy is unchanged; all their asynchronous
|
|
1014
|
-
// effects are provider calls (including JWT signing) and fetch/body reads.
|
|
1015
|
-
const transportAuth: (AuthProvider & Pick<McpOAuthProvider, "redirectUrl" | "clientMetadata">) | McpOAuthProvider | undefined = ownedProvider
|
|
1016
|
-
&& !isNonInteractiveOAuth(definition.oauth) && !legacySse ? {
|
|
1017
|
-
// Preserve transport configuration introspection; auth() uses this same provider.
|
|
1018
|
-
get redirectUrl() { return ownedProvider.redirectUrl; },
|
|
1019
|
-
get clientMetadata() { return ownedProvider.clientMetadata; },
|
|
1020
|
-
token: () => client.ownNative(async () => (await ownedProvider.tokens())?.access_token),
|
|
1021
|
-
onUnauthorized: context => client.ownNative(async () => {
|
|
1022
|
-
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(context.response);
|
|
1023
|
-
const result = await auth(ownedProvider, {
|
|
1024
|
-
serverUrl: context.serverUrl, fetchFn: context.fetchFn,
|
|
1025
|
-
...(resourceMetadataUrl !== undefined ? { resourceMetadataUrl } : {}),
|
|
1026
|
-
...(scope !== undefined ? { scope } : {}),
|
|
1027
|
-
skipIssuerMetadataValidation: definition.oauth !== false && definition.oauth?.skipIssuerMetadataValidation === true,
|
|
1028
|
-
});
|
|
1029
|
-
if (result !== "AUTHORIZED") throw new UnauthorizedError();
|
|
1030
|
-
}),
|
|
1031
|
-
} : ownedProvider;
|
|
1032
|
-
const options = {
|
|
1033
|
-
...(requestInit !== undefined ? { requestInit } : {}),
|
|
1034
|
-
...(transportAuth !== undefined ? { authProvider: transportAuth } : {}),
|
|
1035
|
-
skipIssuerMetadataValidation: definition.oauth !== false && definition.oauth?.skipIssuerMetadataValidation === true,
|
|
1036
|
-
fetch,
|
|
1037
|
-
};
|
|
1038
|
-
const transport: Transport = legacySse ? new SSEClientTransport(url, options) : new StreamableHTTPClientTransport(url, {
|
|
1039
|
-
...options,
|
|
1040
|
-
reconnectionScheduler: (reconnect, delay) => {
|
|
1041
|
-
const timer = setTimeout(() => {
|
|
1042
|
-
if (transportClosed) return;
|
|
1043
|
-
this.beforeActivity();
|
|
1044
|
-
reconnect();
|
|
1045
|
-
}, delay);
|
|
1046
|
-
return () => clearTimeout(timer);
|
|
1047
|
-
},
|
|
1048
|
-
...(supportsOAuth(definition) && !isNonInteractiveOAuth(definition.oauth)
|
|
1049
|
-
? { onInsufficientScope: "throw" as const } : {}),
|
|
1050
|
-
});
|
|
1051
|
-
if (authProvider) {
|
|
1052
|
-
const send = transport.send.bind(transport);
|
|
1053
|
-
transport.send = async (message, options) => authProvider.runWithSignal(options?.requestSignal, () => send(message, options));
|
|
1054
|
-
}
|
|
1055
|
-
const close = transport.close.bind(transport);
|
|
1056
|
-
transport.close = () => {
|
|
1057
|
-
transportClosed = true;
|
|
1058
|
-
// Native OAuth fetches do not inherit the transport's abort signal.
|
|
1059
|
-
authProvider?.deactivate();
|
|
1060
|
-
return close();
|
|
1061
|
-
};
|
|
1062
|
-
if (!legacySse && supportsOAuth(definition)) {
|
|
1063
|
-
const previous = transport.onerror;
|
|
1064
|
-
transport.onerror = error => {
|
|
1065
|
-
previous?.(error);
|
|
1066
|
-
if (!active() || isServerDisabled(definition) || !supportsOAuth(definition) || !isOAuthChallenge(error)) return;
|
|
1067
|
-
recordOAuthChallenge(serverName, serverUrl, storageBase, error, runtime);
|
|
1068
|
-
// A known issuer was validated by the native discovery hook, never inferred from the challenge.
|
|
1069
|
-
if (issuer) {
|
|
1070
|
-
const request = getOAuthRequest(serverName, serverUrl, storageBase, runtime);
|
|
1071
|
-
if (request && !request.issuer) request.issuer = issuer;
|
|
1072
|
-
}
|
|
1073
|
-
onAuthChallenge?.(error);
|
|
1074
|
-
};
|
|
1075
|
-
}
|
|
1076
|
-
if (!legacySse) trackToolHttpFailures(transport);
|
|
1077
|
-
return { transport, oauthProvider: authProvider !== undefined };
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
private async fetchAllTools(client: Client, requestOptions?: RequestOptions): Promise<McpTool[]> {
|
|
1081
|
-
return (await client.listTools(undefined, { ...requestOptions, cacheMode: "refresh" })).tools;
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
private async fetchAllPrompts(
|
|
1085
|
-
client: Client,
|
|
1086
|
-
requestOptions?: RequestOptions,
|
|
1087
|
-
): Promise<{ prompts: McpPrompt[]; failed: boolean }> {
|
|
1088
|
-
const capabilities = client.getServerCapabilities?.();
|
|
1089
|
-
if (!capabilities?.prompts) return { prompts: [], failed: false };
|
|
1090
|
-
|
|
1091
|
-
try {
|
|
1092
|
-
const { prompts } = await client.listPrompts(undefined, { ...requestOptions, cacheMode: "refresh" });
|
|
1093
|
-
return { prompts, failed: false };
|
|
1094
|
-
} catch (error) {
|
|
1095
|
-
if (requestOptions?.signal?.aborted) throwIfAborted(requestOptions.signal);
|
|
1096
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1097
|
-
logger.debug(`MCP: prompts/list failed: ${message}`);
|
|
1098
|
-
return { prompts: [], failed: true };
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
private async fetchAllResources(client: Client, requestOptions?: RequestOptions): Promise<McpResource[]> {
|
|
1103
|
-
const capabilities = client.getServerCapabilities?.();
|
|
1104
|
-
if (!capabilities?.resources) return [];
|
|
1105
|
-
|
|
1106
|
-
try {
|
|
1107
|
-
return (await client.listResources(undefined, { ...requestOptions, cacheMode: "refresh" })).resources;
|
|
1108
|
-
} catch {
|
|
1109
|
-
if (requestOptions?.signal?.aborted) {
|
|
1110
|
-
throwIfAborted(requestOptions.signal);
|
|
1111
|
-
}
|
|
1112
|
-
// The server advertises resources but the listing failed
|
|
1113
|
-
return [];
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
private attachAdapterNotificationHandlers(serverName: string, client: Client): void {
|
|
1118
|
-
client.setNotificationHandler(serverStreamResultPatchNotificationSchema.shape.method.value,
|
|
1119
|
-
{ params: serverStreamResultPatchNotificationSchema.shape.params }, params => {
|
|
1120
|
-
this.uiStreamListeners.get(params.streamToken)?.(serverName, params);
|
|
1121
|
-
});
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
registerUiStreamListener(streamToken: string, listener: UiStreamListener): void {
|
|
1125
|
-
this.uiStreamListeners.set(streamToken, listener);
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
removeUiStreamListener(streamToken: string): void {
|
|
1129
|
-
this.uiStreamListeners.delete(streamToken);
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
async getPrompt(
|
|
1133
|
-
name: string,
|
|
1134
|
-
promptName: string,
|
|
1135
|
-
args?: Record<string, string>,
|
|
1136
|
-
signal?: AbortSignal,
|
|
1137
|
-
): Promise<GetPromptResult> {
|
|
1138
|
-
const connection = this.connections.get(name);
|
|
1139
|
-
if (!connection || connection.status !== "connected") {
|
|
1140
|
-
throw new Error(`Server "${name}" is not connected`);
|
|
1141
|
-
}
|
|
1142
|
-
try {
|
|
1143
|
-
this.touch(name);
|
|
1144
|
-
this.incrementInFlight(name);
|
|
1145
|
-
return await connection.client.getPrompt(
|
|
1146
|
-
{ name: promptName, ...(args ? { arguments: args } : {}) },
|
|
1147
|
-
this.getRequestOptions(name, signal),
|
|
1148
|
-
);
|
|
1149
|
-
} finally {
|
|
1150
|
-
this.decrementInFlight(name);
|
|
1151
|
-
this.touch(name);
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
async readResource(name: string, uri: string, signal?: AbortSignal): Promise<ReadResourceResult> {
|
|
1156
|
-
if (isServerDisabled(this.connections.get(name)?.definition)) {
|
|
1157
|
-
throw new Error(`MCP server "${name}" is disabled`);
|
|
1158
|
-
}
|
|
1159
|
-
const connection = this.connections.get(name);
|
|
1160
|
-
if (!connection || connection.status !== "connected") {
|
|
1161
|
-
throw new Error(`Server "${name}" is not connected`);
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
try {
|
|
1165
|
-
this.touch(name);
|
|
1166
|
-
this.incrementInFlight(name);
|
|
1167
|
-
return await connection.client.readResource({ uri }, this.getRequestOptions(name, signal));
|
|
1168
|
-
} finally {
|
|
1169
|
-
this.decrementInFlight(name);
|
|
1170
|
-
this.touch(name);
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
async close(name: string): Promise<void> {
|
|
1175
|
-
this.beforeActivity();
|
|
1176
|
-
this.closeGenerations.set(name, (this.closeGenerations.get(name) ?? 0) + 1);
|
|
1177
|
-
this.connectAttempts.get(name)?.abort(new Error(`MCP connection ${name} was closed`));
|
|
1178
|
-
const pendingClose = this.closePromises.get(name);
|
|
1179
|
-
if (pendingClose) return pendingClose;
|
|
1180
|
-
|
|
1181
|
-
const owned = new Set(this.retiredConnections.get(name));
|
|
1182
|
-
const connection = this.connections.get(name);
|
|
1183
|
-
if (connection) owned.add(connection);
|
|
1184
|
-
// Keep failed cleanup/callback tails owned even after removal from the active connection map.
|
|
1185
|
-
if (owned.size) this.retiredConnections.set(name, owned);
|
|
1186
|
-
// Remove the placeholder before awaiting cleanup; stale callers cannot resurrect it.
|
|
1187
|
-
this.connections.delete(name);
|
|
1188
|
-
this.acceptedUrlElicitations.delete(name);
|
|
1189
|
-
for (const item of owned) item.status = "closed";
|
|
1190
|
-
const pendingConnect = this.connectPromises.get(name);
|
|
1191
|
-
const closing = (async () => {
|
|
1192
|
-
const results = await Promise.allSettled([
|
|
1193
|
-
...[...owned].map(item => this.disposeConnection(item)),
|
|
1194
|
-
pendingConnect?.catch(error => {
|
|
1195
|
-
if (this.containsCleanupFailure(error)) throw error;
|
|
1196
|
-
}),
|
|
1197
|
-
]);
|
|
1198
|
-
const failures = results.flatMap(result => result.status === "rejected" ? [result.reason] : []);
|
|
1199
|
-
if (failures.length > 0) throw new AggregateError(failures, "MCP connection cleanup failed");
|
|
1200
|
-
this.retiredConnections.delete(name);
|
|
1201
|
-
})().finally(() => {
|
|
1202
|
-
if (this.closePromises.get(name) === closing) this.closePromises.delete(name);
|
|
1203
|
-
});
|
|
1204
|
-
this.closePromises.set(name, closing);
|
|
1205
|
-
return closing;
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
private disposeConnection(connection: ServerConnection): Promise<void> {
|
|
1209
|
-
const existing = this.disposePromises.get(connection);
|
|
1210
|
-
if (existing) return existing;
|
|
1211
|
-
const closing = (async () => {
|
|
1212
|
-
const results = await Promise.allSettled([
|
|
1213
|
-
// The SDK client owns its transport.
|
|
1214
|
-
Promise.resolve().then(() => connection.client.close()),
|
|
1215
|
-
this.traceWriter?.flush() ?? Promise.resolve(),
|
|
1216
|
-
]);
|
|
1217
|
-
const failures = results.flatMap(result => result.status === "rejected" ? [result.reason] : []);
|
|
1218
|
-
// Keep ordinary per-server cancellation prompt, but retain native send/auth tails for
|
|
1219
|
-
// readiness and final shutdown. Inbound handler promises still drain before disposal.
|
|
1220
|
-
await drainClientCallbacks(connection.client);
|
|
1221
|
-
if (failures.length > 0) throw new AggregateError(failures, "MCP connection cleanup failed");
|
|
1222
|
-
this.releaseClientWhenSettled(connection.client);
|
|
1223
|
-
})();
|
|
1224
|
-
this.disposePromises.set(connection, closing);
|
|
1225
|
-
void closing.catch(() => {
|
|
1226
|
-
// Native close retains its transport until onclose. Retry only on an
|
|
1227
|
-
// explicit later close, retaining the connection/operations veto meanwhile.
|
|
1228
|
-
if (connection.client.transport === connection.transport && this.disposePromises.get(connection) === closing)
|
|
1229
|
-
this.disposePromises.delete(connection);
|
|
1230
|
-
});
|
|
1231
|
-
return closing;
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
async closeAll(): Promise<void> {
|
|
1235
|
-
this.stopped = true;
|
|
1236
|
-
const names = new Set([
|
|
1237
|
-
...this.connections.keys(), ...this.retiredConnections.keys(),
|
|
1238
|
-
...this.connectPromises.keys(), ...this.closePromises.keys(),
|
|
1239
|
-
]);
|
|
1240
|
-
const results = await Promise.allSettled([...names].map(name => this.close(name)));
|
|
1241
|
-
const failures = results.flatMap(result => result.status === "rejected" ? [result.reason] : []);
|
|
1242
|
-
this.uiStreamListeners.clear();
|
|
1243
|
-
this.acceptedUrlElicitations.clear();
|
|
1244
|
-
this.samplingConfig = undefined;
|
|
1245
|
-
this.elicitationConfig = undefined;
|
|
1246
|
-
await Promise.all([...this.ownedClients].map(client => drainClientOperations(client)));
|
|
1247
|
-
try { await this.flushForCheckpoint(); } catch (error) { failures.push(error); }
|
|
1248
|
-
if (failures.length > 0) throw new AggregateError(failures, "MCP manager cleanup failed");
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
private containsCleanupFailure(error: unknown): boolean {
|
|
1252
|
-
const pending: unknown[] = [error];
|
|
1253
|
-
const seen = new Set<unknown>();
|
|
1254
|
-
while (pending.length > 0) {
|
|
1255
|
-
const current = pending.pop();
|
|
1256
|
-
if (!(current instanceof Error) || seen.has(current)) continue;
|
|
1257
|
-
seen.add(current);
|
|
1258
|
-
if (current instanceof AggregateError) {
|
|
1259
|
-
if (/cleanup failed|setup failed/.test(current.message)) return true;
|
|
1260
|
-
pending.push(...current.errors);
|
|
1261
|
-
}
|
|
1262
|
-
if (current.cause !== undefined) pending.push(current.cause);
|
|
1263
|
-
}
|
|
1264
|
-
return false;
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
getConnection(name: string): ServerConnection | undefined {
|
|
1268
|
-
return this.connections.get(name);
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
getAllConnections(): Map<string, ServerConnection> {
|
|
1272
|
-
return new Map(this.connections);
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
touch(name: string): void {
|
|
1276
|
-
const connection = this.connections.get(name);
|
|
1277
|
-
if (connection) {
|
|
1278
|
-
connection.lastUsedAt = Date.now();
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
incrementInFlight(name: string): void {
|
|
1283
|
-
const connection = this.connections.get(name);
|
|
1284
|
-
if (connection) {
|
|
1285
|
-
connection.inFlight = (connection.inFlight ?? 0) + 1;
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
decrementInFlight(name: string): void {
|
|
1290
|
-
const connection = this.connections.get(name);
|
|
1291
|
-
if (connection && connection.inFlight) {
|
|
1292
|
-
connection.inFlight--;
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
isIdle(name: string, timeoutMs: number): boolean {
|
|
1297
|
-
const connection = this.connections.get(name);
|
|
1298
|
-
if (!connection || connection.status !== "connected") return false;
|
|
1299
|
-
if (connection.inFlight > 0) return false;
|
|
1300
|
-
// Background subscriptions keep their existing idle policy, but accepted requests and
|
|
1301
|
-
// callbacks (including cancellation tails) must not be closed as idle while unfinished.
|
|
1302
|
-
if ([...(clientOperations.get(connection.client)?.values() ?? [])].some(kind => kind !== "subscription")) return false;
|
|
1303
|
-
return (Date.now() - connection.lastUsedAt) > timeoutMs;
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
/**
|
|
1308
|
-
* Resolve environment variables with interpolation.
|
|
1309
|
-
*/
|
|
1310
|
-
function resolveEnv(env: Record<string, string> | undefined, serverName: string): Record<string, string> {
|
|
1311
|
-
const resolved: Record<string, string> = {};
|
|
1312
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
1313
|
-
if (value !== undefined) resolved[key] = value;
|
|
1314
|
-
}
|
|
1315
|
-
const overrides = resolveCommandSecretsRecord(
|
|
1316
|
-
env,
|
|
1317
|
-
key => `MCP server "${serverName}" stdio env "${key}"`,
|
|
1318
|
-
);
|
|
1319
|
-
return overrides ? { ...resolved, ...overrides } : resolved;
|
|
1320
|
-
}
|