@wrongstack/mcp 0.285.0 → 0.286.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/dist/index.js CHANGED
@@ -1,3 +1,742 @@
1
+ // src/authorization.ts
2
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import * as dns from "node:dns/promises";
4
+ import * as http from "node:http";
5
+ import * as https from "node:https";
6
+ import * as net from "node:net";
7
+ import { isPrivateIPv4, isPrivateIPv6 } from "@wrongstack/core/utils";
8
+ function canonicalMcpResource(rawUrl) {
9
+ let url;
10
+ try {
11
+ url = new URL(rawUrl);
12
+ } catch {
13
+ throw new Error("MCP authorization resource must be an absolute URL");
14
+ }
15
+ if (url.protocol !== "https:" && !isLoopbackHttp(url)) {
16
+ throw new Error("MCP authorization resource must use HTTPS (except loopback development)");
17
+ }
18
+ if (url.username || url.password || url.hash) {
19
+ throw new Error("MCP authorization resource must not contain credentials or a fragment");
20
+ }
21
+ if (url.pathname === "/" && !url.search) return url.origin;
22
+ return url.toString();
23
+ }
24
+ function authorizationHeaderForToken(token, expectedResource, now = Date.now()) {
25
+ if (canonicalMcpResource(token.resource) !== expectedResource) {
26
+ throw new Error("MCP access token resource does not match the target server");
27
+ }
28
+ if (token.expiresAt !== void 0 && token.expiresAt <= now) {
29
+ throw new Error("MCP access token is expired");
30
+ }
31
+ const tokenType = token.tokenType ?? "Bearer";
32
+ if (tokenType.toLowerCase() !== "bearer") {
33
+ throw new Error(`Unsupported MCP OAuth token type "${tokenType}"`);
34
+ }
35
+ if (!token.accessToken || token.accessToken.length > 16384 || /[\r\n]/.test(token.accessToken)) {
36
+ throw new Error("MCP access token is empty, oversized, or contains invalid characters");
37
+ }
38
+ return `Bearer ${token.accessToken}`;
39
+ }
40
+ function parseMcpBearerChallenge(header, resource) {
41
+ const challenge = {
42
+ status: 401,
43
+ resource,
44
+ scopes: [],
45
+ rawScheme: "Bearer"
46
+ };
47
+ if (!header) return challenge;
48
+ const bearer = /(?:^|,)\s*Bearer(?:\s+|$)/i.exec(header);
49
+ if (!bearer) return challenge;
50
+ const parameters = header.slice((bearer.index ?? 0) + bearer[0].length);
51
+ const resourceMetadata = challengeParameter(parameters, "resource_metadata");
52
+ if (resourceMetadata) {
53
+ const metadataUrl = validateMetadataUrl(resourceMetadata);
54
+ if (metadataUrl) challenge.resourceMetadataUrl = metadataUrl;
55
+ }
56
+ const scope = challengeParameter(parameters, "scope");
57
+ if (scope) {
58
+ challenge.scopes = [...new Set(scope.split(/\s+/).filter(Boolean))].slice(0, 64);
59
+ }
60
+ return challenge;
61
+ }
62
+ function protectedResourceMetadataUrls(resource) {
63
+ const url = new URL(canonicalMcpResource(resource));
64
+ const suffix = url.pathname === "/" ? "" : url.pathname;
65
+ const candidates = [
66
+ new URL(`/.well-known/oauth-protected-resource${suffix}`, url.origin).toString(),
67
+ new URL("/.well-known/oauth-protected-resource", url.origin).toString()
68
+ ];
69
+ return [...new Set(candidates)];
70
+ }
71
+ function authorizationServerMetadataUrls(issuer) {
72
+ const url = secureOAuthUrl(issuer, "authorization server issuer");
73
+ const suffix = url.pathname === "/" ? "" : url.pathname;
74
+ const candidates = [
75
+ new URL(`/.well-known/oauth-authorization-server${suffix}`, url.origin).toString(),
76
+ new URL(`/.well-known/openid-configuration${suffix}`, url.origin).toString()
77
+ ];
78
+ if (suffix) {
79
+ candidates.push(
80
+ new URL(
81
+ `${suffix.replace(/\/$/, "")}/.well-known/openid-configuration`,
82
+ url.origin
83
+ ).toString()
84
+ );
85
+ }
86
+ return candidates;
87
+ }
88
+ function parseProtectedResourceMetadata(value, expectedResource) {
89
+ const metadata = record(value, "protected resource metadata");
90
+ const resource = canonicalMcpResource(requiredString(metadata["resource"], "resource"));
91
+ if (resource !== canonicalMcpResource(expectedResource)) {
92
+ throw new Error("MCP protected resource metadata resource does not match the target server");
93
+ }
94
+ const authorizationServers = boundedStringArray(
95
+ metadata["authorization_servers"],
96
+ "authorization_servers",
97
+ 8
98
+ ).map((issuer) => secureOAuthUrl(issuer, "authorization server issuer").toString());
99
+ if (authorizationServers.length === 0) {
100
+ throw new Error("MCP protected resource metadata must declare an authorization server");
101
+ }
102
+ return {
103
+ resource,
104
+ authorizationServers,
105
+ scopesSupported: optionalStringArray(metadata["scopes_supported"], "scopes_supported", 128)
106
+ };
107
+ }
108
+ function parseAuthorizationServerMetadata(value, expectedIssuer) {
109
+ const metadata = record(value, "authorization server metadata");
110
+ const issuer = secureOAuthUrl(requiredString(metadata["issuer"], "issuer"), "issuer").toString();
111
+ if (issuer !== secureOAuthUrl(expectedIssuer, "expected issuer").toString()) {
112
+ throw new Error("MCP authorization metadata issuer mismatch");
113
+ }
114
+ const methods = boundedStringArray(
115
+ metadata["code_challenge_methods_supported"],
116
+ "code_challenge_methods_supported",
117
+ 16
118
+ );
119
+ if (!methods.includes("S256")) {
120
+ throw new Error("MCP authorization server does not advertise required PKCE S256 support");
121
+ }
122
+ const registration = optionalString(metadata["registration_endpoint"], "registration_endpoint");
123
+ return {
124
+ issuer,
125
+ authorizationEndpoint: secureOAuthUrl(
126
+ requiredString(metadata["authorization_endpoint"], "authorization_endpoint"),
127
+ "authorization endpoint"
128
+ ).toString(),
129
+ tokenEndpoint: secureOAuthUrl(
130
+ requiredString(metadata["token_endpoint"], "token_endpoint"),
131
+ "token endpoint"
132
+ ).toString(),
133
+ registrationEndpoint: registration ? secureOAuthUrl(registration, "registration endpoint").toString() : void 0,
134
+ scopesSupported: optionalStringArray(metadata["scopes_supported"], "scopes_supported", 128)
135
+ };
136
+ }
137
+ function validateMcpAuthorizationServerMetadata(value) {
138
+ const metadata = record(value, "stored authorization server metadata");
139
+ const registration = optionalString(metadata["registrationEndpoint"], "registrationEndpoint");
140
+ return {
141
+ issuer: secureOAuthUrl(requiredString(metadata["issuer"], "issuer"), "issuer").toString(),
142
+ authorizationEndpoint: secureOAuthUrl(
143
+ requiredString(metadata["authorizationEndpoint"], "authorizationEndpoint"),
144
+ "authorization endpoint"
145
+ ).toString(),
146
+ tokenEndpoint: secureOAuthUrl(
147
+ requiredString(metadata["tokenEndpoint"], "tokenEndpoint"),
148
+ "token endpoint"
149
+ ).toString(),
150
+ registrationEndpoint: registration ? secureOAuthUrl(registration, "registration endpoint").toString() : void 0,
151
+ scopesSupported: optionalStringArray(metadata["scopesSupported"], "scopesSupported", 128)
152
+ };
153
+ }
154
+ async function discoverMcpAuthorization(resource, options = {}) {
155
+ const canonicalResource = canonicalMcpResource(resource);
156
+ const resourceUrl = new URL(canonicalResource);
157
+ const allowedLoopbackHostname = isLoopbackHttp(resourceUrl) ? unbracket(resourceUrl.hostname).toLowerCase() : void 0;
158
+ const fetchJson = options.fetchJson ?? ((url, signal) => requestPinnedJson(url, {
159
+ signal,
160
+ timeoutMs: options.timeoutMs,
161
+ maxResponseBytes: options.maxResponseBytes,
162
+ lookup: options.lookup,
163
+ allowedLoopbackHostname
164
+ }));
165
+ const challenge = parseMcpBearerChallenge(options.challengeHeader ?? null, canonicalResource);
166
+ const resourceCandidates = challenge.resourceMetadataUrl ? [challenge.resourceMetadataUrl] : protectedResourceMetadataUrls(canonicalResource);
167
+ const resourceDiscovery = await discoverFirst(
168
+ resourceCandidates,
169
+ fetchJson,
170
+ options.signal,
171
+ (value) => parseProtectedResourceMetadata(value, canonicalResource),
172
+ "protected resource metadata"
173
+ );
174
+ const issuer = resourceDiscovery.value.authorizationServers[0];
175
+ const authorizationDiscovery = await discoverFirst(
176
+ authorizationServerMetadataUrls(issuer),
177
+ fetchJson,
178
+ options.signal,
179
+ (value) => parseAuthorizationServerMetadata(value, issuer),
180
+ "authorization server metadata"
181
+ );
182
+ return {
183
+ resourceMetadataUrl: resourceDiscovery.url,
184
+ authorizationServerMetadataUrl: authorizationDiscovery.url,
185
+ protectedResource: resourceDiscovery.value,
186
+ authorizationServer: authorizationDiscovery.value
187
+ };
188
+ }
189
+ function createMcpAuthorizationRequest(options) {
190
+ const resource = canonicalMcpResource(options.resource);
191
+ const clientId = boundedCredential(options.clientId, "client id");
192
+ const redirectUri = validateRedirectUri(options.redirectUri);
193
+ const scopes = validateScopes(options.scopes ?? []);
194
+ const codeVerifier = base64Url(randomBytes(32));
195
+ const codeChallenge = base64Url(createHash("sha256").update(codeVerifier).digest());
196
+ const state = base64Url(randomBytes(32));
197
+ const authorizationUrl = secureOAuthUrl(
198
+ options.authorizationServer.authorizationEndpoint,
199
+ "authorization endpoint"
200
+ );
201
+ authorizationUrl.searchParams.set("response_type", "code");
202
+ authorizationUrl.searchParams.set("client_id", clientId);
203
+ authorizationUrl.searchParams.set("redirect_uri", redirectUri);
204
+ authorizationUrl.searchParams.set("state", state);
205
+ authorizationUrl.searchParams.set("code_challenge", codeChallenge);
206
+ authorizationUrl.searchParams.set("code_challenge_method", "S256");
207
+ authorizationUrl.searchParams.set("resource", resource);
208
+ if (scopes.length > 0) authorizationUrl.searchParams.set("scope", scopes.join(" "));
209
+ return {
210
+ authorizationUrl: authorizationUrl.toString(),
211
+ state,
212
+ codeVerifier,
213
+ redirectUri,
214
+ clientId,
215
+ resource
216
+ };
217
+ }
218
+ function parseMcpAuthorizationCallback(callbackUrl, session) {
219
+ let callback;
220
+ try {
221
+ callback = new URL(callbackUrl);
222
+ } catch {
223
+ throw new Error("MCP OAuth callback must be an absolute URL");
224
+ }
225
+ const expected = new URL(validateRedirectUri(session.redirectUri));
226
+ if (callback.protocol !== expected.protocol || callback.hostname !== expected.hostname || callback.port !== expected.port || callback.pathname !== expected.pathname) {
227
+ throw new Error("MCP OAuth callback redirect URI does not match the authorization session");
228
+ }
229
+ const returnedState = callback.searchParams.get("state") ?? "";
230
+ if (!constantTimeEqual(returnedState, session.state)) {
231
+ throw new Error("MCP OAuth callback state mismatch");
232
+ }
233
+ const oauthError = callback.searchParams.get("error");
234
+ if (oauthError)
235
+ throw new Error(`MCP OAuth authorization failed: ${boundedErrorCode(oauthError)}`);
236
+ return boundedCredential(callback.searchParams.get("code") ?? "", "authorization code");
237
+ }
238
+ async function exchangeMcpAuthorizationCode(options) {
239
+ const resource = canonicalMcpResource(options.resource);
240
+ const body = new URLSearchParams({
241
+ grant_type: "authorization_code",
242
+ code: boundedCredential(options.code, "authorization code"),
243
+ client_id: boundedCredential(options.clientId, "client id"),
244
+ redirect_uri: validateRedirectUri(options.redirectUri),
245
+ code_verifier: validateCodeVerifier(options.codeVerifier),
246
+ resource
247
+ }).toString();
248
+ const response = await requestPinnedJson(options.authorizationServer.tokenEndpoint, {
249
+ method: "POST",
250
+ body,
251
+ headers: { "content-type": "application/x-www-form-urlencoded" },
252
+ signal: options.signal,
253
+ timeoutMs: options.timeoutMs,
254
+ maxResponseBytes: options.maxResponseBytes,
255
+ lookup: options.lookup,
256
+ allowedLoopbackHostname: loopbackHostnameForResource(resource)
257
+ });
258
+ if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
259
+ return parseTokenResponse(response, resource);
260
+ }
261
+ async function refreshMcpAccessToken(options) {
262
+ const resource = canonicalMcpResource(options.resource);
263
+ const previousRefreshToken = boundedCredential(options.refreshToken, "refresh token");
264
+ const body = new URLSearchParams({
265
+ grant_type: "refresh_token",
266
+ refresh_token: previousRefreshToken,
267
+ client_id: boundedCredential(options.clientId, "client id"),
268
+ resource
269
+ }).toString();
270
+ const response = await requestPinnedJson(options.authorizationServer.tokenEndpoint, {
271
+ method: "POST",
272
+ body,
273
+ headers: { "content-type": "application/x-www-form-urlencoded" },
274
+ signal: options.signal,
275
+ timeoutMs: options.timeoutMs,
276
+ maxResponseBytes: options.maxResponseBytes,
277
+ lookup: options.lookup,
278
+ allowedLoopbackHostname: loopbackHostnameForResource(resource)
279
+ });
280
+ if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
281
+ const parsed = parseTokenResponse(response, resource);
282
+ return { ...parsed, refreshToken: parsed.refreshToken ?? previousRefreshToken };
283
+ }
284
+ function challengeParameter(parameters, name) {
285
+ const pattern = new RegExp(
286
+ `(?:^|,)\\s*${name}\\s*=\\s*(?:"((?:\\\\.|[^"\\\\])*)"|([^,\\s]+))`,
287
+ "i"
288
+ );
289
+ const match = pattern.exec(parameters);
290
+ const value = match?.[1] ?? match?.[2];
291
+ return value?.replace(/\\(["\\])/g, "$1");
292
+ }
293
+ function validateMetadataUrl(value) {
294
+ try {
295
+ const url = new URL(value);
296
+ if (url.username || url.password || url.hash) return void 0;
297
+ if (url.protocol !== "https:" && !isLoopbackHttp(url)) return void 0;
298
+ return url.toString();
299
+ } catch {
300
+ return void 0;
301
+ }
302
+ }
303
+ function record(value, label) {
304
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
305
+ throw new Error(`MCP ${label} must be an object`);
306
+ }
307
+ return value;
308
+ }
309
+ function requiredString(value, field) {
310
+ if (typeof value !== "string" || value.length === 0 || value.length > 4096) {
311
+ throw new Error(`MCP authorization field "${field}" must be a bounded non-empty string`);
312
+ }
313
+ return value;
314
+ }
315
+ function optionalString(value, field) {
316
+ return value === void 0 ? void 0 : requiredString(value, field);
317
+ }
318
+ function boundedStringArray(value, field, maxItems) {
319
+ if (!Array.isArray(value) || value.length > maxItems) {
320
+ throw new Error(`MCP authorization field "${field}" must be an array of at most ${maxItems}`);
321
+ }
322
+ return [...new Set(value.map((entry) => requiredString(entry, field)))];
323
+ }
324
+ function optionalStringArray(value, field, maxItems) {
325
+ return value === void 0 ? [] : boundedStringArray(value, field, maxItems);
326
+ }
327
+ function secureOAuthUrl(value, label) {
328
+ let url;
329
+ try {
330
+ url = new URL(value);
331
+ } catch {
332
+ throw new Error(`MCP ${label} must be an absolute URL`);
333
+ }
334
+ if (url.protocol !== "https:" && !isLoopbackHttp(url)) {
335
+ throw new Error(`MCP ${label} must use HTTPS (except loopback development)`);
336
+ }
337
+ if (url.username || url.password || url.search || url.hash) {
338
+ throw new Error(`MCP ${label} must not contain credentials, query, or fragment components`);
339
+ }
340
+ if (url.pathname === "/") return new URL(url.origin);
341
+ return url;
342
+ }
343
+ async function discoverFirst(candidates, fetchJson, signal, parse, label) {
344
+ const failures = [];
345
+ for (const candidate of candidates) {
346
+ signal?.throwIfAborted();
347
+ try {
348
+ const value = await fetchJson(candidate, signal);
349
+ if (value === void 0) {
350
+ failures.push(`${candidate}: not found`);
351
+ continue;
352
+ }
353
+ return { url: candidate, value: parse(value) };
354
+ } catch (error) {
355
+ signal?.throwIfAborted();
356
+ failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`);
357
+ }
358
+ }
359
+ throw new Error(`MCP ${label} discovery failed (${failures.join("; ")})`);
360
+ }
361
+ async function requestPinnedJson(rawUrl, options) {
362
+ const url = secureOAuthUrl(rawUrl, "discovery URL");
363
+ const target = await resolvePinnedAddress(url, options);
364
+ const timeoutMs = options.timeoutMs ?? 1e4;
365
+ const maxBytes = options.maxResponseBytes ?? 64 * 1024;
366
+ options.signal?.throwIfAborted();
367
+ return new Promise((resolve, reject) => {
368
+ let settled = false;
369
+ const finish = (error, value) => {
370
+ if (settled) return;
371
+ settled = true;
372
+ options.signal?.removeEventListener("abort", onAbort);
373
+ if (error) reject(error);
374
+ else resolve(value);
375
+ };
376
+ const onAbort = () => {
377
+ request3.destroy(options.signal?.reason instanceof Error ? options.signal.reason : void 0);
378
+ };
379
+ const headers = {
380
+ accept: "application/json",
381
+ host: url.host,
382
+ ...options.headers
383
+ };
384
+ if (options.body !== void 0) {
385
+ headers["content-length"] = Buffer.byteLength(options.body);
386
+ }
387
+ const requestOptions = {
388
+ host: target.address,
389
+ family: target.family,
390
+ port: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
391
+ method: options.method ?? "GET",
392
+ path: `${url.pathname}${url.search}`,
393
+ headers,
394
+ ...url.protocol === "https:" && net.isIP(unbracket(url.hostname)) === 0 ? { servername: unbracket(url.hostname) } : {}
395
+ };
396
+ const requestFn = url.protocol === "https:" ? https.request : http.request;
397
+ const request3 = requestFn(requestOptions, (response) => {
398
+ const status = response.statusCode ?? 0;
399
+ if (status === 404 || status === 410) {
400
+ response.resume();
401
+ finish(void 0, void 0);
402
+ return;
403
+ }
404
+ if (status >= 300 && status < 400) {
405
+ response.resume();
406
+ finish(new Error("MCP OAuth discovery redirects are not allowed"));
407
+ return;
408
+ }
409
+ if (status < 200 || status >= 300) {
410
+ response.resume();
411
+ finish(new Error(`MCP OAuth discovery HTTP ${status}`));
412
+ return;
413
+ }
414
+ const contentType = response.headers["content-type"] ?? "";
415
+ if (!/^(?:application\/json|[^;]+\+json)(?:;|$)/i.test(contentType)) {
416
+ response.resume();
417
+ finish(new Error("MCP OAuth discovery response must be JSON"));
418
+ return;
419
+ }
420
+ const declaredLength = Number(response.headers["content-length"] ?? 0);
421
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
422
+ response.destroy();
423
+ finish(new Error(`MCP OAuth discovery response exceeds ${maxBytes} bytes`));
424
+ return;
425
+ }
426
+ const chunks = [];
427
+ let size = 0;
428
+ response.on("data", (chunk) => {
429
+ size += chunk.length;
430
+ if (size > maxBytes) {
431
+ response.destroy();
432
+ finish(new Error(`MCP OAuth discovery response exceeds ${maxBytes} bytes`));
433
+ return;
434
+ }
435
+ chunks.push(chunk);
436
+ });
437
+ response.once("end", () => {
438
+ try {
439
+ finish(void 0, JSON.parse(Buffer.concat(chunks).toString("utf8")));
440
+ } catch {
441
+ finish(new Error("MCP OAuth discovery response is not valid JSON"));
442
+ }
443
+ });
444
+ response.once("error", (error) => finish(error));
445
+ });
446
+ request3.setTimeout(timeoutMs, () => {
447
+ request3.destroy(new Error(`MCP OAuth discovery timed out after ${timeoutMs}ms`));
448
+ });
449
+ request3.once("error", (error) => finish(error));
450
+ options.signal?.addEventListener("abort", onAbort, { once: true });
451
+ request3.end(options.body);
452
+ });
453
+ }
454
+ async function resolvePinnedAddress(url, options) {
455
+ const hostname = unbracket(url.hostname).toLowerCase();
456
+ const literalFamily = net.isIP(hostname);
457
+ if (literalFamily === 4 || literalFamily === 6) {
458
+ assertDiscoveryAddressAllowed(
459
+ hostname,
460
+ literalFamily,
461
+ hostname,
462
+ options.allowedLoopbackHostname
463
+ );
464
+ return { address: hostname, family: literalFamily };
465
+ }
466
+ const lookup2 = options.lookup ?? ((host) => dns.lookup(host, { all: true }));
467
+ const records = await lookup2(hostname);
468
+ if (records.length === 0)
469
+ throw new Error(`MCP OAuth discovery DNS returned no addresses for ${hostname}`);
470
+ for (const record3 of records) {
471
+ if (record3.family !== 4 && record3.family !== 6) {
472
+ throw new Error("MCP OAuth discovery DNS returned an unsupported address family");
473
+ }
474
+ assertDiscoveryAddressAllowed(
475
+ record3.address,
476
+ record3.family,
477
+ hostname,
478
+ options.allowedLoopbackHostname
479
+ );
480
+ }
481
+ const selected = records[0];
482
+ return { address: selected.address, family: selected.family };
483
+ }
484
+ function assertDiscoveryAddressAllowed(address, family, hostname, allowedLoopbackHostname) {
485
+ const isPrivate = family === 4 ? isPrivateIPv4(address) : isPrivateIPv6(address);
486
+ if (!isPrivate) return;
487
+ const loopback = family === 4 ? address.startsWith("127.") : address === "::1";
488
+ if (loopback && hostname === allowedLoopbackHostname) return;
489
+ throw new Error(`MCP OAuth discovery blocked private address ${address}`);
490
+ }
491
+ function unbracket(hostname) {
492
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
493
+ }
494
+ function validateRedirectUri(value) {
495
+ let url;
496
+ try {
497
+ url = new URL(value);
498
+ } catch {
499
+ throw new Error("MCP OAuth redirect URI must be an absolute URL");
500
+ }
501
+ if (url.protocol !== "https:" && !isLoopbackHttp(url)) {
502
+ throw new Error("MCP OAuth redirect URI must use HTTPS or loopback HTTP");
503
+ }
504
+ if (url.username || url.password || url.search || url.hash) {
505
+ throw new Error("MCP OAuth redirect URI must not contain credentials, query, or fragment");
506
+ }
507
+ return url.toString();
508
+ }
509
+ function validateScopes(scopes) {
510
+ if (scopes.length > 128) throw new Error("MCP OAuth scope list exceeds 128 entries");
511
+ const normalized = scopes.map((scope) => {
512
+ if (!scope || scope.length > 256 || /\s/.test(scope)) {
513
+ throw new Error("MCP OAuth scopes must be bounded non-empty tokens");
514
+ }
515
+ return scope;
516
+ });
517
+ return [...new Set(normalized)];
518
+ }
519
+ function validateCodeVerifier(value) {
520
+ if (value.length < 43 || value.length > 128 || !/^[A-Za-z0-9._~-]+$/.test(value)) {
521
+ throw new Error("MCP OAuth PKCE code verifier is invalid");
522
+ }
523
+ return value;
524
+ }
525
+ function boundedCredential(value, label) {
526
+ if (!value || value.length > 16384 || /[\r\n]/.test(value)) {
527
+ throw new Error(`MCP OAuth ${label} is empty, oversized, or invalid`);
528
+ }
529
+ return value;
530
+ }
531
+ function boundedErrorCode(value) {
532
+ return /^[A-Za-z0-9._-]{1,128}$/.test(value) ? value : "invalid_error";
533
+ }
534
+ function base64Url(value) {
535
+ return Buffer.from(value).toString("base64url");
536
+ }
537
+ function constantTimeEqual(left, right) {
538
+ const leftHash = createHash("sha256").update(left).digest();
539
+ const rightHash = createHash("sha256").update(right).digest();
540
+ return timingSafeEqual(leftHash, rightHash);
541
+ }
542
+ function parseTokenResponse(value, resource) {
543
+ const response = record(value, "token response");
544
+ const accessToken = boundedCredential(
545
+ requiredString(response["access_token"], "access_token"),
546
+ "access token"
547
+ );
548
+ const tokenType = optionalString(response["token_type"], "token_type") ?? "Bearer";
549
+ if (tokenType.toLowerCase() !== "bearer") {
550
+ throw new Error(`Unsupported MCP OAuth token type "${tokenType}"`);
551
+ }
552
+ const expiresIn = response["expires_in"];
553
+ let expiresAt;
554
+ if (expiresIn !== void 0) {
555
+ if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 31536e3) {
556
+ throw new Error("MCP OAuth expires_in must be between 1 second and 1 year");
557
+ }
558
+ expiresAt = Date.now() + Math.floor(expiresIn * 1e3);
559
+ }
560
+ const refresh = optionalString(response["refresh_token"], "refresh_token");
561
+ const scope = optionalString(response["scope"], "scope");
562
+ const token = {
563
+ accessToken,
564
+ tokenType: "Bearer",
565
+ resource,
566
+ scopes: scope ? validateScopes(scope.split(/\s+/).filter(Boolean)) : [],
567
+ ...expiresAt !== void 0 ? { expiresAt } : {},
568
+ ...refresh ? { refreshToken: boundedCredential(refresh, "refresh token") } : {}
569
+ };
570
+ authorizationHeaderForToken(token, resource);
571
+ return token;
572
+ }
573
+ function loopbackHostnameForResource(resource) {
574
+ const url = new URL(resource);
575
+ return isLoopbackHttp(url) ? unbracket(url.hostname).toLowerCase() : void 0;
576
+ }
577
+ function isLoopbackHttp(url) {
578
+ if (url.protocol !== "http:") return false;
579
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
580
+ }
581
+
582
+ // src/authorization-manager.ts
583
+ var DEFAULT_PENDING_TTL_MS = 10 * 6e4;
584
+ var MAX_PENDING_AUTHORIZATIONS = 32;
585
+ var MCPAuthorizationManager = class {
586
+ constructor(options) {
587
+ this.options = options;
588
+ this.pendingTtlMs = options.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
589
+ if (!Number.isFinite(this.pendingTtlMs) || this.pendingTtlMs <= 0) {
590
+ throw new Error("MCP authorization pending TTL must be a positive finite number");
591
+ }
592
+ this.discover = options.discover ?? discoverMcpAuthorization;
593
+ this.exchange = options.exchange ?? exchangeMcpAuthorizationCode;
594
+ this.now = options.now ?? Date.now;
595
+ }
596
+ options;
597
+ pending = /* @__PURE__ */ new Map();
598
+ pendingTtlMs;
599
+ discover;
600
+ exchange;
601
+ now;
602
+ async begin(input) {
603
+ const resource = canonicalMcpResource(input.resource);
604
+ const key = authorizationKey(input.serverName, resource);
605
+ this.pruneExpired();
606
+ if (!this.pending.has(key) && this.pending.size >= MAX_PENDING_AUTHORIZATIONS) {
607
+ throw new Error("Too many pending MCP authorization sessions");
608
+ }
609
+ const discovery = await this.discover(resource, {
610
+ challengeHeader: input.challengeHeader,
611
+ signal: input.signal
612
+ });
613
+ const challengeScopes = parseMcpBearerChallenge(input.challengeHeader ?? null, resource).scopes;
614
+ const scopes = input.scopes ? [...input.scopes] : challengeScopes;
615
+ const session = createMcpAuthorizationRequest({
616
+ authorizationServer: discovery.authorizationServer,
617
+ clientId: input.clientId,
618
+ redirectUri: input.redirectUri,
619
+ resource,
620
+ scopes
621
+ });
622
+ const normalizedScopes = new URL(session.authorizationUrl).searchParams.get("scope")?.split(" ").filter(Boolean) ?? [];
623
+ const expiresAt = this.now() + this.pendingTtlMs;
624
+ this.pending.set(key, { session, discovery, scopes: normalizedScopes, expiresAt });
625
+ return {
626
+ serverName: boundedServerName(input.serverName),
627
+ resource,
628
+ authorizationUrl: session.authorizationUrl,
629
+ redirectUri: session.redirectUri,
630
+ scopes: [...normalizedScopes],
631
+ expiresAt
632
+ };
633
+ }
634
+ async complete(input) {
635
+ const serverName = boundedServerName(input.serverName);
636
+ const resource = canonicalMcpResource(input.resource);
637
+ const key = authorizationKey(serverName, resource);
638
+ this.pruneExpired();
639
+ const pending = this.pending.get(key);
640
+ if (!pending) {
641
+ throw new Error("No live MCP authorization session exists for this server");
642
+ }
643
+ const code = parseMcpAuthorizationCallback(input.callbackUrl, pending.session);
644
+ this.pending.delete(key);
645
+ const tokenSet = await this.exchange({
646
+ authorizationServer: pending.discovery.authorizationServer,
647
+ clientId: pending.session.clientId,
648
+ redirectUri: pending.session.redirectUri,
649
+ resource,
650
+ code,
651
+ codeVerifier: pending.session.codeVerifier,
652
+ signal: input.signal
653
+ });
654
+ const stored = {
655
+ serverName,
656
+ resource,
657
+ clientId: pending.session.clientId,
658
+ authorizationServer: pending.discovery.authorizationServer,
659
+ tokenSet,
660
+ updatedAt: new Date(this.now()).toISOString()
661
+ };
662
+ await this.options.store.save(stored);
663
+ this.emit("authorized", stored);
664
+ return statusFromStored(stored, this.now());
665
+ }
666
+ async status(serverName, resource) {
667
+ const normalizedName = boundedServerName(serverName);
668
+ const normalizedResource = canonicalMcpResource(resource);
669
+ this.pruneExpired();
670
+ const pending = this.pending.get(authorizationKey(normalizedName, normalizedResource));
671
+ if (pending) {
672
+ return {
673
+ serverName: normalizedName,
674
+ resource: normalizedResource,
675
+ state: "pending",
676
+ expiresAt: pending.expiresAt,
677
+ scopes: [...pending.scopes],
678
+ canRefresh: false
679
+ };
680
+ }
681
+ const stored = await this.options.store.load(normalizedName, normalizedResource);
682
+ return stored ? statusFromStored(stored, this.now()) : {
683
+ serverName: normalizedName,
684
+ resource: normalizedResource,
685
+ state: "not_authorized",
686
+ scopes: [],
687
+ canRefresh: false
688
+ };
689
+ }
690
+ async disconnect(serverName, resource) {
691
+ const normalizedName = boundedServerName(serverName);
692
+ const normalizedResource = canonicalMcpResource(resource);
693
+ this.pending.delete(authorizationKey(normalizedName, normalizedResource));
694
+ const removed = await this.options.store.remove(normalizedName, normalizedResource);
695
+ if (removed) {
696
+ this.options.onStateChange?.({
697
+ serverName: normalizedName,
698
+ state: "removed",
699
+ resource: normalizedResource
700
+ });
701
+ }
702
+ return removed;
703
+ }
704
+ pruneExpired() {
705
+ const now = this.now();
706
+ for (const [key, value] of this.pending) {
707
+ if (value.expiresAt <= now) this.pending.delete(key);
708
+ }
709
+ }
710
+ emit(state, value) {
711
+ this.options.onStateChange?.({
712
+ serverName: value.serverName,
713
+ state,
714
+ resource: value.resource,
715
+ expiresAt: value.tokenSet.expiresAt,
716
+ scopes: [...value.tokenSet.scopes ?? []]
717
+ });
718
+ }
719
+ };
720
+ function statusFromStored(value, now) {
721
+ return {
722
+ serverName: value.serverName,
723
+ resource: value.resource,
724
+ state: value.tokenSet.expiresAt !== void 0 && value.tokenSet.expiresAt <= now ? "expired" : "authorized",
725
+ expiresAt: value.tokenSet.expiresAt,
726
+ scopes: [...value.tokenSet.scopes ?? []],
727
+ canRefresh: !!value.tokenSet.refreshToken
728
+ };
729
+ }
730
+ function authorizationKey(serverName, resource) {
731
+ return `${boundedServerName(serverName)}\0${resource}`;
732
+ }
733
+ function boundedServerName(value) {
734
+ if (!value || value.length > 256 || /[\r\n\0]/.test(value)) {
735
+ throw new Error("MCP authorization server name is invalid");
736
+ }
737
+ return value;
738
+ }
739
+
1
740
  // src/client.ts
2
741
  import { spawn } from "node:child_process";
3
742
  import { buildChildEnv } from "@wrongstack/core";
@@ -47,6 +786,207 @@ var MCP_CONSTANTS = Object.freeze({
47
786
  REQUEST_LOG_CAP: 1024
48
787
  });
49
788
 
789
+ // src/protocol.ts
790
+ function record2(value, label) {
791
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
792
+ throw new Error(`Malformed MCP ${label}: expected object`);
793
+ }
794
+ return value;
795
+ }
796
+ function requiredString2(value, label) {
797
+ if (typeof value !== "string" || value.length === 0) {
798
+ throw new Error(`Malformed MCP ${label}: expected non-empty string`);
799
+ }
800
+ return value;
801
+ }
802
+ function optionalString2(value, label) {
803
+ if (value === void 0) return void 0;
804
+ if (typeof value !== "string") throw new Error(`Malformed MCP ${label}: expected string`);
805
+ return value;
806
+ }
807
+ function optionalRecord(value, label) {
808
+ if (value === void 0) return void 0;
809
+ return record2(value, label);
810
+ }
811
+ function optionalCursor(value, label) {
812
+ return optionalString2(value, `${label}.nextCursor`);
813
+ }
814
+ function parseServerMetadata(value) {
815
+ const input = record2(value, "initialize result");
816
+ const serverInfo = record2(input["serverInfo"], "initialize.serverInfo");
817
+ const capabilities = record2(input["capabilities"], "initialize.capabilities");
818
+ return {
819
+ protocolVersion: requiredString2(input["protocolVersion"], "initialize.protocolVersion"),
820
+ capabilities,
821
+ serverInfo: {
822
+ name: requiredString2(serverInfo["name"], "initialize.serverInfo.name"),
823
+ version: requiredString2(serverInfo["version"], "initialize.serverInfo.version"),
824
+ title: optionalString2(serverInfo["title"], "initialize.serverInfo.title")
825
+ },
826
+ instructions: optionalString2(input["instructions"], "initialize.instructions")
827
+ };
828
+ }
829
+ function parseResource(value, index) {
830
+ const input = record2(value, `resources/list.resources[${index}]`);
831
+ const size = input["size"];
832
+ if (size !== void 0 && (typeof size !== "number" || !Number.isFinite(size) || size < 0)) {
833
+ throw new Error(`Malformed MCP resources/list.resources[${index}].size`);
834
+ }
835
+ return {
836
+ uri: requiredString2(input["uri"], `resources/list.resources[${index}].uri`),
837
+ name: requiredString2(input["name"], `resources/list.resources[${index}].name`),
838
+ title: optionalString2(input["title"], `resources/list.resources[${index}].title`),
839
+ description: optionalString2(
840
+ input["description"],
841
+ `resources/list.resources[${index}].description`
842
+ ),
843
+ mimeType: optionalString2(input["mimeType"], `resources/list.resources[${index}].mimeType`),
844
+ size,
845
+ annotations: optionalRecord(
846
+ input["annotations"],
847
+ `resources/list.resources[${index}].annotations`
848
+ )
849
+ };
850
+ }
851
+ function parseListResourcesResult(value) {
852
+ const input = record2(value, "resources/list result");
853
+ if (!Array.isArray(input["resources"])) {
854
+ throw new Error("Malformed MCP resources/list result: resources must be an array");
855
+ }
856
+ return {
857
+ resources: input["resources"].map(parseResource),
858
+ nextCursor: optionalCursor(input["nextCursor"], "resources/list")
859
+ };
860
+ }
861
+ function parseListResourceTemplatesResult(value) {
862
+ const input = record2(value, "resources/templates/list result");
863
+ const templates = input["resourceTemplates"];
864
+ if (!Array.isArray(templates)) {
865
+ throw new Error(
866
+ "Malformed MCP resources/templates/list result: resourceTemplates must be an array"
867
+ );
868
+ }
869
+ return {
870
+ resourceTemplates: templates.map((value2, index) => {
871
+ const template = record2(value2, `resources/templates/list.resourceTemplates[${index}]`);
872
+ return {
873
+ uriTemplate: requiredString2(
874
+ template["uriTemplate"],
875
+ `resources/templates/list.resourceTemplates[${index}].uriTemplate`
876
+ ),
877
+ name: requiredString2(
878
+ template["name"],
879
+ `resources/templates/list.resourceTemplates[${index}].name`
880
+ ),
881
+ title: optionalString2(
882
+ template["title"],
883
+ `resources/templates/list.resourceTemplates[${index}].title`
884
+ ),
885
+ description: optionalString2(
886
+ template["description"],
887
+ `resources/templates/list.resourceTemplates[${index}].description`
888
+ ),
889
+ mimeType: optionalString2(
890
+ template["mimeType"],
891
+ `resources/templates/list.resourceTemplates[${index}].mimeType`
892
+ ),
893
+ annotations: optionalRecord(
894
+ template["annotations"],
895
+ `resources/templates/list.resourceTemplates[${index}].annotations`
896
+ )
897
+ };
898
+ }),
899
+ nextCursor: optionalCursor(input["nextCursor"], "resources/templates/list")
900
+ };
901
+ }
902
+ function parseReadResourceResult(value) {
903
+ const input = record2(value, "resources/read result");
904
+ if (!Array.isArray(input["contents"])) {
905
+ throw new Error("Malformed MCP resources/read result: contents must be an array");
906
+ }
907
+ return {
908
+ contents: input["contents"].map((value2, index) => {
909
+ const content = record2(value2, `resources/read.contents[${index}]`);
910
+ const text = optionalString2(content["text"], `resources/read.contents[${index}].text`);
911
+ const blob = optionalString2(content["blob"], `resources/read.contents[${index}].blob`);
912
+ if (text === void 0 && blob === void 0) {
913
+ throw new Error(`Malformed MCP resources/read.contents[${index}]: expected text or blob`);
914
+ }
915
+ return {
916
+ uri: requiredString2(content["uri"], `resources/read.contents[${index}].uri`),
917
+ mimeType: optionalString2(content["mimeType"], `resources/read.contents[${index}].mimeType`),
918
+ text,
919
+ blob
920
+ };
921
+ })
922
+ };
923
+ }
924
+ function parsePromptArgument(value, promptIndex, argIndex) {
925
+ const input = record2(value, `prompts/list.prompts[${promptIndex}].arguments[${argIndex}]`);
926
+ const required = input["required"];
927
+ if (required !== void 0 && typeof required !== "boolean") {
928
+ throw new Error(
929
+ `Malformed MCP prompts/list.prompts[${promptIndex}].arguments[${argIndex}].required`
930
+ );
931
+ }
932
+ return {
933
+ name: requiredString2(
934
+ input["name"],
935
+ `prompts/list.prompts[${promptIndex}].arguments[${argIndex}].name`
936
+ ),
937
+ description: optionalString2(
938
+ input["description"],
939
+ `prompts/list.prompts[${promptIndex}].arguments[${argIndex}].description`
940
+ ),
941
+ required
942
+ };
943
+ }
944
+ function parseListPromptsResult(value) {
945
+ const input = record2(value, "prompts/list result");
946
+ if (!Array.isArray(input["prompts"])) {
947
+ throw new Error("Malformed MCP prompts/list result: prompts must be an array");
948
+ }
949
+ return {
950
+ prompts: input["prompts"].map((value2, index) => {
951
+ const prompt = record2(value2, `prompts/list.prompts[${index}]`);
952
+ const args = prompt["arguments"];
953
+ if (args !== void 0 && !Array.isArray(args)) {
954
+ throw new Error(`Malformed MCP prompts/list.prompts[${index}].arguments`);
955
+ }
956
+ return {
957
+ name: requiredString2(prompt["name"], `prompts/list.prompts[${index}].name`),
958
+ title: optionalString2(prompt["title"], `prompts/list.prompts[${index}].title`),
959
+ description: optionalString2(
960
+ prompt["description"],
961
+ `prompts/list.prompts[${index}].description`
962
+ ),
963
+ arguments: args?.map((arg, argIndex) => parsePromptArgument(arg, index, argIndex))
964
+ };
965
+ }),
966
+ nextCursor: optionalCursor(input["nextCursor"], "prompts/list")
967
+ };
968
+ }
969
+ function parseGetPromptResult(value) {
970
+ const input = record2(value, "prompts/get result");
971
+ if (!Array.isArray(input["messages"])) {
972
+ throw new Error("Malformed MCP prompts/get result: messages must be an array");
973
+ }
974
+ return {
975
+ description: optionalString2(input["description"], "prompts/get.description"),
976
+ messages: input["messages"].map((value2, index) => {
977
+ const message = record2(value2, `prompts/get.messages[${index}]`);
978
+ const role = message["role"];
979
+ if (role !== "user" && role !== "assistant") {
980
+ throw new Error(`Malformed MCP prompts/get.messages[${index}].role`);
981
+ }
982
+ if (message["content"] === void 0) {
983
+ throw new Error(`Malformed MCP prompts/get.messages[${index}].content`);
984
+ }
985
+ return { role, content: message["content"] };
986
+ })
987
+ };
988
+ }
989
+
50
990
  // src/tool-schema.ts
51
991
  function normalizeMCPTools(value) {
52
992
  if (!Array.isArray(value)) return [];
@@ -75,9 +1015,9 @@ function normalizeMCPTools(value) {
75
1015
  }
76
1016
 
77
1017
  // src/transport.ts
78
- import { randomBytes } from "node:crypto";
79
- import * as https from "node:https";
80
- import * as net from "node:net";
1018
+ import { randomBytes as randomBytes2 } from "node:crypto";
1019
+ import * as https2 from "node:https";
1020
+ import * as net2 from "node:net";
81
1021
  import { ConfigError, ToolError } from "@wrongstack/core";
82
1022
  function isTlsUnsafeAllowed() {
83
1023
  return process.env["WRONGSTACK_UNSAFE_MCP_TLS"] === "1";
@@ -102,7 +1042,7 @@ function validateTransportUrl(rawUrl) {
102
1042
  }
103
1043
  const hostname = url.hostname;
104
1044
  const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
105
- const ipVersion = net.isIP(host);
1045
+ const ipVersion = net2.isIP(host);
106
1046
  if (ipVersion === 4) {
107
1047
  const parts = host.split(".").map(Number);
108
1048
  if (parts[0] === 169 && parts[1] === 254) {
@@ -162,7 +1102,11 @@ var SSEReader = class {
162
1102
  message: `SSE: pending line exceeds ${SSE_READER_MAX_BUFFER} bytes \u2014 upstream is not framing events`,
163
1103
  code: "TOOL_EXECUTION_FAILED",
164
1104
  toolName: "mcp_transport_sse_reader",
165
- context: { phase: "feed", bufferLength: this.buffer.length, maxBuffer: SSE_READER_MAX_BUFFER }
1105
+ context: {
1106
+ phase: "feed",
1107
+ bufferLength: this.buffer.length,
1108
+ maxBuffer: SSE_READER_MAX_BUFFER
1109
+ }
166
1110
  });
167
1111
  }
168
1112
  let idx = this.buffer.indexOf("\n");
@@ -190,7 +1134,11 @@ var SSEReader = class {
190
1134
  message: `SSE: exceeded ${SSE_READER_MAX_DATA_LINES} data lines per event \u2014 upstream is not sending blank-line delimiters`,
191
1135
  code: "TOOL_EXECUTION_FAILED",
192
1136
  toolName: "mcp_transport_sse_reader",
193
- context: { phase: "processLine", dataLineCount: this.dataLines.length, maxDataLines: SSE_READER_MAX_DATA_LINES }
1137
+ context: {
1138
+ phase: "processLine",
1139
+ dataLineCount: this.dataLines.length,
1140
+ maxDataLines: SSE_READER_MAX_DATA_LINES
1141
+ }
194
1142
  });
195
1143
  }
196
1144
  this.dataLines.push(value);
@@ -226,13 +1174,25 @@ var SSEReader = class {
226
1174
  function isJsonRpcResult(v) {
227
1175
  if (typeof v !== "object" || v === null) return false;
228
1176
  const r = v;
229
- if (r.jsonrpc !== "2.0") return false;
230
- if (r.error !== void 0) {
231
- return typeof r.error === "object" && r.error !== null && typeof r.error.code === "number" && typeof r.error.message === "string";
1177
+ if (r["jsonrpc"] !== "2.0" || typeof r["id"] !== "number") return false;
1178
+ if (Object.hasOwn(r, "method")) return false;
1179
+ const hasResult = Object.hasOwn(r, "result");
1180
+ const hasError = Object.hasOwn(r, "error");
1181
+ if (hasResult === hasError) return false;
1182
+ if (hasError) {
1183
+ const error = r["error"];
1184
+ return typeof error === "object" && error !== null && typeof error["code"] === "number" && typeof error["message"] === "string";
232
1185
  }
233
- return "result" in r || r.id === void 0;
1186
+ return true;
234
1187
  }
235
- function extractJsonRpcResults(text) {
1188
+ function isJsonRpcMethodEnvelope(v) {
1189
+ if (typeof v !== "object" || v === null) return false;
1190
+ const envelope = v;
1191
+ if (envelope["jsonrpc"] !== "2.0" || typeof envelope["method"] !== "string") return false;
1192
+ const id = envelope["id"];
1193
+ return id === void 0 || typeof id === "number" || typeof id === "string";
1194
+ }
1195
+ function extractJsonRpcEnvelopes(text) {
236
1196
  const out = [];
237
1197
  let dataBuf = [];
238
1198
  const flush = () => {
@@ -242,7 +1202,7 @@ function extractJsonRpcResults(text) {
242
1202
  if (!joined) return;
243
1203
  try {
244
1204
  const parsed = JSON.parse(joined);
245
- if (isJsonRpcResult(parsed)) out.push(parsed);
1205
+ if (isJsonRpcResult(parsed) || isJsonRpcMethodEnvelope(parsed)) out.push(parsed);
246
1206
  } catch {
247
1207
  }
248
1208
  };
@@ -266,7 +1226,7 @@ function extractJsonRpcResults(text) {
266
1226
  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
267
1227
  try {
268
1228
  const parsed = JSON.parse(trimmed);
269
- if (isJsonRpcResult(parsed)) out.push(parsed);
1229
+ if (isJsonRpcResult(parsed) || isJsonRpcMethodEnvelope(parsed)) out.push(parsed);
270
1230
  } catch {
271
1231
  }
272
1232
  }
@@ -274,9 +1234,8 @@ function extractJsonRpcResults(text) {
274
1234
  flush();
275
1235
  return out;
276
1236
  }
277
- function pickJsonRpcResult(text, id) {
278
- const results = extractJsonRpcResults(text);
279
- return results.find((r) => r.id === id) ?? results[0];
1237
+ function extractJsonRpcResults(text) {
1238
+ return extractJsonRpcEnvelopes(text).filter(isJsonRpcResult);
280
1239
  }
281
1240
  function assertMatchingJsonRpcResult(data, expectedId, method) {
282
1241
  if (!isJsonRpcResult(data)) {
@@ -287,7 +1246,7 @@ function assertMatchingJsonRpcResult(data, expectedId, method) {
287
1246
  context: { method, expectedId, reason: "not-jsonrpc-envelope" }
288
1247
  });
289
1248
  }
290
- if (data.id !== void 0 && data.id !== expectedId) {
1249
+ if (data.id !== expectedId) {
291
1250
  throw new ToolError({
292
1251
  message: `Invalid JSON-RPC response: id mismatch for ${method} (expected ${expectedId}, got ${data.id})`,
293
1252
  code: "TOOL_EXECUTION_FAILED",
@@ -295,14 +1254,6 @@ function assertMatchingJsonRpcResult(data, expectedId, method) {
295
1254
  context: { method, expectedId, actualId: data.id, reason: "id-mismatch" }
296
1255
  });
297
1256
  }
298
- if (data.id === void 0 && !method.startsWith("notifications/")) {
299
- throw new ToolError({
300
- message: `Invalid JSON-RPC response: missing id for ${method}`,
301
- code: "TOOL_EXECUTION_FAILED",
302
- toolName: "mcp_transport_jsonrpc",
303
- context: { method, expectedId, reason: "missing-id" }
304
- });
305
- }
306
1257
  return data;
307
1258
  }
308
1259
  function makeAbortError(method) {
@@ -336,16 +1287,26 @@ var BaseHTTPTransport = class {
336
1287
  headers;
337
1288
  timeout;
338
1289
  requestTimeout;
1290
+ name;
1291
+ authorizationProvider;
1292
+ authorizationResource;
339
1293
  /** Per-request TLS agent — created once from HttpTransportOptions.tls */
340
1294
  tlsAgent;
341
1295
  tools = [];
1296
+ serverMetadata;
342
1297
  abortController;
343
1298
  disconnectHandlers = [];
344
1299
  toolsChangedListeners = /* @__PURE__ */ new Set();
1300
+ resourcesChangedListeners = /* @__PURE__ */ new Set();
1301
+ promptsChangedListeners = /* @__PURE__ */ new Set();
1302
+ protocolVersion;
345
1303
  constructor(opts, transportName) {
346
1304
  validateTransportUrl(opts.url);
1305
+ this.name = opts.name;
347
1306
  this.url = opts.url;
348
1307
  this.headers = { ...opts.headers };
1308
+ this.authorizationProvider = opts.authorizationProvider;
1309
+ this.authorizationResource = canonicalMcpResource(opts.url);
349
1310
  this.timeout = opts.startupTimeoutMs ?? 1e4;
350
1311
  this.requestTimeout = opts.requestTimeoutMs ?? 6e4;
351
1312
  if (opts.tls) {
@@ -361,7 +1322,7 @@ var BaseHTTPTransport = class {
361
1322
  `[mcp:${transportName}] \u26A0\uFE0F TLS verification DISABLED for ${this.url}. Network attacks are possible \u2014 only use on localhost.`
362
1323
  );
363
1324
  }
364
- this.tlsAgent = new https.Agent({
1325
+ this.tlsAgent = new https2.Agent({
365
1326
  ca: opts.tls.ca,
366
1327
  rejectUnauthorized: opts.tls.rejectUnauthorized
367
1328
  });
@@ -370,9 +1331,52 @@ var BaseHTTPTransport = class {
370
1331
  getState() {
371
1332
  return this.state;
372
1333
  }
1334
+ async fetchWithAuthorization(input, init, signal) {
1335
+ const context = {
1336
+ serverName: this.name,
1337
+ resource: this.authorizationResource,
1338
+ signal
1339
+ };
1340
+ const send = async () => {
1341
+ signal?.throwIfAborted();
1342
+ const headers = new Headers(init.headers);
1343
+ if (this.protocolVersion) headers.set("MCP-Protocol-Version", this.protocolVersion);
1344
+ const token = await this.authorizationProvider?.getAccessToken(context);
1345
+ signal?.throwIfAborted();
1346
+ if (token) {
1347
+ headers.set(
1348
+ "Authorization",
1349
+ authorizationHeaderForToken(token, this.authorizationResource)
1350
+ );
1351
+ }
1352
+ return fetch(input, { ...init, headers });
1353
+ };
1354
+ let response = await send();
1355
+ if (response.status !== 401 || !this.authorizationProvider?.handleUnauthorized) {
1356
+ return response;
1357
+ }
1358
+ const challenge = parseMcpBearerChallenge(
1359
+ response.headers.get("www-authenticate"),
1360
+ this.authorizationResource
1361
+ );
1362
+ const retry = await this.authorizationProvider.handleUnauthorized(challenge, context);
1363
+ if (!retry) return response;
1364
+ await response.body?.cancel().catch(() => void 0);
1365
+ response = await send();
1366
+ return response;
1367
+ }
373
1368
  listTools() {
374
1369
  return [...this.tools];
375
1370
  }
1371
+ getServerMetadata() {
1372
+ const metadata = this.serverMetadata;
1373
+ if (!metadata) return void 0;
1374
+ return {
1375
+ ...metadata,
1376
+ capabilities: { ...metadata.capabilities },
1377
+ serverInfo: { ...metadata.serverInfo }
1378
+ };
1379
+ }
376
1380
  onDisconnect(cb) {
377
1381
  this.disconnectHandlers.push(cb);
378
1382
  return () => {
@@ -386,7 +1390,15 @@ var BaseHTTPTransport = class {
386
1390
  this.toolsChangedListeners.delete(cb);
387
1391
  };
388
1392
  }
389
- /**
1393
+ onResourcesChanged(cb) {
1394
+ this.resourcesChangedListeners.add(cb);
1395
+ return () => this.resourcesChangedListeners.delete(cb);
1396
+ }
1397
+ onPromptsChanged(cb) {
1398
+ this.promptsChangedListeners.add(cb);
1399
+ return () => this.promptsChangedListeners.delete(cb);
1400
+ }
1401
+ /**
390
1402
  * Fire all disconnect handlers. Subclasses call this when the connection
391
1403
  * drops so the registry can schedule reconnects.
392
1404
  */
@@ -398,6 +1410,22 @@ var BaseHTTPTransport = class {
398
1410
  }
399
1411
  }
400
1412
  }
1413
+ notifyResourcesChanged() {
1414
+ for (const cb of this.resourcesChangedListeners) {
1415
+ try {
1416
+ cb();
1417
+ } catch {
1418
+ }
1419
+ }
1420
+ }
1421
+ notifyPromptsChanged() {
1422
+ for (const cb of this.promptsChangedListeners) {
1423
+ try {
1424
+ cb();
1425
+ } catch {
1426
+ }
1427
+ }
1428
+ }
401
1429
  /**
402
1430
  * Apply the pinned TLS agent (if configured) to a `RequestInit` object.
403
1431
  * Uses `HttpDispatcher` from `@wrongstack/core`'s dispatcher-types shim,
@@ -444,6 +1472,7 @@ var SSETransport = class extends BaseHTTPTransport {
444
1472
  }
445
1473
  async connect() {
446
1474
  this.state = "connecting";
1475
+ this.serverMetadata = void 0;
447
1476
  this.abortController = new AbortController();
448
1477
  const signal = this.abortController.signal;
449
1478
  const startupTimer = setTimeout(() => this.abortController?.abort(), this.timeout);
@@ -454,7 +1483,7 @@ var SSETransport = class extends BaseHTTPTransport {
454
1483
  signal
455
1484
  };
456
1485
  this.applyTlsAgent(fetchOpts);
457
- const response = await fetch(sseUrl, fetchOpts);
1486
+ const response = await this.fetchWithAuthorization(sseUrl, fetchOpts, signal);
458
1487
  if (!response.ok) {
459
1488
  throw new ToolError({
460
1489
  message: `SSE connect HTTP ${response.status}: ${response.statusText}`,
@@ -478,6 +1507,10 @@ var SSETransport = class extends BaseHTTPTransport {
478
1507
  if (msg.method && !msg.id) {
479
1508
  if (msg.method === "notifications/tools/list_changed") {
480
1509
  void this.handleToolsListChanged();
1510
+ } else if (msg.method === "notifications/resources/list_changed") {
1511
+ this.notifyResourcesChanged();
1512
+ } else if (msg.method === "notifications/prompts/list_changed") {
1513
+ this.notifyPromptsChanged();
481
1514
  }
482
1515
  }
483
1516
  });
@@ -500,6 +1533,8 @@ var SSETransport = class extends BaseHTTPTransport {
500
1533
  context: { transport: "sse", url: this.url }
501
1534
  });
502
1535
  }
1536
+ this.serverMetadata = parseServerMetadata(initRes.result);
1537
+ this.protocolVersion = this.serverMetadata.protocolVersion;
503
1538
  try {
504
1539
  await this.httpPost("notifications/initialized", {});
505
1540
  } catch {
@@ -509,11 +1544,7 @@ var SSETransport = class extends BaseHTTPTransport {
509
1544
  this.tools.splice(0, this.tools.length);
510
1545
  } else {
511
1546
  const result = toolsRes.result;
512
- this.tools.splice(
513
- 0,
514
- this.tools.length,
515
- ...normalizeMCPTools(result?.tools)
516
- );
1547
+ this.tools.splice(0, this.tools.length, ...normalizeMCPTools(result?.tools));
517
1548
  }
518
1549
  this.state = "connected";
519
1550
  clearTimeout(startupTimer);
@@ -542,7 +1573,7 @@ var SSETransport = class extends BaseHTTPTransport {
542
1573
  buildSSEUrl() {
543
1574
  try {
544
1575
  const url = new URL(this.url);
545
- url.searchParams.set("session", randomBytes(16).toString("hex"));
1576
+ url.searchParams.set("session", randomBytes2(16).toString("hex"));
546
1577
  return url.toString();
547
1578
  } catch {
548
1579
  return this.url;
@@ -565,7 +1596,7 @@ var SSETransport = class extends BaseHTTPTransport {
565
1596
  };
566
1597
  this.applyTlsAgent(fetchOpts);
567
1598
  try {
568
- const res = await fetch(this.url, fetchOpts);
1599
+ const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
569
1600
  if (!res.ok) {
570
1601
  const body2 = await res.text();
571
1602
  const cap = MCP_CONSTANTS.REQUEST_LOG_CAP;
@@ -624,13 +1655,12 @@ var SSETransport = class extends BaseHTTPTransport {
624
1655
  };
625
1656
  }
626
1657
  /** Generic JSON-RPC request — used by MCPClient.request() for SSE transports. */
627
- async request(method, params, timeoutMs) {
1658
+ async request(method, params, timeoutMs, opts) {
628
1659
  const id = this.genId();
629
1660
  const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
630
- const timeoutSignal = createTimeoutSignal(
631
- this.abortController?.signal,
632
- timeoutMs ?? this.requestTimeout
633
- );
1661
+ const external = opts?.signal;
1662
+ const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
1663
+ const timeoutSignal = createTimeoutSignal(parent, timeoutMs ?? this.requestTimeout);
634
1664
  const fetchOpts = {
635
1665
  method: "POST",
636
1666
  headers: {
@@ -642,7 +1672,7 @@ var SSETransport = class extends BaseHTTPTransport {
642
1672
  };
643
1673
  this.applyTlsAgent(fetchOpts);
644
1674
  try {
645
- const res = await fetch(this.url, fetchOpts);
1675
+ const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
646
1676
  if (!res.ok) {
647
1677
  throw new ToolError({
648
1678
  message: `HTTP ${res.status}: ${res.statusText}`,
@@ -670,6 +1700,16 @@ var SSETransport = class extends BaseHTTPTransport {
670
1700
  }
671
1701
  const result = assertMatchingJsonRpcResult(data, id, method);
672
1702
  return { jsonrpc: "2.0", id, result: result.result, error: result.error };
1703
+ } catch (err) {
1704
+ if (external?.aborted && !method.startsWith("notifications/")) {
1705
+ void this.httpPost("notifications/cancelled", {
1706
+ requestId: id,
1707
+ reason: "client aborted"
1708
+ }).catch(() => {
1709
+ });
1710
+ throw makeAbortError(method);
1711
+ }
1712
+ throw err;
673
1713
  } finally {
674
1714
  timeoutSignal.dispose();
675
1715
  }
@@ -700,8 +1740,45 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
700
1740
  genId() {
701
1741
  return this._nextId++;
702
1742
  }
1743
+ consumeResponseText(text, requestId) {
1744
+ const envelopes = extractJsonRpcEnvelopes(text);
1745
+ for (const envelope of envelopes) {
1746
+ if ("method" in envelope && envelope.id === void 0) {
1747
+ this.handleNotification(envelope.method);
1748
+ }
1749
+ }
1750
+ const responses = envelopes.filter(isJsonRpcResult);
1751
+ return responses.find((envelope) => envelope.id === requestId) ?? responses.find((envelope) => envelope.id !== void 0) ?? responses[0];
1752
+ }
1753
+ handleNotification(method) {
1754
+ if (method === "notifications/resources/list_changed") {
1755
+ this.notifyResourcesChanged();
1756
+ } else if (method === "notifications/prompts/list_changed") {
1757
+ this.notifyPromptsChanged();
1758
+ } else if (method === "notifications/tools/list_changed") {
1759
+ void this.refreshTools();
1760
+ }
1761
+ }
1762
+ async refreshTools() {
1763
+ try {
1764
+ const response = await this.postRaw("tools/list", {});
1765
+ if (response.error) return;
1766
+ const tools = normalizeMCPTools(
1767
+ response.result?.tools
1768
+ );
1769
+ this.tools.splice(0, this.tools.length, ...tools);
1770
+ for (const listener of this.toolsChangedListeners) {
1771
+ try {
1772
+ listener([...tools]);
1773
+ } catch {
1774
+ }
1775
+ }
1776
+ } catch {
1777
+ }
1778
+ }
703
1779
  async connect() {
704
1780
  this.state = "connecting";
1781
+ this.serverMetadata = void 0;
705
1782
  this.abortController = new AbortController();
706
1783
  const signal = this.abortController.signal;
707
1784
  const startupTimer = setTimeout(() => this.abortController?.abort(), this.timeout);
@@ -726,7 +1803,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
726
1803
  signal
727
1804
  };
728
1805
  this.applyTlsAgent(initFetchOpts);
729
- const initRes = await fetch(this.url, initFetchOpts);
1806
+ const initRes = await this.fetchWithAuthorization(this.url, initFetchOpts, signal);
730
1807
  if (!initRes.ok) {
731
1808
  throw new Error(`initialize HTTP ${initRes.status}: ${initRes.statusText}`);
732
1809
  }
@@ -745,6 +1822,8 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
745
1822
  if (data.error) {
746
1823
  throw new Error(`initialize failed: ${data.error.message}`);
747
1824
  }
1825
+ this.serverMetadata = parseServerMetadata(data.result);
1826
+ this.protocolVersion = this.serverMetadata.protocolVersion;
748
1827
  this.sessionId = initRes.headers.get("mcp-session-id") ?? void 0;
749
1828
  await this.postRaw("notifications/initialized", {});
750
1829
  const toolsRes = await this.postRaw("tools/list", {});
@@ -782,7 +1861,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
782
1861
  };
783
1862
  this.applyTlsAgent(fetchOpts);
784
1863
  try {
785
- const res = await fetch(this.url, fetchOpts);
1864
+ const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
786
1865
  if (!res.ok) {
787
1866
  throw new Error(`HTTP ${res.status}: ${res.statusText}`);
788
1867
  }
@@ -790,7 +1869,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
790
1869
  await res.text().catch(() => void 0);
791
1870
  return { jsonrpc: "2.0" };
792
1871
  }
793
- const match = pickJsonRpcResult(await res.text(), id);
1872
+ const match = this.consumeResponseText(await res.text(), id);
794
1873
  if (match) {
795
1874
  return assertMatchingJsonRpcResult(match, id, method);
796
1875
  }
@@ -810,13 +1889,12 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
810
1889
  }
811
1890
  }
812
1891
  /** Generic JSON-RPC request — used by MCPClient.request() for SSE/streamable-http transports. */
813
- async request(method, params, timeoutMs) {
1892
+ async request(method, params, timeoutMs, opts) {
814
1893
  const id = this.genId();
815
1894
  const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
816
- const timeoutSignal = createTimeoutSignal(
817
- this.abortController?.signal,
818
- timeoutMs ?? this.requestTimeout
819
- );
1895
+ const external = opts?.signal;
1896
+ const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
1897
+ const timeoutSignal = createTimeoutSignal(parent, timeoutMs ?? this.requestTimeout);
820
1898
  const fetchOpts = {
821
1899
  method: "POST",
822
1900
  headers: {
@@ -829,8 +1907,8 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
829
1907
  signal: timeoutSignal.signal
830
1908
  };
831
1909
  this.applyTlsAgent(fetchOpts);
832
- const res = await fetch(this.url, fetchOpts);
833
1910
  try {
1911
+ const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
834
1912
  if (!res.ok) {
835
1913
  throw new Error(`HTTP ${res.status}: ${res.statusText}`);
836
1914
  }
@@ -838,7 +1916,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
838
1916
  await res.text().catch(() => void 0);
839
1917
  return { jsonrpc: "2.0", id };
840
1918
  }
841
- const parsed = pickJsonRpcResult(await res.text(), id);
1919
+ const parsed = this.consumeResponseText(await res.text(), id);
842
1920
  if (parsed) {
843
1921
  return {
844
1922
  jsonrpc: "2.0",
@@ -848,6 +1926,16 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
848
1926
  };
849
1927
  }
850
1928
  throw new Error("Could not parse response as JSON-RPC");
1929
+ } catch (err) {
1930
+ if (external?.aborted && !method.startsWith("notifications/")) {
1931
+ void this.postRaw("notifications/cancelled", {
1932
+ requestId: id,
1933
+ reason: "client aborted"
1934
+ }).catch(() => {
1935
+ });
1936
+ throw makeAbortError(method);
1937
+ }
1938
+ throw err;
851
1939
  } finally {
852
1940
  timeoutSignal.dispose();
853
1941
  }
@@ -875,6 +1963,18 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
875
1963
  };
876
1964
 
877
1965
  // src/client.ts
1966
+ function isJsonRpcResponse(value) {
1967
+ if (typeof value !== "object" || value === null) return false;
1968
+ const response = value;
1969
+ if (response["jsonrpc"] !== "2.0" || typeof response["id"] !== "number") return false;
1970
+ if (Object.hasOwn(response, "method")) return false;
1971
+ const hasResult = Object.hasOwn(response, "result");
1972
+ const hasError = Object.hasOwn(response, "error");
1973
+ if (hasResult === hasError) return false;
1974
+ if (!hasError) return true;
1975
+ const error = response["error"];
1976
+ return typeof error === "object" && error !== null && typeof error["code"] === "number" && typeof error["message"] === "string";
1977
+ }
878
1978
  var MCPClient = class {
879
1979
  constructor(opts) {
880
1980
  this.opts = opts;
@@ -891,6 +1991,8 @@ var MCPClient = class {
891
1991
  pending = /* @__PURE__ */ new Map();
892
1992
  rxBuffer = "";
893
1993
  _tools = [];
1994
+ /** Server-declared handshake metadata. Populated for stdio in the first protocol slice. */
1995
+ _serverMetadata;
894
1996
  /** Cached tool list — survives reconnects so the registry can re-register without re-discovering. */
895
1997
  _toolsCache;
896
1998
  _drainPending = false;
@@ -902,11 +2004,22 @@ var MCPClient = class {
902
2004
  exitListeners = /* @__PURE__ */ new Set();
903
2005
  /** Notified when the server announces a tools/list_changed notification. */
904
2006
  toolsChangedListeners = /* @__PURE__ */ new Set();
2007
+ resourcesChangedListeners = /* @__PURE__ */ new Set();
2008
+ promptsChangedListeners = /* @__PURE__ */ new Set();
905
2009
  /** Notified when an HTTP transport (SSE or streamable-http) disconnects. */
906
2010
  disconnectListeners = /* @__PURE__ */ new Set();
907
2011
  getState() {
908
2012
  return this.state;
909
2013
  }
2014
+ getServerMetadata() {
2015
+ const metadata = this._serverMetadata;
2016
+ if (!metadata) return void 0;
2017
+ return {
2018
+ ...metadata,
2019
+ capabilities: { ...metadata.capabilities },
2020
+ serverInfo: { ...metadata.serverInfo }
2021
+ };
2022
+ }
910
2023
  listTools() {
911
2024
  return this._tools.length > 0 ? [...this._tools] : this._toolsCache ? [...this._toolsCache] : [];
912
2025
  }
@@ -936,6 +2049,7 @@ var MCPClient = class {
936
2049
  }
937
2050
  async connect() {
938
2051
  this.state = "connecting";
2052
+ this._serverMetadata = void 0;
939
2053
  if (this.opts.transport === "stdio") {
940
2054
  await this.connectStdio();
941
2055
  } else if (this.opts.transport === "sse") {
@@ -1007,6 +2121,12 @@ var MCPClient = class {
1007
2121
  this.state = "failed";
1008
2122
  throw new Error(`MCP initialize failed: ${initialize.error.message}`);
1009
2123
  }
2124
+ try {
2125
+ this._serverMetadata = parseServerMetadata(initialize.result);
2126
+ } catch (err) {
2127
+ this.state = "failed";
2128
+ throw new Error(`MCP initialize returned malformed server metadata: ${toErrorMessage(err)}`);
2129
+ }
1010
2130
  try {
1011
2131
  await this.notify("notifications/initialized", {});
1012
2132
  } catch (err) {
@@ -1034,7 +2154,8 @@ var MCPClient = class {
1034
2154
  url: this.opts.url,
1035
2155
  headers: this.opts.headers,
1036
2156
  startupTimeoutMs: this.opts.startupTimeoutMs,
1037
- requestTimeoutMs: this.opts.requestTimeoutMs
2157
+ requestTimeoutMs: this.opts.requestTimeoutMs,
2158
+ authorizationProvider: this.opts.authorizationProvider
1038
2159
  };
1039
2160
  this.sseTransport = new SSETransport(httpOpts);
1040
2161
  this.sseTransport.onDisconnect(() => {
@@ -1056,6 +2177,8 @@ var MCPClient = class {
1056
2177
  }
1057
2178
  }
1058
2179
  });
2180
+ this.sseTransport.onResourcesChanged(() => this.emitCapabilityChanged("resources"));
2181
+ this.sseTransport.onPromptsChanged(() => this.emitCapabilityChanged("prompts"));
1059
2182
  try {
1060
2183
  await this.sseTransport.connect();
1061
2184
  } catch (err) {
@@ -1068,6 +2191,7 @@ var MCPClient = class {
1068
2191
  }
1069
2192
  this._tools = this.sseTransport.listTools();
1070
2193
  this._toolsCache = this._tools;
2194
+ this._serverMetadata = this.sseTransport.getServerMetadata();
1071
2195
  this.state = "connected";
1072
2196
  }
1073
2197
  async connectStreamableHTTP() {
@@ -1080,7 +2204,8 @@ var MCPClient = class {
1080
2204
  url: this.opts.url,
1081
2205
  headers: this.opts.headers,
1082
2206
  startupTimeoutMs: this.opts.startupTimeoutMs,
1083
- requestTimeoutMs: this.opts.requestTimeoutMs
2207
+ requestTimeoutMs: this.opts.requestTimeoutMs,
2208
+ authorizationProvider: this.opts.authorizationProvider
1084
2209
  };
1085
2210
  this.httpTransport = new StreamableHTTPTransport(httpOpts);
1086
2211
  this.httpTransport.onDisconnect(() => {
@@ -1102,6 +2227,8 @@ var MCPClient = class {
1102
2227
  }
1103
2228
  }
1104
2229
  });
2230
+ this.httpTransport.onResourcesChanged(() => this.emitCapabilityChanged("resources"));
2231
+ this.httpTransport.onPromptsChanged(() => this.emitCapabilityChanged("prompts"));
1105
2232
  try {
1106
2233
  await this.httpTransport.connect();
1107
2234
  } catch (err) {
@@ -1114,6 +2241,7 @@ var MCPClient = class {
1114
2241
  }
1115
2242
  this._tools = this.httpTransport.listTools();
1116
2243
  this._toolsCache = this._tools;
2244
+ this._serverMetadata = this.httpTransport.getServerMetadata();
1117
2245
  this.state = "connected";
1118
2246
  }
1119
2247
  async callTool(name, input, opts) {
@@ -1136,6 +2264,79 @@ var MCPClient = class {
1136
2264
  isError: Boolean(result?.isError)
1137
2265
  };
1138
2266
  }
2267
+ async listResources(opts = {}) {
2268
+ const params = pageParams(opts.cursor, "resources/list cursor");
2269
+ return this.requestCapability(
2270
+ "resources",
2271
+ "resources/list",
2272
+ params,
2273
+ parseListResourcesResult,
2274
+ opts
2275
+ );
2276
+ }
2277
+ async listResourceTemplates(opts = {}) {
2278
+ const params = pageParams(opts.cursor, "resources/templates/list cursor");
2279
+ return this.requestCapability(
2280
+ "resources",
2281
+ "resources/templates/list",
2282
+ params,
2283
+ parseListResourceTemplatesResult,
2284
+ opts
2285
+ );
2286
+ }
2287
+ async readResource(uri, opts = {}) {
2288
+ validateProtocolString(uri, "resource URI");
2289
+ return this.requestCapability(
2290
+ "resources",
2291
+ "resources/read",
2292
+ { uri },
2293
+ parseReadResourceResult,
2294
+ opts
2295
+ );
2296
+ }
2297
+ async subscribeResource(uri, opts = {}) {
2298
+ validateProtocolString(uri, "resource URI");
2299
+ this.requireResourceSubscriptions("resources/subscribe");
2300
+ await this.requestCapability(
2301
+ "resources",
2302
+ "resources/subscribe",
2303
+ { uri },
2304
+ parseEmptyResult,
2305
+ opts
2306
+ );
2307
+ }
2308
+ async unsubscribeResource(uri, opts = {}) {
2309
+ validateProtocolString(uri, "resource URI");
2310
+ this.requireResourceSubscriptions("resources/unsubscribe");
2311
+ await this.requestCapability(
2312
+ "resources",
2313
+ "resources/unsubscribe",
2314
+ { uri },
2315
+ parseEmptyResult,
2316
+ opts
2317
+ );
2318
+ }
2319
+ async listPrompts(opts = {}) {
2320
+ const params = pageParams(opts.cursor, "prompts/list cursor");
2321
+ return this.requestCapability("prompts", "prompts/list", params, parseListPromptsResult, opts);
2322
+ }
2323
+ async getPrompt(name, args, opts = {}) {
2324
+ validateProtocolString(name, "prompt name");
2325
+ if (args && Object.keys(args).length > 64) {
2326
+ throw new Error("MCP prompt arguments exceed the limit of 64");
2327
+ }
2328
+ for (const [key, value] of Object.entries(args ?? {})) {
2329
+ validateProtocolString(key, "prompt argument name");
2330
+ validateProtocolString(value, `prompt argument "${key}"`, true);
2331
+ }
2332
+ return this.requestCapability(
2333
+ "prompts",
2334
+ "prompts/get",
2335
+ args === void 0 ? { name } : { name, arguments: args },
2336
+ parseGetPromptResult,
2337
+ opts
2338
+ );
2339
+ }
1139
2340
  async close() {
1140
2341
  if (this.child) {
1141
2342
  const child = this.child;
@@ -1170,8 +2371,8 @@ var MCPClient = class {
1170
2371
  this.state = "disconnected";
1171
2372
  }
1172
2373
  request(method, params, timeoutMs = this.opts.requestTimeoutMs ?? 6e4, opts) {
1173
- if (this.sseTransport) return this.sseTransport.request(method, params, timeoutMs);
1174
- if (this.httpTransport) return this.httpTransport.request(method, params, timeoutMs);
2374
+ if (this.sseTransport) return this.sseTransport.request(method, params, timeoutMs, opts);
2375
+ if (this.httpTransport) return this.httpTransport.request(method, params, timeoutMs, opts);
1175
2376
  const signal = opts?.signal;
1176
2377
  if (signal?.aborted) {
1177
2378
  const err = new Error(`MCP "${this.opts.name}" request "${method}" aborted before send`);
@@ -1238,6 +2439,37 @@ var MCPClient = class {
1238
2439
  }
1239
2440
  });
1240
2441
  }
2442
+ async requestCapability(capability, method, params, parse, opts) {
2443
+ if (this.state !== "connected") {
2444
+ throw new Error(`MCP client "${this.opts.name}" not connected (state=${this.state})`);
2445
+ }
2446
+ const metadata = this._serverMetadata;
2447
+ if (!metadata) {
2448
+ throw new Error(
2449
+ `MCP server "${this.opts.name}" capability metadata is unavailable for ${method}`
2450
+ );
2451
+ }
2452
+ if (!metadata.capabilities[capability]) {
2453
+ throw new Error(
2454
+ `MCP server "${this.opts.name}" does not advertise the ${capability} capability`
2455
+ );
2456
+ }
2457
+ const response = await this.request(method, params, void 0, opts);
2458
+ if (response.error) {
2459
+ throw new Error(`MCP ${method} failed: ${response.error.message}`);
2460
+ }
2461
+ return parse(response.result);
2462
+ }
2463
+ requireResourceSubscriptions(method) {
2464
+ if (this.state !== "connected") {
2465
+ throw new Error(`MCP client "${this.opts.name}" not connected (state=${this.state})`);
2466
+ }
2467
+ if (this._serverMetadata?.capabilities.resources?.subscribe !== true) {
2468
+ throw new Error(
2469
+ `MCP server "${this.opts.name}" does not advertise resource subscriptions for ${method}`
2470
+ );
2471
+ }
2472
+ }
1241
2473
  /**
1242
2474
  * Reject every in-flight {@link request} call. Used when the underlying
1243
2475
  * transport dies — without this, callers awaiting `tools/call` over a
@@ -1322,14 +2554,48 @@ var MCPClient = class {
1322
2554
  } catch {
1323
2555
  return;
1324
2556
  }
1325
- if (msg.id !== void 0 && this.pending.has(msg.id)) {
2557
+ if (typeof msg !== "object" || msg === null) return;
2558
+ const envelope = msg;
2559
+ if (envelope["jsonrpc"] !== "2.0") return;
2560
+ if (typeof envelope["method"] === "string") {
2561
+ const id = envelope["id"];
2562
+ if (typeof id === "number" || typeof id === "string") {
2563
+ this.handleServerRequest({
2564
+ jsonrpc: "2.0",
2565
+ id,
2566
+ method: envelope["method"],
2567
+ params: envelope["params"]
2568
+ });
2569
+ return;
2570
+ }
2571
+ if (Object.hasOwn(envelope, "id")) return;
2572
+ if (envelope["method"] === "notifications/tools/list_changed") {
2573
+ void this.handleToolsListChanged();
2574
+ } else if (envelope["method"] === "notifications/resources/list_changed") {
2575
+ this.emitCapabilityChanged("resources");
2576
+ } else if (envelope["method"] === "notifications/prompts/list_changed") {
2577
+ this.emitCapabilityChanged("prompts");
2578
+ }
2579
+ return;
2580
+ }
2581
+ if (!isJsonRpcResponse(msg)) return;
2582
+ if (this.pending.has(msg.id)) {
1326
2583
  const entry = this.pending.get(msg.id);
1327
2584
  this.pending.delete(msg.id);
1328
2585
  entry?.resolve(msg);
1329
- return;
1330
2586
  }
1331
- if (typeof msg.method === "string" && msg.method === "notifications/tools/list_changed") {
1332
- void this.handleToolsListChanged();
2587
+ }
2588
+ handleServerRequest(request3) {
2589
+ const message = request3.method === "sampling/createMessage" ? "Client sampling is disabled by policy" : `Method not found: ${request3.method}`;
2590
+ const response = {
2591
+ jsonrpc: "2.0",
2592
+ id: request3.id,
2593
+ error: { code: -32601, message }
2594
+ };
2595
+ try {
2596
+ this.child?.stdin?.write(`${JSON.stringify(response)}
2597
+ `);
2598
+ } catch {
1333
2599
  }
1334
2600
  }
1335
2601
  /**
@@ -1361,138 +2627,761 @@ var MCPClient = class {
1361
2627
  removeToolsChangedListener(listener) {
1362
2628
  this.toolsChangedListeners.delete(listener);
1363
2629
  }
2630
+ addResourcesChangedListener(listener) {
2631
+ this.resourcesChangedListeners.add(listener);
2632
+ }
2633
+ removeResourcesChangedListener(listener) {
2634
+ this.resourcesChangedListeners.delete(listener);
2635
+ }
2636
+ addPromptsChangedListener(listener) {
2637
+ this.promptsChangedListeners.add(listener);
2638
+ }
2639
+ removePromptsChangedListener(listener) {
2640
+ this.promptsChangedListeners.delete(listener);
2641
+ }
2642
+ emitCapabilityChanged(capability) {
2643
+ const listeners = capability === "resources" ? this.resourcesChangedListeners : this.promptsChangedListeners;
2644
+ for (const listener of listeners) {
2645
+ try {
2646
+ listener(this.opts.name);
2647
+ } catch {
2648
+ }
2649
+ }
2650
+ }
1364
2651
  };
1365
2652
  function quoteWindowsArg(arg) {
1366
2653
  if (!/[\s"]/.test(arg)) return arg;
1367
2654
  return `"${arg.replace(/"/g, '""')}"`;
1368
2655
  }
1369
-
1370
- // src/wrap-tool.ts
1371
- import { ToolCapabilities } from "@wrongstack/core";
1372
- var MUTATING_RE = /create|update|delete|write|send|set|put|post|patch|remove|rename|move/i;
1373
- function isMutatingTool(mcpTool) {
1374
- if (MUTATING_RE.test(mcpTool.name)) return true;
1375
- const schema = mcpTool.inputSchema;
1376
- if (schema && typeof schema === "object") {
1377
- const props = schema.properties;
1378
- if (props) {
1379
- for (const key of Object.keys(props)) {
1380
- if (MUTATING_RE.test(key)) return true;
1381
- }
1382
- }
2656
+ var MAX_PROTOCOL_INPUT_CHARS = 8192;
2657
+ function validateProtocolString(value, label, allowEmpty = false) {
2658
+ if (typeof value !== "string" || !allowEmpty && value.length === 0) {
2659
+ throw new Error(`MCP ${label} must be ${allowEmpty ? "a string" : "a non-empty string"}`);
2660
+ }
2661
+ if (value.length > MAX_PROTOCOL_INPUT_CHARS) {
2662
+ throw new Error(`MCP ${label} exceeds ${MAX_PROTOCOL_INPUT_CHARS} characters`);
1383
2663
  }
1384
- return false;
1385
2664
  }
1386
- function wrapMCPTool(serverName, mcpTool, client, permission = "confirm") {
1387
- const qualifiedName = `mcp__${serverName}__${mcpTool.name}`;
2665
+ function pageParams(cursor, label) {
2666
+ if (cursor === void 0) return {};
2667
+ validateProtocolString(cursor, label);
2668
+ return { cursor };
2669
+ }
2670
+ function parseEmptyResult(value) {
2671
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2672
+ throw new Error("Malformed MCP empty result: expected object");
2673
+ }
2674
+ }
2675
+
2676
+ // src/content-selection.ts
2677
+ var DEFAULT_MCP_INSERTION_MAX_BYTES = 256 * 1024;
2678
+ var DEFAULT_MCP_RESOURCE_SCHEMES = [
2679
+ "file",
2680
+ "git",
2681
+ "http",
2682
+ "https",
2683
+ "mcp",
2684
+ "mem",
2685
+ "repo",
2686
+ "resource"
2687
+ ];
2688
+ function prepareResourceInsertion(serverName, requestedUri, result, policy = {}) {
2689
+ requireIdentity(serverName, "server name");
2690
+ validateUri(requestedUri, policy);
2691
+ if (result.contents.length > 64) {
2692
+ throw new Error("MCP resource insertion exceeds the limit of 64 content blocks");
2693
+ }
2694
+ let byteSize = 0;
2695
+ for (const content of result.contents) {
2696
+ validateUri(content.uri, policy);
2697
+ if (content.text !== void 0) byteSize += utf8Bytes(content.text);
2698
+ if (content.blob !== void 0) byteSize += base64DecodedBytes(content.blob);
2699
+ enforceSize(byteSize, policy);
2700
+ }
1388
2701
  return {
1389
- name: qualifiedName,
1390
- description: mcpTool.description ?? `${qualifiedName} (MCP tool)`,
1391
- usageHint: `Tool provided by MCP server "${serverName}". ${mcpTool.description ?? ""}`,
1392
- permission,
1393
- mutating: isMutatingTool(mcpTool),
1394
- capabilities: [ToolCapabilities.MCP_PROXY],
1395
- inputSchema: mcpTool.inputSchema ?? { type: "object", properties: {} },
1396
- async execute(input, _ctx, opts) {
1397
- const live = typeof client === "function" ? await client() : client;
1398
- const res = await live.callTool(mcpTool.name, input, { signal: opts.signal });
1399
- if (res.isError) {
1400
- throw new Error(stringify(res.content));
1401
- }
1402
- return stringify(res.content);
1403
- }
2702
+ kind: "resource",
2703
+ untrusted: true,
2704
+ byteSize,
2705
+ provenance: {
2706
+ origin: "mcp",
2707
+ serverName,
2708
+ capability: "resource",
2709
+ resourceUri: requestedUri
2710
+ },
2711
+ contents: structuredClone(result.contents)
1404
2712
  };
1405
2713
  }
1406
- function stringify(c) {
1407
- if (typeof c === "string") return c;
1408
- if (Array.isArray(c)) {
1409
- return c.map((item) => {
1410
- if (item && typeof item === "object") {
1411
- const t = item.type;
1412
- if (t === "text") return item.text ?? "";
1413
- return JSON.stringify(item);
1414
- }
1415
- return String(item);
1416
- }).join("\n");
2714
+ function preparePromptInsertion(serverName, promptName, args, result, policy = {}) {
2715
+ requireIdentity(serverName, "server name");
2716
+ requireIdentity(promptName, "prompt name");
2717
+ if (result.messages.length > 128) {
2718
+ throw new Error("MCP prompt insertion exceeds the limit of 128 messages");
1417
2719
  }
1418
- if (c && typeof c === "object") {
1419
- if ("text" in c) {
1420
- return String(c.text);
1421
- }
1422
- return JSON.stringify(c);
2720
+ for (const message of result.messages) validateEmbeddedUris(message.content, policy, 0);
2721
+ let serialized;
2722
+ try {
2723
+ serialized = JSON.stringify(result.messages);
2724
+ } catch {
2725
+ throw new Error("MCP prompt insertion contains non-serializable content");
1423
2726
  }
1424
- return String(c ?? "");
1425
- }
1426
-
1427
- // src/registry.ts
1428
- import { expectDefined } from "@wrongstack/core";
1429
-
1430
- // src/manifest-cache.ts
1431
- import { createHash } from "node:crypto";
1432
- import * as fs from "node:fs/promises";
1433
- import * as path from "node:path";
1434
- function manifestConfigHash(cfg) {
1435
- const basis = JSON.stringify({
1436
- transport: cfg.transport,
1437
- command: cfg.command ?? null,
1438
- args: cfg.args ?? null,
1439
- url: cfg.url ?? null
1440
- });
1441
- return createHash("sha256").update(basis).digest("hex").slice(0, 16);
2727
+ const byteSize = utf8Bytes(serialized);
2728
+ enforceSize(byteSize, policy);
2729
+ return {
2730
+ kind: "prompt",
2731
+ untrusted: true,
2732
+ byteSize,
2733
+ provenance: {
2734
+ origin: "mcp",
2735
+ serverName,
2736
+ capability: "prompt",
2737
+ promptName,
2738
+ promptArgumentNames: Object.keys(args ?? {}).sort()
2739
+ },
2740
+ description: result.description,
2741
+ messages: structuredClone(result.messages)
2742
+ };
1442
2743
  }
1443
- function manifestFile(cacheDir, name) {
1444
- const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
1445
- return path.join(cacheDir, "mcp-tools", `${safe}.json`);
2744
+ function validateEmbeddedUris(value, policy, depth) {
2745
+ if (depth > 32) throw new Error("MCP prompt insertion exceeds the nesting depth limit");
2746
+ if (Array.isArray(value)) {
2747
+ for (const item of value) validateEmbeddedUris(item, policy, depth + 1);
2748
+ return;
2749
+ }
2750
+ if (!value || typeof value !== "object") return;
2751
+ for (const [key, nested] of Object.entries(value)) {
2752
+ if (key === "uri" && typeof nested === "string") validateUri(nested, policy);
2753
+ validateEmbeddedUris(nested, policy, depth + 1);
2754
+ }
1446
2755
  }
1447
- async function readManifest(cacheDir, name, configHash) {
2756
+ function validateUri(uri, policy) {
2757
+ if (uri.length === 0 || uri.length > 8192) {
2758
+ throw new Error("MCP insertion URI must contain 1\u20138192 characters");
2759
+ }
2760
+ let parsed;
1448
2761
  try {
1449
- const raw = await fs.readFile(manifestFile(cacheDir, name), "utf8");
1450
- const parsed = JSON.parse(raw);
1451
- if (parsed.configHash !== configHash || !Array.isArray(parsed.tools)) return null;
1452
- return parsed.tools;
2762
+ parsed = new URL(uri);
1453
2763
  } catch {
1454
- return null;
2764
+ throw new Error("MCP insertion URI must be absolute");
2765
+ }
2766
+ const scheme = parsed.protocol.slice(0, -1).toLowerCase();
2767
+ const allowed = new Set(
2768
+ (policy.allowedUriSchemes ?? DEFAULT_MCP_RESOURCE_SCHEMES).map((value) => value.toLowerCase())
2769
+ );
2770
+ if (!allowed.has(scheme)) {
2771
+ throw new Error(`MCP insertion URI scheme "${scheme}" is not allowed`);
2772
+ }
2773
+ if ((scheme === "http" || scheme === "https") && (parsed.username || parsed.password)) {
2774
+ throw new Error("MCP insertion URI must not contain credentials");
1455
2775
  }
1456
2776
  }
1457
- async function writeManifest(cacheDir, name, configHash, tools) {
2777
+ function enforceSize(byteSize, policy) {
2778
+ const maxBytes = policy.maxBytes ?? DEFAULT_MCP_INSERTION_MAX_BYTES;
2779
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
2780
+ throw new Error("MCP insertion maxBytes must be a positive safe integer");
2781
+ }
2782
+ if (byteSize > maxBytes) {
2783
+ throw new Error(`MCP insertion exceeds the ${maxBytes}-byte content limit`);
2784
+ }
2785
+ }
2786
+ function base64DecodedBytes(blob) {
2787
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(blob)) {
2788
+ throw new Error("MCP resource insertion contains invalid base64 content");
2789
+ }
2790
+ const padding = blob.endsWith("==") ? 2 : blob.endsWith("=") ? 1 : 0;
2791
+ return blob.length / 4 * 3 - padding;
2792
+ }
2793
+ function utf8Bytes(value) {
2794
+ return new TextEncoder().encode(value).byteLength;
2795
+ }
2796
+ function requireIdentity(value, label) {
2797
+ if (value.length === 0 || value.length > 256) {
2798
+ throw new Error(`MCP insertion ${label} must contain 1\u2013256 characters`);
2799
+ }
2800
+ }
2801
+
2802
+ // src/manage.ts
2803
+ import { randomBytes as randomBytes3 } from "node:crypto";
2804
+ import * as fs from "node:fs/promises";
2805
+ async function readConfig(path2) {
1458
2806
  try {
1459
- const file = manifestFile(cacheDir, name);
1460
- await fs.mkdir(path.dirname(file), { recursive: true });
1461
- const body = { configHash, tools };
1462
- const tmp = `${file}.tmp`;
1463
- await fs.writeFile(tmp, JSON.stringify(body, null, 2), "utf8");
1464
- await fs.rename(tmp, file);
2807
+ return JSON.parse(await fs.readFile(path2, "utf8"));
1465
2808
  } catch {
2809
+ return {};
1466
2810
  }
1467
2811
  }
1468
-
1469
- // src/registry.ts
1470
- var MCPRegistry = class _MCPRegistry {
1471
- servers = /* @__PURE__ */ new Map();
1472
- toolRegistry;
1473
- events;
1474
- log;
1475
- lazyMode;
1476
- cacheDir;
1477
- idleTimeoutMs;
1478
- /** Single shared idle sweep timer (started lazily; unref'd; cleared on stopAll). */
1479
- idleTimer;
1480
- constructor(opts) {
1481
- this.toolRegistry = opts.toolRegistry;
1482
- this.events = opts.events;
1483
- this.log = opts.log;
1484
- this.lazyMode = opts.lazyMode ?? false;
1485
- this.cacheDir = opts.cacheDir;
1486
- this.idleTimeoutMs = opts.idleTimeoutMs ?? MCP_CONSTANTS.IDLE.DEFAULT_TIMEOUT_MS;
2812
+ async function writeConfig(path2, cfg) {
2813
+ const raw = JSON.stringify(cfg, null, 2);
2814
+ const tmp = `${path2}.${process.pid}.${randomBytes3(6).toString("hex")}.tmp`;
2815
+ await fs.writeFile(tmp, raw, "utf8");
2816
+ try {
2817
+ await fs.rename(tmp, path2);
2818
+ } catch (err) {
2819
+ await fs.rm(tmp, { force: true }).catch(() => void 0);
2820
+ throw err;
1487
2821
  }
1488
- async start(cfg) {
1489
- if (cfg.enabled === false) return;
1490
- if (this.servers.has(cfg.name)) {
1491
- throw new Error(
1492
- `MCP server "${cfg.name}" is already registered \u2014 use restart() to re-cycle a running server`
1493
- );
1494
- }
1495
- const lazy = !!cfg.lazy && !!this.cacheDir;
2822
+ }
2823
+ function isMcpServerRecord(value) {
2824
+ return !!value && typeof value === "object" && !Array.isArray(value);
2825
+ }
2826
+ async function readServers(configPath) {
2827
+ const full = await readConfig(configPath);
2828
+ const servers = isMcpServerRecord(full.mcpServers) ? { ...full.mcpServers } : {};
2829
+ return { full, servers };
2830
+ }
2831
+ async function persist(configPath, full, servers) {
2832
+ full.mcpServers = servers;
2833
+ await writeConfig(configPath, full);
2834
+ }
2835
+ function normalizeTransport(t) {
2836
+ if (t === "sse") return "sse";
2837
+ if (t === "http" || t === "streamable-http") return "streamable-http";
2838
+ return "stdio";
2839
+ }
2840
+ function buildConfig(input, base) {
2841
+ const cfg = {
2842
+ name: input.name,
2843
+ transport: input.transport ? normalizeTransport(String(input.transport)) : base?.transport ?? "stdio"
2844
+ };
2845
+ const description = input.description ?? base?.description;
2846
+ if (description !== void 0) cfg.description = description;
2847
+ const command = input.command ?? base?.command;
2848
+ if (command !== void 0) cfg.command = command;
2849
+ const args = input.args ?? base?.args;
2850
+ if (args !== void 0) cfg.args = args;
2851
+ const env = input.env ?? base?.env;
2852
+ if (env !== void 0) cfg.env = env;
2853
+ const url = input.url ?? base?.url;
2854
+ if (url !== void 0) cfg.url = url;
2855
+ const headers = input.headers ?? base?.headers;
2856
+ if (headers !== void 0) cfg.headers = headers;
2857
+ const allowedTools = input.allowedTools ?? base?.allowedTools;
2858
+ if (allowedTools !== void 0) cfg.allowedTools = allowedTools;
2859
+ const permission = input.permission ?? base?.permission;
2860
+ if (permission !== void 0) cfg.permission = permission;
2861
+ const enabled = input.enabled ?? base?.enabled;
2862
+ if (enabled !== void 0) cfg.enabled = enabled;
2863
+ const lazy = input.lazy ?? base?.lazy;
2864
+ if (lazy !== void 0) cfg.lazy = lazy;
2865
+ const passthroughEnv = input.passthroughEnv ?? base?.passthroughEnv;
2866
+ if (passthroughEnv !== void 0) cfg.passthroughEnv = passthroughEnv;
2867
+ const health = input.health ?? base?.health;
2868
+ if (health !== void 0) cfg.health = health;
2869
+ return cfg;
2870
+ }
2871
+ function projectServer(name, cfg, registry) {
2872
+ const live = registry.list().find((s) => s.name === name);
2873
+ const info = {
2874
+ name,
2875
+ transport: cfg.transport,
2876
+ enabled: cfg.enabled !== false,
2877
+ status: live ? live.state : "stopped",
2878
+ tools: live?.tools ?? []
2879
+ };
2880
+ if (cfg.description !== void 0) info.description = cfg.description;
2881
+ if (cfg.url !== void 0) info.url = cfg.url;
2882
+ if (cfg.command !== void 0) info.command = cfg.command;
2883
+ if (cfg.lazy !== void 0) info.lazy = cfg.lazy;
2884
+ return info;
2885
+ }
2886
+ function liveState(name, registry) {
2887
+ const live = registry.list().find((s) => s.name === name);
2888
+ return { state: live?.state ?? "stopped", tools: live?.tools ?? [] };
2889
+ }
2890
+ function errMessage(err) {
2891
+ return err instanceof Error ? err.message : String(err);
2892
+ }
2893
+ async function listMcp(deps) {
2894
+ const { servers } = await readServers(deps.configPath);
2895
+ return Object.entries(servers).map(
2896
+ ([name, cfg]) => projectServer(name, { ...cfg, name }, deps.registry)
2897
+ );
2898
+ }
2899
+ async function addMcp(input, deps) {
2900
+ if (!input.name) return { ok: false, message: "Server name is required" };
2901
+ const { full, servers } = await readServers(deps.configPath);
2902
+ if (servers[input.name]) {
2903
+ return { ok: false, message: `Server "${input.name}" already exists` };
2904
+ }
2905
+ const preset = deps.presets?.[input.name];
2906
+ const hasExplicitConfig = !!(input.transport || input.command || input.url);
2907
+ const cfg = hasExplicitConfig ? buildConfig(input, preset) : preset ? buildConfig({ ...input, name: input.name }, preset) : buildConfig(input);
2908
+ if (!hasExplicitConfig && !preset) {
2909
+ const known = Object.keys(deps.presets ?? {}).join(", ");
2910
+ return {
2911
+ ok: false,
2912
+ message: known ? `Unknown server "${input.name}". Available presets: ${known}` : `No configuration provided for "${input.name}"`
2913
+ };
2914
+ }
2915
+ cfg.enabled = input.enabled ?? false;
2916
+ servers[input.name] = cfg;
2917
+ await persist(deps.configPath, full, servers);
2918
+ if (cfg.enabled) {
2919
+ return startServer(input.name, cfg, deps, `Server "${input.name}" added`);
2920
+ }
2921
+ trackDisabled(deps.registry, cfg);
2922
+ return {
2923
+ ok: true,
2924
+ message: `Server "${input.name}" added (disabled)`,
2925
+ server: projectServer(input.name, cfg, deps.registry)
2926
+ };
2927
+ }
2928
+ async function updateMcp(input, deps) {
2929
+ if (!input.name) return { ok: false, message: "Server name is required" };
2930
+ const { full, servers } = await readServers(deps.configPath);
2931
+ const existing = servers[input.name];
2932
+ if (!existing) return { ok: false, message: `Server "${input.name}" not found` };
2933
+ const cfg = buildConfig(input, { ...existing, name: input.name });
2934
+ servers[input.name] = cfg;
2935
+ await persist(deps.configPath, full, servers);
2936
+ if (cfg.enabled !== false) {
2937
+ return startServer(input.name, cfg, deps, `Server "${input.name}" updated`, { restart: true });
2938
+ }
2939
+ await safeStop(input.name, deps);
2940
+ trackDisabled(deps.registry, cfg);
2941
+ return {
2942
+ ok: true,
2943
+ message: `Server "${input.name}" updated`,
2944
+ server: projectServer(input.name, cfg, deps.registry)
2945
+ };
2946
+ }
2947
+ async function removeMcp(name, deps) {
2948
+ if (!name) return { ok: false, message: "Server name is required" };
2949
+ const { full, servers } = await readServers(deps.configPath);
2950
+ if (!servers[name]) return { ok: false, message: `Server "${name}" not found` };
2951
+ await safeStop(name, deps);
2952
+ forgetRegistryState(deps.registry, name);
2953
+ delete servers[name];
2954
+ await persist(deps.configPath, full, servers);
2955
+ return { ok: true, message: `Server "${name}" removed` };
2956
+ }
2957
+ async function enableMcp(name, deps) {
2958
+ if (!name) return { ok: false, message: "Server name is required" };
2959
+ const { full, servers } = await readServers(deps.configPath);
2960
+ const cfg = servers[name];
2961
+ if (!cfg) {
2962
+ return { ok: false, message: `Server "${name}" is not in config. Add it first.` };
2963
+ }
2964
+ cfg.enabled = true;
2965
+ servers[name] = cfg;
2966
+ await persist(deps.configPath, full, servers);
2967
+ return startServer(name, cfg, deps, `Server "${name}" enabled`, { restart: true });
2968
+ }
2969
+ async function disableMcp(name, deps) {
2970
+ if (!name) return { ok: false, message: "Server name is required" };
2971
+ const { full, servers } = await readServers(deps.configPath);
2972
+ const cfg = servers[name];
2973
+ if (!cfg) return { ok: false, message: `Server "${name}" is not in config.` };
2974
+ await safeStop(name, deps);
2975
+ cfg.enabled = false;
2976
+ trackDisabled(deps.registry, { ...cfg, name });
2977
+ servers[name] = cfg;
2978
+ await persist(deps.configPath, full, servers);
2979
+ return {
2980
+ ok: true,
2981
+ message: `Server "${name}" disabled`,
2982
+ server: projectServer(name, cfg, deps.registry)
2983
+ };
2984
+ }
2985
+ async function restartMcp(name, deps) {
2986
+ if (!name) return { ok: false, message: "Server name is required" };
2987
+ const registered = deps.registry.list().some((s) => s.name === name);
2988
+ if (registered) {
2989
+ try {
2990
+ await deps.registry.restart(name);
2991
+ const { state, tools } = liveState(name, deps.registry);
2992
+ return { ok: true, message: `Server "${name}" restarted`, state, tools };
2993
+ } catch (err) {
2994
+ return { ok: false, message: `Failed to restart "${name}": ${errMessage(err)}` };
2995
+ }
2996
+ }
2997
+ const { servers } = await readServers(deps.configPath);
2998
+ const cfg = servers[name];
2999
+ if (!cfg) return { ok: false, message: `Server "${name}" is not in config.` };
3000
+ return startServer(name, { ...cfg, name }, deps, `Server "${name}" started`, { restart: true });
3001
+ }
3002
+ async function discoverMcp(name, deps) {
3003
+ if (!name) return { ok: false, message: "Server name is required" };
3004
+ const result = await restartMcp(name, deps);
3005
+ if (!result.ok) return result;
3006
+ const { state, tools } = liveState(name, deps.registry);
3007
+ return {
3008
+ ok: true,
3009
+ message: `Discovered ${tools.length} tool${tools.length === 1 ? "" : "s"} from "${name}"`,
3010
+ state,
3011
+ tools
3012
+ };
3013
+ }
3014
+ async function startServer(name, cfg, deps, okMessage, opts) {
3015
+ try {
3016
+ const alreadyRegistered = deps.registry.list().some((s) => s.name === name);
3017
+ if (alreadyRegistered && opts?.restart) {
3018
+ await deps.registry.restart(name);
3019
+ } else if (alreadyRegistered) {
3020
+ await deps.registry.restart(name);
3021
+ } else {
3022
+ await deps.registry.start({ ...cfg, enabled: true });
3023
+ }
3024
+ const { state, tools } = liveState(name, deps.registry);
3025
+ return {
3026
+ ok: true,
3027
+ message: okMessage,
3028
+ server: projectServer(name, cfg, deps.registry),
3029
+ state,
3030
+ tools
3031
+ };
3032
+ } catch (err) {
3033
+ const message = errMessage(err);
3034
+ return {
3035
+ ok: true,
3036
+ // config persisted — surface a soft warning, not a hard failure
3037
+ message: `${okMessage} in config, but failed to start: ${message}`,
3038
+ server: projectServer(name, cfg, deps.registry),
3039
+ registryError: message
3040
+ };
3041
+ }
3042
+ }
3043
+ async function safeStop(name, deps) {
3044
+ try {
3045
+ await deps.registry.stop(name);
3046
+ } catch {
3047
+ }
3048
+ }
3049
+ function trackDisabled(registry, cfg) {
3050
+ if (typeof registry.markDisabled === "function") registry.markDisabled(cfg);
3051
+ }
3052
+ function forgetRegistryState(registry, name) {
3053
+ if (typeof registry.forget === "function") registry.forget(name);
3054
+ }
3055
+
3056
+ // src/manifest-cache.ts
3057
+ import { createHash as createHash2 } from "node:crypto";
3058
+ import * as fs2 from "node:fs/promises";
3059
+ import * as path from "node:path";
3060
+ function manifestConfigHash(cfg) {
3061
+ const basis = JSON.stringify({
3062
+ transport: cfg.transport,
3063
+ command: cfg.command ?? null,
3064
+ args: cfg.args ?? null,
3065
+ url: cfg.url ?? null
3066
+ });
3067
+ return createHash2("sha256").update(basis).digest("hex").slice(0, 16);
3068
+ }
3069
+ function manifestFile(cacheDir, name) {
3070
+ const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
3071
+ return path.join(cacheDir, "mcp-tools", `${safe}.json`);
3072
+ }
3073
+ async function readManifest(cacheDir, name, configHash) {
3074
+ const manifest = await readCapabilityManifest(cacheDir, name, configHash);
3075
+ return manifest?.tools ?? null;
3076
+ }
3077
+ async function readCapabilityManifest(cacheDir, name, configHash) {
3078
+ try {
3079
+ const raw = await fs2.readFile(manifestFile(cacheDir, name), "utf8");
3080
+ const parsed = JSON.parse(raw);
3081
+ if (parsed.configHash !== configHash || !Array.isArray(parsed.tools)) return null;
3082
+ return {
3083
+ tools: parsed.tools,
3084
+ serverMetadata: parsed.serverMetadata === void 0 ? void 0 : parseServerMetadata(parsed.serverMetadata),
3085
+ resources: parsed.resources === void 0 ? void 0 : parseListResourcesResult({ resources: parsed.resources }).resources,
3086
+ resourceTemplates: parsed.resourceTemplates === void 0 ? void 0 : parseListResourceTemplatesResult({ resourceTemplates: parsed.resourceTemplates }).resourceTemplates,
3087
+ prompts: parsed.prompts === void 0 ? void 0 : parseListPromptsResult({ prompts: parsed.prompts }).prompts
3088
+ };
3089
+ } catch {
3090
+ return null;
3091
+ }
3092
+ }
3093
+ async function writeManifest(cacheDir, name, configHash, tools) {
3094
+ const previous = await readCapabilityManifest(cacheDir, name, configHash);
3095
+ await writeCapabilityManifest(cacheDir, name, configHash, {
3096
+ ...previous,
3097
+ tools
3098
+ });
3099
+ }
3100
+ async function writeCapabilityManifest(cacheDir, name, configHash, manifest) {
3101
+ try {
3102
+ const file = manifestFile(cacheDir, name);
3103
+ await fs2.mkdir(path.dirname(file), { recursive: true });
3104
+ const body = { version: 2, configHash, ...manifest };
3105
+ const tmp = `${file}.tmp`;
3106
+ await fs2.writeFile(tmp, JSON.stringify(body, null, 2), "utf8");
3107
+ await fs2.rename(tmp, file);
3108
+ } catch {
3109
+ }
3110
+ }
3111
+
3112
+ // src/operations.ts
3113
+ var MCP_OPERATION_LIMITS = Object.freeze({
3114
+ LATENCY_SAMPLES: 128,
3115
+ RECENT_EVENTS: 32,
3116
+ REASON_CHARS: 64
3117
+ });
3118
+ var SAFE_OPERATION_REASONS = /* @__PURE__ */ new Set([
3119
+ "automatic",
3120
+ "complete",
3121
+ "connect-attempt-failed",
3122
+ "connected",
3123
+ "http-disconnect",
3124
+ "http-disconnect-lazy",
3125
+ "idle-timeout",
3126
+ "lazy-demand",
3127
+ "manual",
3128
+ "ok",
3129
+ "process-exit",
3130
+ "process-exit-lazy",
3131
+ "prompt-discovery-failed",
3132
+ "reconnect-exhausted",
3133
+ "resource-discovery-failed",
3134
+ "resource-template-discovery-failed",
3135
+ "started",
3136
+ "tool-call-failed"
3137
+ ]);
3138
+ function createMCPServerOperationState() {
3139
+ return {
3140
+ consecutiveFailures: 0,
3141
+ failures: { transport: 0, protocol: 0, tool: 0 },
3142
+ reconnectCount: 0,
3143
+ wakeCount: 0,
3144
+ sleepCount: 0,
3145
+ restartCount: 0,
3146
+ connectionSamples: [],
3147
+ discoverySamples: [],
3148
+ callSamples: [],
3149
+ inFlightCalls: 0,
3150
+ peakInFlightCalls: 0,
3151
+ recentEvents: []
3152
+ };
3153
+ }
3154
+ function healthStateFor(connectionState, operations, enabled = true) {
3155
+ if (!enabled) return "disabled";
3156
+ if (connectionState === "dormant") return "dormant";
3157
+ if (connectionState === "connecting" || connectionState === "reconnecting" || connectionState === "idle") {
3158
+ return "connecting";
3159
+ }
3160
+ if (connectionState === "failed") return "failed";
3161
+ if (connectionState === "disconnected" || operations.consecutiveFailures > 0) return "degraded";
3162
+ return "healthy";
3163
+ }
3164
+ function evaluateHealthThresholds(operations, thresholds) {
3165
+ if (!thresholds) return [];
3166
+ const checks = [];
3167
+ if (thresholds.connectionLatencyP95Ms !== void 0 && operations.connectionSamples.length > 0) {
3168
+ const value = percentile([...operations.connectionSamples].sort((a, b) => a - b), 0.95);
3169
+ checks.push({
3170
+ name: "connection-latency-p95",
3171
+ passed: value <= thresholds.connectionLatencyP95Ms,
3172
+ value,
3173
+ threshold: thresholds.connectionLatencyP95Ms
3174
+ });
3175
+ }
3176
+ if (thresholds.discoveryLatencyP95Ms !== void 0 && operations.discoverySamples.length > 0) {
3177
+ const value = percentile([...operations.discoverySamples].sort((a, b) => a - b), 0.95);
3178
+ checks.push({
3179
+ name: "discovery-latency-p95",
3180
+ passed: value <= thresholds.discoveryLatencyP95Ms,
3181
+ value,
3182
+ threshold: thresholds.discoveryLatencyP95Ms
3183
+ });
3184
+ }
3185
+ if (thresholds.callLatencyP95Ms !== void 0 && operations.callSamples.length > 0) {
3186
+ const value = percentile([...operations.callSamples].sort((a, b) => a - b), 0.95);
3187
+ checks.push({
3188
+ name: "call-latency-p95",
3189
+ passed: value <= thresholds.callLatencyP95Ms,
3190
+ value,
3191
+ threshold: thresholds.callLatencyP95Ms
3192
+ });
3193
+ }
3194
+ if (thresholds.inFlightCalls !== void 0) {
3195
+ checks.push({
3196
+ name: "in-flight-calls",
3197
+ passed: operations.peakInFlightCalls <= thresholds.inFlightCalls,
3198
+ value: operations.peakInFlightCalls,
3199
+ threshold: thresholds.inFlightCalls
3200
+ });
3201
+ }
3202
+ return checks;
3203
+ }
3204
+ function applyHealthThresholds(state, checks) {
3205
+ if (state !== "healthy") return state;
3206
+ return checks.some((c) => !c.passed) ? "degraded" : "healthy";
3207
+ }
3208
+ function summarizeLatency(samples) {
3209
+ if (samples.length === 0) return { count: 0 };
3210
+ const sorted = [...samples].sort((a, b) => a - b);
3211
+ return {
3212
+ count: samples.length,
3213
+ lastMs: samples[samples.length - 1],
3214
+ minMs: sorted[0],
3215
+ maxMs: sorted[sorted.length - 1],
3216
+ p50Ms: percentile(sorted, 0.5),
3217
+ p95Ms: percentile(sorted, 0.95)
3218
+ };
3219
+ }
3220
+ function pushBounded(target, value, limit) {
3221
+ target.push(value);
3222
+ if (target.length > limit) target.splice(0, target.length - limit);
3223
+ }
3224
+ function safeOperationReason(reason) {
3225
+ const normalized = reason.toLowerCase().replace(/[^a-z0-9_.:-]+/g, "-");
3226
+ const bounded = normalized.slice(0, MCP_OPERATION_LIMITS.REASON_CHARS);
3227
+ return SAFE_OPERATION_REASONS.has(bounded) ? bounded : "other";
3228
+ }
3229
+ function percentile(sorted, ratio) {
3230
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1))];
3231
+ }
3232
+
3233
+ // src/registry.ts
3234
+ import { expectDefined } from "@wrongstack/core";
3235
+
3236
+ // src/wrap-tool.ts
3237
+ import { ToolCapabilities } from "@wrongstack/core";
3238
+ var MUTATING_RE = /create|update|delete|write|send|set|put|post|patch|remove|rename|move/i;
3239
+ function isMutatingTool(mcpTool) {
3240
+ if (MUTATING_RE.test(mcpTool.name)) return true;
3241
+ const schema = mcpTool.inputSchema;
3242
+ if (schema && typeof schema === "object") {
3243
+ const props = schema.properties;
3244
+ if (props) {
3245
+ for (const key of Object.keys(props)) {
3246
+ if (MUTATING_RE.test(key)) return true;
3247
+ }
3248
+ }
3249
+ }
3250
+ return false;
3251
+ }
3252
+ function wrapMCPTool(serverName, mcpTool, client, permission = "confirm", observer) {
3253
+ const qualifiedName = `mcp__${serverName}__${mcpTool.name}`;
3254
+ return {
3255
+ name: qualifiedName,
3256
+ description: mcpTool.description ?? `${qualifiedName} (MCP tool)`,
3257
+ usageHint: `Tool provided by MCP server "${serverName}". ${mcpTool.description ?? ""}`,
3258
+ permission,
3259
+ mutating: isMutatingTool(mcpTool),
3260
+ capabilities: [ToolCapabilities.MCP_PROXY],
3261
+ inputSchema: mcpTool.inputSchema ?? { type: "object", properties: {} },
3262
+ async execute(input, _ctx, opts) {
3263
+ const startedAt = Date.now();
3264
+ observer?.onStart();
3265
+ let ok = false;
3266
+ try {
3267
+ const live = typeof client === "function" ? await client() : client;
3268
+ const res = await live.callTool(mcpTool.name, input, { signal: opts.signal });
3269
+ if (res.isError) {
3270
+ throw new Error(stringify(res.content));
3271
+ }
3272
+ ok = true;
3273
+ return stringify(res.content);
3274
+ } finally {
3275
+ observer?.onFinish({ durationMs: Date.now() - startedAt, ok });
3276
+ }
3277
+ }
3278
+ };
3279
+ }
3280
+ function stringify(c) {
3281
+ if (typeof c === "string") return c;
3282
+ if (Array.isArray(c)) {
3283
+ return c.map((item) => {
3284
+ if (item && typeof item === "object") {
3285
+ const t = item.type;
3286
+ if (t === "text") return item.text ?? "";
3287
+ return JSON.stringify(item);
3288
+ }
3289
+ return String(item);
3290
+ }).join("\n");
3291
+ }
3292
+ if (c && typeof c === "object") {
3293
+ if ("text" in c) {
3294
+ return String(c.text);
3295
+ }
3296
+ return JSON.stringify(c);
3297
+ }
3298
+ return String(c ?? "");
3299
+ }
3300
+
3301
+ // src/registry.ts
3302
+ var MCPRegistry = class _MCPRegistry {
3303
+ servers = /* @__PURE__ */ new Map();
3304
+ /** Configured-off servers are tracked without creating a transport/client. */
3305
+ disabledServers = /* @__PURE__ */ new Map();
3306
+ toolRegistry;
3307
+ events;
3308
+ log;
3309
+ lazyMode;
3310
+ cacheDir;
3311
+ idleTimeoutMs;
3312
+ authorizationProviderFactory;
3313
+ authorizationManager;
3314
+ operationListeners = /* @__PURE__ */ new Set();
3315
+ /** Single shared idle sweep timer (started lazily; unref'd; cleared on stopAll). */
3316
+ idleTimer;
3317
+ constructor(opts) {
3318
+ this.toolRegistry = opts.toolRegistry;
3319
+ this.events = opts.events;
3320
+ this.log = opts.log;
3321
+ this.lazyMode = opts.lazyMode ?? false;
3322
+ this.cacheDir = opts.cacheDir;
3323
+ this.idleTimeoutMs = opts.idleTimeoutMs ?? MCP_CONSTANTS.IDLE.DEFAULT_TIMEOUT_MS;
3324
+ this.authorizationProviderFactory = opts.authorizationProviderFactory;
3325
+ this.authorizationManager = opts.authorizationManager;
3326
+ }
3327
+ requireSlot(name) {
3328
+ const slot = this.servers.get(name);
3329
+ if (!slot) throw new Error(`MCP server "${name}" not registered`);
3330
+ return slot;
3331
+ }
3332
+ async beginAuthorization(name, input) {
3333
+ const manager = this.requireAuthorizationManager();
3334
+ const cfg = this.requireHttpServerConfig(name);
3335
+ return manager.begin({
3336
+ serverName: name,
3337
+ resource: cfg.url,
3338
+ ...input
3339
+ });
3340
+ }
3341
+ async completeAuthorization(name, callbackUrl, signal) {
3342
+ const manager = this.requireAuthorizationManager();
3343
+ const cfg = this.requireHttpServerConfig(name);
3344
+ return manager.complete({ serverName: name, resource: cfg.url, callbackUrl, signal });
3345
+ }
3346
+ async authorizationStatus(name) {
3347
+ const manager = this.requireAuthorizationManager();
3348
+ const cfg = this.requireHttpServerConfig(name);
3349
+ return manager.status(name, cfg.url);
3350
+ }
3351
+ async disconnectAuthorization(name) {
3352
+ const manager = this.requireAuthorizationManager();
3353
+ const cfg = this.requireHttpServerConfig(name);
3354
+ return manager.disconnect(name, cfg.url);
3355
+ }
3356
+ requireAuthorizationManager() {
3357
+ if (!this.authorizationManager) {
3358
+ throw new Error("MCP authorization management is not configured for this host");
3359
+ }
3360
+ return this.authorizationManager;
3361
+ }
3362
+ requireHttpServerConfig(name) {
3363
+ const cfg = this.servers.get(name)?.cfg ?? this.disabledServers.get(name);
3364
+ if (!cfg) throw new Error(`MCP server "${name}" not registered`);
3365
+ if (cfg.transport === "stdio" || !cfg.url) {
3366
+ throw new Error(`MCP server "${name}" does not use an HTTP transport`);
3367
+ }
3368
+ return cfg;
3369
+ }
3370
+ async start(cfg) {
3371
+ if (cfg.enabled === false) {
3372
+ if (this.servers.has(cfg.name)) {
3373
+ await this.stop(cfg.name);
3374
+ }
3375
+ this.markDisabled(cfg);
3376
+ return;
3377
+ }
3378
+ this.disabledServers.delete(cfg.name);
3379
+ if (this.servers.has(cfg.name)) {
3380
+ throw new Error(
3381
+ `MCP server "${cfg.name}" is already registered \u2014 use restart() to re-cycle a running server`
3382
+ );
3383
+ }
3384
+ const lazy = !!cfg.lazy && !!this.cacheDir;
1496
3385
  const slot = {
1497
3386
  cfg,
1498
3387
  state: "idle",
@@ -1503,7 +3392,8 @@ var MCPRegistry = class _MCPRegistry {
1503
3392
  reconnectCycles: 0,
1504
3393
  lazy,
1505
3394
  lastUsed: Date.now(),
1506
- registeredLazy: false
3395
+ registeredLazy: false,
3396
+ operations: createMCPServerOperationState()
1507
3397
  };
1508
3398
  this.servers.set(cfg.name, slot);
1509
3399
  if (lazy) {
@@ -1512,6 +3402,16 @@ var MCPRegistry = class _MCPRegistry {
1512
3402
  await this.attemptConnect(slot);
1513
3403
  }
1514
3404
  }
3405
+ /** Record an intentionally disabled configuration without opening a transport. */
3406
+ markDisabled(cfg) {
3407
+ this.servers.delete(cfg.name);
3408
+ this.disabledServers.set(cfg.name, { ...cfg, enabled: false });
3409
+ }
3410
+ /** Remove residual operational/configuration state after a management delete. */
3411
+ forget(name) {
3412
+ this.servers.delete(name);
3413
+ this.disabledServers.delete(name);
3414
+ }
1515
3415
  /**
1516
3416
  * Boot a lazy server WITHOUT spawning it. If a tool manifest is cached (from a
1517
3417
  * prior connect with matching config), register resolver-backed wrappers and
@@ -1525,13 +3425,17 @@ var MCPRegistry = class _MCPRegistry {
1525
3425
  return;
1526
3426
  }
1527
3427
  const hash = manifestConfigHash(slot.cfg);
1528
- const cached = await readManifest(cacheDir, slot.cfg.name, hash);
1529
- if (cached && cached.length > 0) {
1530
- this.applyTools(slot, cached);
3428
+ const cached = await readCapabilityManifest(cacheDir, slot.cfg.name, hash);
3429
+ if (cached) {
3430
+ slot.serverMetadata = cached.serverMetadata;
3431
+ slot.resources = cached.resources;
3432
+ slot.resourceTemplates = cached.resourceTemplates;
3433
+ slot.prompts = cached.prompts;
3434
+ this.applyTools(slot, cached.tools);
1531
3435
  slot.state = "dormant";
1532
3436
  this.ensureIdleSweep();
1533
3437
  this.log.info(
1534
- `MCP server "${slot.cfg.name}" registered lazily from cache (${cached.length} tools, dormant)`
3438
+ `MCP server "${slot.cfg.name}" registered lazily from cache (${cached.tools.length} tools, dormant)`
1535
3439
  );
1536
3440
  return;
1537
3441
  }
@@ -1547,6 +3451,11 @@ var MCPRegistry = class _MCPRegistry {
1547
3451
  slot.lastUsed = Date.now();
1548
3452
  if (slot.client && slot.state === "connected") return slot.client;
1549
3453
  if (slot.connecting) return slot.connecting;
3454
+ const waking = slot.state === "dormant";
3455
+ if (waking) {
3456
+ slot.operations.wakeCount++;
3457
+ this.recordOperation(slot, "wake", "lazy-demand");
3458
+ }
1550
3459
  slot.connecting = (async () => {
1551
3460
  try {
1552
3461
  slot.attempts = 0;
@@ -1627,6 +3536,7 @@ var MCPRegistry = class _MCPRegistry {
1627
3536
  slot.client.removeExitListener(this.onChildExit);
1628
3537
  if (slot.onDisconnect) slot.client.removeDisconnectListener(slot.onDisconnect);
1629
3538
  slot.client.removeToolsChangedListener(this.onToolsChanged);
3539
+ this.removeCatalogListeners(slot.client);
1630
3540
  await slot.client.close();
1631
3541
  slot.client = void 0;
1632
3542
  }
@@ -1635,13 +3545,20 @@ var MCPRegistry = class _MCPRegistry {
1635
3545
  for (const t of slot.toolNames) this.toolRegistry.unregister(t);
1636
3546
  slot.toolNames = [];
1637
3547
  slot.lazyTools = [];
3548
+ slot.serverMetadata = void 0;
3549
+ slot.resources = void 0;
3550
+ slot.resourceTemplates = void 0;
3551
+ slot.prompts = void 0;
1638
3552
  slot.registeredLazy = false;
1639
3553
  slot.state = "disconnected";
3554
+ this.recordOperation(slot, "stop", "manual");
1640
3555
  this.events.emit("mcp.server.disconnected", { name, reason: "stop" });
1641
3556
  }
1642
3557
  async restart(name) {
1643
3558
  const slot = this.servers.get(name);
1644
3559
  if (!slot) throw new Error(`MCP server "${name}" not registered`);
3560
+ slot.operations.restartCount++;
3561
+ this.recordOperation(slot, "restart", "manual");
1645
3562
  await this.stop(name);
1646
3563
  slot.attempts = 0;
1647
3564
  slot.reconnectCycles = 0;
@@ -1658,6 +3575,131 @@ var MCPRegistry = class _MCPRegistry {
1658
3575
  };
1659
3576
  });
1660
3577
  }
3578
+ /**
3579
+ * Subscribe to payload-free operational signals. Callers must still avoid
3580
+ * using `serverName` as an unbounded metric label.
3581
+ */
3582
+ onOperation(listener) {
3583
+ this.operationListeners.add(listener);
3584
+ return () => this.operationListeners.delete(listener);
3585
+ }
3586
+ /** Detailed, defensively-copied operational snapshots for CLI/WebUI/HQ. */
3587
+ operationalHealth() {
3588
+ const active = Array.from(this.servers.values()).map((slot) => {
3589
+ const op = slot.operations;
3590
+ const baseHealth = healthStateFor(slot.state, op, slot.cfg.enabled !== false);
3591
+ const checks = evaluateHealthThresholds(op, slot.cfg.health?.thresholds);
3592
+ return {
3593
+ name: slot.cfg.name,
3594
+ connectionState: slot.state,
3595
+ healthState: applyHealthThresholds(baseHealth, checks),
3596
+ lastSuccessAt: op.lastSuccessAt,
3597
+ lastFailureAt: op.lastFailureAt,
3598
+ lastFailureKind: op.lastFailureKind,
3599
+ lastReason: op.lastReason,
3600
+ consecutiveFailures: op.consecutiveFailures,
3601
+ failures: { ...op.failures },
3602
+ reconnectCount: op.reconnectCount,
3603
+ wakeCount: op.wakeCount,
3604
+ sleepCount: op.sleepCount,
3605
+ restartCount: op.restartCount,
3606
+ connectionLatency: summarizeLatency(op.connectionSamples),
3607
+ discoveryLatency: summarizeLatency(op.discoverySamples),
3608
+ callLatency: summarizeLatency(op.callSamples),
3609
+ inFlightCalls: op.inFlightCalls,
3610
+ peakInFlightCalls: op.peakInFlightCalls,
3611
+ recentEvents: op.recentEvents.map((event) => ({ ...event })),
3612
+ healthChecks: checks
3613
+ };
3614
+ });
3615
+ const disabled = Array.from(this.disabledServers.values()).map((cfg) => {
3616
+ const operations = createMCPServerOperationState();
3617
+ return {
3618
+ name: cfg.name,
3619
+ connectionState: "idle",
3620
+ healthState: "disabled",
3621
+ consecutiveFailures: 0,
3622
+ failures: { ...operations.failures },
3623
+ reconnectCount: 0,
3624
+ wakeCount: 0,
3625
+ sleepCount: 0,
3626
+ restartCount: 0,
3627
+ connectionLatency: summarizeLatency([]),
3628
+ discoveryLatency: summarizeLatency([]),
3629
+ callLatency: summarizeLatency([]),
3630
+ inFlightCalls: 0,
3631
+ peakInFlightCalls: 0,
3632
+ recentEvents: [],
3633
+ healthChecks: []
3634
+ };
3635
+ });
3636
+ return [...active, ...disabled];
3637
+ }
3638
+ getCatalog(name) {
3639
+ const slot = this.servers.get(name);
3640
+ if (!slot) return void 0;
3641
+ return catalogSnapshot(slot);
3642
+ }
3643
+ async listResources(name, opts = {}) {
3644
+ const slot = this.requireSlot(name);
3645
+ if (!opts.refresh && slot.resources) return cloneRecords(slot.resources);
3646
+ const client = await this.ensureConnected(name);
3647
+ if (!client.getServerMetadata()?.capabilities.resources) return [];
3648
+ slot.resources = await collectPages(
3649
+ (cursor) => client.listResources(cursor ? { cursor } : {}),
3650
+ (page) => page.resources
3651
+ );
3652
+ await this.persistCapabilityManifest(slot);
3653
+ return cloneRecords(slot.resources);
3654
+ }
3655
+ async listResourceTemplates(name, opts = {}) {
3656
+ const slot = this.requireSlot(name);
3657
+ if (!opts.refresh && slot.resourceTemplates) return cloneRecords(slot.resourceTemplates);
3658
+ const client = await this.ensureConnected(name);
3659
+ if (!client.getServerMetadata()?.capabilities.resources) return [];
3660
+ slot.resourceTemplates = await collectPages(
3661
+ (cursor) => client.listResourceTemplates(cursor ? { cursor } : {}),
3662
+ (page) => page.resourceTemplates
3663
+ );
3664
+ await this.persistCapabilityManifest(slot);
3665
+ return cloneRecords(slot.resourceTemplates);
3666
+ }
3667
+ async readResource(name, uri) {
3668
+ return (await this.ensureConnected(name)).readResource(uri);
3669
+ }
3670
+ async selectResourceForInsertion(name, uri, policy) {
3671
+ return prepareResourceInsertion(name, uri, await this.readResource(name, uri), policy);
3672
+ }
3673
+ async subscribeResource(name, uri) {
3674
+ await (await this.ensureConnected(name)).subscribeResource(uri);
3675
+ }
3676
+ async unsubscribeResource(name, uri) {
3677
+ await (await this.ensureConnected(name)).unsubscribeResource(uri);
3678
+ }
3679
+ async listPrompts(name, opts = {}) {
3680
+ const slot = this.requireSlot(name);
3681
+ if (!opts.refresh && slot.prompts) return cloneRecords(slot.prompts);
3682
+ const client = await this.ensureConnected(name);
3683
+ if (!client.getServerMetadata()?.capabilities.prompts) return [];
3684
+ slot.prompts = await collectPages(
3685
+ (cursor) => client.listPrompts(cursor ? { cursor } : {}),
3686
+ (page) => page.prompts
3687
+ );
3688
+ await this.persistCapabilityManifest(slot);
3689
+ return cloneRecords(slot.prompts);
3690
+ }
3691
+ async getPrompt(serverName, promptName, args) {
3692
+ return (await this.ensureConnected(serverName)).getPrompt(promptName, args);
3693
+ }
3694
+ async selectPromptForInsertion(serverName, promptName, args, policy) {
3695
+ return preparePromptInsertion(
3696
+ serverName,
3697
+ promptName,
3698
+ args,
3699
+ await this.getPrompt(serverName, promptName, args),
3700
+ policy
3701
+ );
3702
+ }
1661
3703
  /**
1662
3704
  * Resolve the live tool names for a slot — the registered names in normal
1663
3705
  * mode, or the cached lazy-tool names when running in lazy mode (where
@@ -1679,7 +3721,30 @@ var MCPRegistry = class _MCPRegistry {
1679
3721
  const filtered = tools.filter((t) => !allowed || allowed.includes(t.name));
1680
3722
  const clientArg = slot.lazy ? () => this.ensureConnected(slot.cfg.name) : expectDefined(client);
1681
3723
  const wrapped = filtered.map(
1682
- (t) => wrapMCPTool(slot.cfg.name, t, clientArg, slot.cfg.permission ?? "confirm")
3724
+ (t) => wrapMCPTool(slot.cfg.name, t, clientArg, slot.cfg.permission ?? "confirm", {
3725
+ onStart: () => {
3726
+ slot.operations.inFlightCalls++;
3727
+ slot.operations.peakInFlightCalls = Math.max(
3728
+ slot.operations.peakInFlightCalls,
3729
+ slot.operations.inFlightCalls
3730
+ );
3731
+ this.recordOperation(slot, "call", "started", void 0, void 0, false);
3732
+ },
3733
+ onFinish: ({ durationMs, ok }) => {
3734
+ slot.operations.inFlightCalls = Math.max(0, slot.operations.inFlightCalls - 1);
3735
+ pushBounded(
3736
+ slot.operations.callSamples,
3737
+ durationMs,
3738
+ MCP_OPERATION_LIMITS.LATENCY_SAMPLES
3739
+ );
3740
+ if (ok) {
3741
+ this.recordSuccess(slot);
3742
+ this.recordOperation(slot, "call", "ok", void 0, durationMs, false);
3743
+ } else {
3744
+ this.recordFailure(slot, "tool", "tool-call-failed", durationMs);
3745
+ }
3746
+ }
3747
+ })
1683
3748
  );
1684
3749
  if (this.lazyMode) {
1685
3750
  slot.lazyTools = wrapped;
@@ -1693,7 +3758,71 @@ var MCPRegistry = class _MCPRegistry {
1693
3758
  this.log.warn(`MCP tool "${tool.name}" not registered`, err);
1694
3759
  }
1695
3760
  }
1696
- if (slot.lazy) slot.registeredLazy = true;
3761
+ if (slot.lazy && wrapped.length > 0) slot.registeredLazy = true;
3762
+ }
3763
+ async discoverCapabilities(slot, client) {
3764
+ const startedAt = Date.now();
3765
+ slot.serverMetadata = client.getServerMetadata();
3766
+ const capabilities = slot.serverMetadata?.capabilities;
3767
+ if (capabilities?.resources) {
3768
+ try {
3769
+ slot.resources = await collectPages(
3770
+ (cursor) => client.listResources(cursor ? { cursor } : {}),
3771
+ (page) => page.resources
3772
+ );
3773
+ } catch (err) {
3774
+ slot.resources = void 0;
3775
+ this.recordFailure(slot, "protocol", "resource-discovery-failed");
3776
+ this.log.warn(`MCP server "${slot.cfg.name}" resource discovery failed`, err);
3777
+ }
3778
+ try {
3779
+ slot.resourceTemplates = await collectPages(
3780
+ (cursor) => client.listResourceTemplates(cursor ? { cursor } : {}),
3781
+ (page) => page.resourceTemplates
3782
+ );
3783
+ } catch (err) {
3784
+ slot.resourceTemplates = void 0;
3785
+ this.recordFailure(slot, "protocol", "resource-template-discovery-failed");
3786
+ this.log.warn(`MCP server "${slot.cfg.name}" resource template discovery failed`, err);
3787
+ }
3788
+ } else {
3789
+ slot.resources = void 0;
3790
+ slot.resourceTemplates = void 0;
3791
+ }
3792
+ if (capabilities?.prompts) {
3793
+ try {
3794
+ slot.prompts = await collectPages(
3795
+ (cursor) => client.listPrompts(cursor ? { cursor } : {}),
3796
+ (page) => page.prompts
3797
+ );
3798
+ } catch (err) {
3799
+ slot.prompts = void 0;
3800
+ this.recordFailure(slot, "protocol", "prompt-discovery-failed");
3801
+ this.log.warn(`MCP server "${slot.cfg.name}" prompt discovery failed`, err);
3802
+ }
3803
+ } else {
3804
+ slot.prompts = void 0;
3805
+ }
3806
+ const durationMs = Date.now() - startedAt;
3807
+ pushBounded(slot.operations.discoverySamples, durationMs, MCP_OPERATION_LIMITS.LATENCY_SAMPLES);
3808
+ this.recordOperation(slot, "discover", "complete", void 0, durationMs, false);
3809
+ }
3810
+ async persistCapabilityManifest(slot) {
3811
+ if (!slot.lazy || !this.cacheDir) return;
3812
+ const cacheDir = this.cacheDir;
3813
+ const previous = slot.manifestWrite ?? Promise.resolve();
3814
+ const pending = previous.then(
3815
+ () => writeCapabilityManifest(cacheDir, slot.cfg.name, manifestConfigHash(slot.cfg), {
3816
+ tools: slot.client?.listTools() ?? [],
3817
+ serverMetadata: slot.serverMetadata,
3818
+ resources: slot.resources,
3819
+ resourceTemplates: slot.resourceTemplates,
3820
+ prompts: slot.prompts
3821
+ })
3822
+ );
3823
+ slot.manifestWrite = pending;
3824
+ await pending;
3825
+ if (slot.manifestWrite === pending) slot.manifestWrite = void 0;
1697
3826
  }
1698
3827
  /** Start the shared idle sweep timer once (unref'd so it never holds the process). */
1699
3828
  ensureIdleSweep() {
@@ -1728,11 +3857,14 @@ var MCPRegistry = class _MCPRegistry {
1728
3857
  slot.client.removeExitListener(this.onChildExit);
1729
3858
  if (slot.onDisconnect) slot.client.removeDisconnectListener(slot.onDisconnect);
1730
3859
  slot.client.removeToolsChangedListener(this.onToolsChanged);
3860
+ this.removeCatalogListeners(slot.client);
1731
3861
  await slot.client.close();
1732
3862
  slot.client = void 0;
1733
3863
  }
1734
3864
  slot.onDisconnect = void 0;
1735
3865
  slot.state = "dormant";
3866
+ slot.operations.sleepCount++;
3867
+ this.recordOperation(slot, "sleep", "idle-timeout");
1736
3868
  this.log.info(`MCP server "${slot.cfg.name}" idle \u2014 sleeping (tools stay registered)`);
1737
3869
  this.events.emit("mcp.server.disconnected", { name: slot.cfg.name, reason: "idle-sleep" });
1738
3870
  }
@@ -1743,7 +3875,7 @@ var MCPRegistry = class _MCPRegistry {
1743
3875
  * triggering connections.
1744
3876
  */
1745
3877
  describe() {
1746
- return Array.from(this.servers.values()).map((s) => {
3878
+ const active = Array.from(this.servers.values()).map((s) => {
1747
3879
  const tools = this.toolNamesForSlot(s);
1748
3880
  return {
1749
3881
  name: s.cfg.name,
@@ -1753,6 +3885,14 @@ var MCPRegistry = class _MCPRegistry {
1753
3885
  tools
1754
3886
  };
1755
3887
  });
3888
+ const disabled = Array.from(this.disabledServers.values()).map((cfg) => ({
3889
+ name: cfg.name,
3890
+ state: "idle",
3891
+ toolCount: 0,
3892
+ enabled: false,
3893
+ tools: []
3894
+ }));
3895
+ return [...active, ...disabled];
1756
3896
  }
1757
3897
  async stopAll() {
1758
3898
  if (this.idleTimer) {
@@ -1762,6 +3902,7 @@ var MCPRegistry = class _MCPRegistry {
1762
3902
  for (const name of Array.from(this.servers.keys())) {
1763
3903
  await this.stop(name);
1764
3904
  }
3905
+ this.disabledServers.clear();
1765
3906
  }
1766
3907
  /**
1767
3908
  * Health check — returns 'ok' for connected servers, the current state otherwise.
@@ -1792,10 +3933,8 @@ var MCPRegistry = class _MCPRegistry {
1792
3933
  slot.toolNames = [];
1793
3934
  slot.registeredLazy = false;
1794
3935
  const discovered = slot.client.listTools();
1795
- if (slot.lazy && this.cacheDir) {
1796
- void writeManifest(this.cacheDir, slot.cfg.name, manifestConfigHash(slot.cfg), discovered);
1797
- }
1798
3936
  this.applyTools(slot, discovered, slot.client);
3937
+ void this.persistCapabilityManifest(slot);
1799
3938
  this.events.emit("mcp.server.connected", {
1800
3939
  name: slot.cfg.name,
1801
3940
  toolCount: slot.toolNames.length
@@ -1804,12 +3943,36 @@ var MCPRegistry = class _MCPRegistry {
1804
3943
  `MCP server "${slot.cfg.name}" tools refreshed (${this.toolNamesForSlot(slot).length} active)`
1805
3944
  );
1806
3945
  };
3946
+ onResourcesChanged = (name) => {
3947
+ const slot = this.servers.get(name);
3948
+ if (!slot) return;
3949
+ slot.resources = void 0;
3950
+ slot.resourceTemplates = void 0;
3951
+ void this.persistCapabilityManifest(slot);
3952
+ this.log.info(`MCP server "${name}" resource catalog invalidated`);
3953
+ };
3954
+ onPromptsChanged = (name) => {
3955
+ const slot = this.servers.get(name);
3956
+ if (!slot) return;
3957
+ slot.prompts = void 0;
3958
+ void this.persistCapabilityManifest(slot);
3959
+ this.log.info(`MCP server "${name}" prompt catalog invalidated`);
3960
+ };
3961
+ addCatalogListeners(client) {
3962
+ client.addResourcesChangedListener(this.onResourcesChanged);
3963
+ client.addPromptsChangedListener(this.onPromptsChanged);
3964
+ }
3965
+ removeCatalogListeners(client) {
3966
+ client.removeResourcesChangedListener(this.onResourcesChanged);
3967
+ client.removePromptsChangedListener(this.onPromptsChanged);
3968
+ }
1807
3969
  onChildExit = (name, code, _signal) => {
1808
3970
  const slot = this.servers.get(name);
1809
3971
  if (!slot) return;
1810
3972
  if (slot.lazy) {
1811
3973
  slot.client = void 0;
1812
3974
  slot.state = "dormant";
3975
+ this.recordFailure(slot, "transport", "process-exit-lazy");
1813
3976
  this.events.emit("mcp.server.disconnected", {
1814
3977
  name,
1815
3978
  reason: `exit:${code ?? "unknown"} (dormant)`
@@ -1824,7 +3987,12 @@ var MCPRegistry = class _MCPRegistry {
1824
3987
  }
1825
3988
  slot.toolNames = [];
1826
3989
  slot.lazyTools = [];
3990
+ slot.serverMetadata = void 0;
3991
+ slot.resources = void 0;
3992
+ slot.resourceTemplates = void 0;
3993
+ slot.prompts = void 0;
1827
3994
  slot.state = "disconnected";
3995
+ this.recordFailure(slot, "transport", "process-exit");
1828
3996
  this.events.emit("mcp.server.disconnected", { name, reason: `exit:${code ?? "unknown"}` });
1829
3997
  this.scheduleReconnect(slot);
1830
3998
  };
@@ -1835,6 +4003,7 @@ var MCPRegistry = class _MCPRegistry {
1835
4003
  if (slot.lazy) {
1836
4004
  slot.client = void 0;
1837
4005
  slot.state = "dormant";
4006
+ this.recordFailure(slot, "transport", "http-disconnect-lazy");
1838
4007
  this.events.emit("mcp.server.disconnected", { name, reason: "http-disconnect (dormant)" });
1839
4008
  return;
1840
4009
  }
@@ -1846,7 +4015,12 @@ var MCPRegistry = class _MCPRegistry {
1846
4015
  }
1847
4016
  slot.toolNames = [];
1848
4017
  slot.lazyTools = [];
4018
+ slot.serverMetadata = void 0;
4019
+ slot.resources = void 0;
4020
+ slot.resourceTemplates = void 0;
4021
+ slot.prompts = void 0;
1849
4022
  slot.state = "disconnected";
4023
+ this.recordFailure(slot, "transport", "http-disconnect");
1850
4024
  this.events.emit("mcp.server.disconnected", { name, reason: "http-disconnect" });
1851
4025
  this.scheduleReconnect(slot);
1852
4026
  };
@@ -1865,6 +4039,7 @@ var MCPRegistry = class _MCPRegistry {
1865
4039
  if (slot.reconnectPending) return;
1866
4040
  if (slot.reconnectCycles >= _MCPRegistry.MAX_RECONNECT_CYCLES) {
1867
4041
  slot.state = "failed";
4042
+ this.recordFailure(slot, "transport", "reconnect-exhausted");
1868
4043
  this.log.error(
1869
4044
  `MCP server "${slot.cfg.name}" giving up after ${slot.reconnectCycles} reconnect cycles. Use \`/mcp restart ${slot.cfg.name}\` to retry.`
1870
4045
  );
@@ -1893,345 +4068,196 @@ var MCPRegistry = class _MCPRegistry {
1893
4068
  async attemptReconnect(slot) {
1894
4069
  slot.reconnectPending = false;
1895
4070
  slot.reconnectCycles++;
4071
+ slot.operations.reconnectCount++;
4072
+ this.recordOperation(slot, "reconnect", "automatic");
1896
4073
  await this.attemptConnect(slot);
1897
4074
  }
1898
- async attemptConnect(slot) {
1899
- const MAX_ATTEMPTS = MCP_CONSTANTS.RECONNECT.MAX_ATTEMPTS;
1900
- let attempt = 0;
1901
- while (attempt < MAX_ATTEMPTS) {
1902
- attempt++;
1903
- slot.state = attempt === 1 ? "connecting" : "reconnecting";
1904
- slot.attempts = attempt;
1905
- let client;
1906
- let boundDisconnect;
1907
- try {
1908
- client = new MCPClient({
1909
- name: slot.cfg.name,
1910
- transport: slot.cfg.transport,
1911
- command: slot.cfg.command,
1912
- args: slot.cfg.args,
1913
- env: slot.cfg.env,
1914
- url: slot.cfg.url,
1915
- headers: slot.cfg.headers,
1916
- startupTimeoutMs: slot.cfg.startupTimeoutMs,
1917
- requestTimeoutMs: slot.cfg.requestTimeoutMs,
1918
- passthroughEnv: slot.cfg.passthroughEnv
1919
- });
1920
- if (slot.cfg.transport === "stdio") {
1921
- client.addExitListener(this.onChildExit);
1922
- } else {
1923
- boundDisconnect = () => this.onTransportDisconnect(slot.cfg.name);
1924
- client.addDisconnectListener(boundDisconnect);
1925
- }
1926
- client.addToolsChangedListener(this.onToolsChanged);
1927
- await client.connect();
1928
- if (slot.client && slot.client !== client) {
1929
- const prior = slot.client;
1930
- const priorDisconnect = slot.onDisconnect;
1931
- slot.client.removeExitListener(this.onChildExit);
1932
- if (priorDisconnect) prior.removeDisconnectListener(priorDisconnect);
1933
- prior.removeToolsChangedListener(this.onToolsChanged);
1934
- prior.close().catch(() => {
1935
- });
1936
- }
1937
- slot.client = client;
1938
- slot.onDisconnect = boundDisconnect;
1939
- const isReconnect = attempt > 1;
1940
- slot.state = "connected";
1941
- slot.reconnectCycles = 0;
1942
- const mc = client;
1943
- const discovered = mc.listTools();
1944
- if (slot.lazy && this.cacheDir) {
1945
- await writeManifest(
1946
- this.cacheDir,
1947
- slot.cfg.name,
1948
- manifestConfigHash(slot.cfg),
1949
- discovered
1950
- );
1951
- }
1952
- this.applyTools(slot, discovered, mc);
1953
- slot.lastUsed = Date.now();
1954
- if (slot.lazy) this.ensureIdleSweep();
1955
- this.events.emit(isReconnect ? "mcp.server.reconnected" : "mcp.server.connected", {
1956
- name: slot.cfg.name,
1957
- toolCount: slot.toolNames.length
1958
- });
1959
- return;
1960
- } catch (err) {
1961
- this.log.warn(`MCP server "${slot.cfg.name}" connect attempt ${attempt} failed`, err);
1962
- if (client) {
1963
- client.removeExitListener(this.onChildExit);
1964
- if (boundDisconnect) client.removeDisconnectListener(boundDisconnect);
1965
- client.removeToolsChangedListener(this.onToolsChanged);
1966
- await client.close().catch(() => {
1967
- });
1968
- }
1969
- if (attempt >= MAX_ATTEMPTS) {
1970
- this.log.error(
1971
- `MCP server "${slot.cfg.name}" connect exhausted after ${MAX_ATTEMPTS} attempts`,
1972
- err
1973
- );
1974
- slot.state = "failed";
1975
- slot.client = void 0;
1976
- if (slot.reconnectTimer) {
1977
- clearTimeout(slot.reconnectTimer);
1978
- slot.reconnectTimer = void 0;
1979
- }
1980
- slot.reconnectPending = false;
1981
- this.events.emit("mcp.server.disconnected", {
1982
- name: slot.cfg.name,
1983
- reason: err instanceof Error ? err.message : "unknown"
1984
- });
1985
- return;
1986
- }
1987
- const delay = 500 * 2 ** attempt;
1988
- await new Promise((r) => setTimeout(r, delay));
1989
- }
1990
- }
1991
- }
1992
- };
1993
-
1994
- // src/manage.ts
1995
- import { randomBytes as randomBytes2 } from "node:crypto";
1996
- import * as fs2 from "node:fs/promises";
1997
- async function readConfig(path2) {
1998
- try {
1999
- return JSON.parse(await fs2.readFile(path2, "utf8"));
2000
- } catch {
2001
- return {};
2002
- }
2003
- }
2004
- async function writeConfig(path2, cfg) {
2005
- const raw = JSON.stringify(cfg, null, 2);
2006
- const tmp = `${path2}.${process.pid}.${randomBytes2(6).toString("hex")}.tmp`;
2007
- await fs2.writeFile(tmp, raw, "utf8");
2008
- try {
2009
- await fs2.rename(tmp, path2);
2010
- } catch (err) {
2011
- await fs2.rm(tmp, { force: true }).catch(() => void 0);
2012
- throw err;
4075
+ recordSuccess(slot, resetFailures = true) {
4076
+ const operations = this.operationsFor(slot);
4077
+ operations.lastSuccessAt = Date.now();
4078
+ if (resetFailures) operations.consecutiveFailures = 0;
2013
4079
  }
2014
- }
2015
- function isMcpServerRecord(value) {
2016
- return !!value && typeof value === "object" && !Array.isArray(value);
2017
- }
2018
- async function readServers(configPath) {
2019
- const full = await readConfig(configPath);
2020
- const servers = isMcpServerRecord(full.mcpServers) ? { ...full.mcpServers } : {};
2021
- return { full, servers };
2022
- }
2023
- async function persist(configPath, full, servers) {
2024
- full.mcpServers = servers;
2025
- await writeConfig(configPath, full);
2026
- }
2027
- function normalizeTransport(t) {
2028
- if (t === "sse") return "sse";
2029
- if (t === "http" || t === "streamable-http") return "streamable-http";
2030
- return "stdio";
2031
- }
2032
- function buildConfig(input, base) {
2033
- const cfg = {
2034
- name: input.name,
2035
- transport: input.transport ? normalizeTransport(String(input.transport)) : base?.transport ?? "stdio"
2036
- };
2037
- const description = input.description ?? base?.description;
2038
- if (description !== void 0) cfg.description = description;
2039
- const command = input.command ?? base?.command;
2040
- if (command !== void 0) cfg.command = command;
2041
- const args = input.args ?? base?.args;
2042
- if (args !== void 0) cfg.args = args;
2043
- const env = input.env ?? base?.env;
2044
- if (env !== void 0) cfg.env = env;
2045
- const url = input.url ?? base?.url;
2046
- if (url !== void 0) cfg.url = url;
2047
- const headers = input.headers ?? base?.headers;
2048
- if (headers !== void 0) cfg.headers = headers;
2049
- const allowedTools = input.allowedTools ?? base?.allowedTools;
2050
- if (allowedTools !== void 0) cfg.allowedTools = allowedTools;
2051
- const permission = input.permission ?? base?.permission;
2052
- if (permission !== void 0) cfg.permission = permission;
2053
- const enabled = input.enabled ?? base?.enabled;
2054
- if (enabled !== void 0) cfg.enabled = enabled;
2055
- const lazy = input.lazy ?? base?.lazy;
2056
- if (lazy !== void 0) cfg.lazy = lazy;
2057
- const passthroughEnv = input.passthroughEnv ?? base?.passthroughEnv;
2058
- if (passthroughEnv !== void 0) cfg.passthroughEnv = passthroughEnv;
2059
- return cfg;
2060
- }
2061
- function projectServer(name, cfg, registry) {
2062
- const live = registry.list().find((s) => s.name === name);
2063
- const info = {
2064
- name,
2065
- transport: cfg.transport,
2066
- enabled: cfg.enabled !== false,
2067
- status: live ? live.state : "stopped",
2068
- tools: live?.tools ?? []
2069
- };
2070
- if (cfg.description !== void 0) info.description = cfg.description;
2071
- if (cfg.url !== void 0) info.url = cfg.url;
2072
- if (cfg.command !== void 0) info.command = cfg.command;
2073
- if (cfg.lazy !== void 0) info.lazy = cfg.lazy;
2074
- return info;
2075
- }
2076
- function liveState(name, registry) {
2077
- const live = registry.list().find((s) => s.name === name);
2078
- return { state: live?.state ?? "stopped", tools: live?.tools ?? [] };
2079
- }
2080
- function errMessage(err) {
2081
- return err instanceof Error ? err.message : String(err);
2082
- }
2083
- async function listMcp(deps) {
2084
- const { servers } = await readServers(deps.configPath);
2085
- return Object.entries(servers).map(
2086
- ([name, cfg]) => projectServer(name, { ...cfg, name }, deps.registry)
2087
- );
2088
- }
2089
- async function addMcp(input, deps) {
2090
- if (!input.name) return { ok: false, message: "Server name is required" };
2091
- const { full, servers } = await readServers(deps.configPath);
2092
- if (servers[input.name]) {
2093
- return { ok: false, message: `Server "${input.name}" already exists` };
4080
+ recordFailure(slot, failureKind, reason, durationMs) {
4081
+ const operations = this.operationsFor(slot);
4082
+ const safeReason = safeOperationReason(reason);
4083
+ operations.lastFailureAt = Date.now();
4084
+ operations.lastFailureKind = failureKind;
4085
+ operations.lastReason = safeReason;
4086
+ operations.consecutiveFailures++;
4087
+ operations.failures[failureKind]++;
4088
+ this.recordOperation(slot, "failure", safeReason, failureKind, durationMs);
2094
4089
  }
2095
- const preset = deps.presets?.[input.name];
2096
- const hasExplicitConfig = !!(input.transport || input.command || input.url);
2097
- const cfg = hasExplicitConfig ? buildConfig(input, preset) : preset ? buildConfig({ ...input, name: input.name }, preset) : buildConfig(input);
2098
- if (!hasExplicitConfig && !preset) {
2099
- const known = Object.keys(deps.presets ?? {}).join(", ");
2100
- return {
2101
- ok: false,
2102
- message: known ? `Unknown server "${input.name}". Available presets: ${known}` : `No configuration provided for "${input.name}"`
4090
+ recordOperation(slot, kind, reason, failureKind, durationMs, retain = true) {
4091
+ const operations = this.operationsFor(slot);
4092
+ const baseHealth = healthStateFor(slot.state, operations, slot.cfg.enabled !== false);
4093
+ const checks = evaluateHealthThresholds(operations, slot.cfg.health?.thresholds);
4094
+ const event = {
4095
+ serverName: slot.cfg.name,
4096
+ kind,
4097
+ at: Date.now(),
4098
+ connectionState: slot.state,
4099
+ healthState: applyHealthThresholds(baseHealth, checks)
2103
4100
  };
4101
+ if (reason !== void 0) event.reason = safeOperationReason(reason);
4102
+ if (failureKind !== void 0) event.failureKind = failureKind;
4103
+ if (durationMs !== void 0) event.durationMs = Math.max(0, Math.round(durationMs));
4104
+ if (retain) {
4105
+ pushBounded(operations.recentEvents, event, MCP_OPERATION_LIMITS.RECENT_EVENTS);
4106
+ }
4107
+ for (const listener of this.operationListeners) {
4108
+ try {
4109
+ listener({ ...event });
4110
+ } catch {
4111
+ }
4112
+ }
2104
4113
  }
2105
- cfg.enabled = input.enabled ?? false;
2106
- servers[input.name] = cfg;
2107
- await persist(deps.configPath, full, servers);
2108
- if (cfg.enabled) {
2109
- return startServer(input.name, cfg, deps, `Server "${input.name}" added`);
2110
- }
2111
- return {
2112
- ok: true,
2113
- message: `Server "${input.name}" added (disabled)`,
2114
- server: projectServer(input.name, cfg, deps.registry)
2115
- };
2116
- }
2117
- async function updateMcp(input, deps) {
2118
- if (!input.name) return { ok: false, message: "Server name is required" };
2119
- const { full, servers } = await readServers(deps.configPath);
2120
- const existing = servers[input.name];
2121
- if (!existing) return { ok: false, message: `Server "${input.name}" not found` };
2122
- const cfg = buildConfig(input, { ...existing, name: input.name });
2123
- servers[input.name] = cfg;
2124
- await persist(deps.configPath, full, servers);
2125
- if (cfg.enabled !== false) {
2126
- return startServer(input.name, cfg, deps, `Server "${input.name}" updated`, { restart: true });
4114
+ /** Keeps private-method unit fixtures from needing to duplicate every slot field. */
4115
+ operationsFor(slot) {
4116
+ if (!slot.operations) slot.operations = createMCPServerOperationState();
4117
+ return slot.operations;
2127
4118
  }
2128
- await safeStop(input.name, deps);
2129
- return {
2130
- ok: true,
2131
- message: `Server "${input.name}" updated`,
2132
- server: projectServer(input.name, cfg, deps.registry)
2133
- };
2134
- }
2135
- async function removeMcp(name, deps) {
2136
- if (!name) return { ok: false, message: "Server name is required" };
2137
- const { full, servers } = await readServers(deps.configPath);
2138
- if (!servers[name]) return { ok: false, message: `Server "${name}" not found` };
2139
- await safeStop(name, deps);
2140
- delete servers[name];
2141
- await persist(deps.configPath, full, servers);
2142
- return { ok: true, message: `Server "${name}" removed` };
2143
- }
2144
- async function enableMcp(name, deps) {
2145
- if (!name) return { ok: false, message: "Server name is required" };
2146
- const { full, servers } = await readServers(deps.configPath);
2147
- const cfg = servers[name];
2148
- if (!cfg) {
2149
- return { ok: false, message: `Server "${name}" is not in config. Add it first.` };
4119
+ async attemptConnect(slot) {
4120
+ const MAX_ATTEMPTS = MCP_CONSTANTS.RECONNECT.MAX_ATTEMPTS;
4121
+ let attempt = 0;
4122
+ while (attempt < MAX_ATTEMPTS) {
4123
+ attempt++;
4124
+ const startedAt = Date.now();
4125
+ slot.state = attempt === 1 ? "connecting" : "reconnecting";
4126
+ slot.attempts = attempt;
4127
+ let client;
4128
+ let boundDisconnect;
4129
+ try {
4130
+ client = new MCPClient({
4131
+ name: slot.cfg.name,
4132
+ transport: slot.cfg.transport,
4133
+ command: slot.cfg.command,
4134
+ args: slot.cfg.args,
4135
+ env: slot.cfg.env,
4136
+ url: slot.cfg.url,
4137
+ headers: slot.cfg.headers,
4138
+ startupTimeoutMs: slot.cfg.startupTimeoutMs,
4139
+ requestTimeoutMs: slot.cfg.requestTimeoutMs,
4140
+ passthroughEnv: slot.cfg.passthroughEnv,
4141
+ authorizationProvider: this.authorizationProviderFactory?.(slot.cfg)
4142
+ });
4143
+ if (slot.cfg.transport === "stdio") {
4144
+ client.addExitListener(this.onChildExit);
4145
+ } else {
4146
+ boundDisconnect = () => this.onTransportDisconnect(slot.cfg.name);
4147
+ client.addDisconnectListener(boundDisconnect);
4148
+ }
4149
+ client.addToolsChangedListener(this.onToolsChanged);
4150
+ this.addCatalogListeners(client);
4151
+ await client.connect();
4152
+ if (slot.client && slot.client !== client) {
4153
+ const prior = slot.client;
4154
+ const priorDisconnect = slot.onDisconnect;
4155
+ slot.client.removeExitListener(this.onChildExit);
4156
+ if (priorDisconnect) prior.removeDisconnectListener(priorDisconnect);
4157
+ prior.removeToolsChangedListener(this.onToolsChanged);
4158
+ this.removeCatalogListeners(prior);
4159
+ prior.close().catch(() => {
4160
+ });
4161
+ }
4162
+ slot.client = client;
4163
+ slot.onDisconnect = boundDisconnect;
4164
+ const isReconnect = slot.reconnectCycles > 0 || attempt > 1;
4165
+ slot.state = "connected";
4166
+ slot.reconnectCycles = 0;
4167
+ const mc = client;
4168
+ const discovered = mc.listTools();
4169
+ await this.discoverCapabilities(slot, mc);
4170
+ await this.persistCapabilityManifest(slot);
4171
+ this.applyTools(slot, discovered, mc);
4172
+ const durationMs = Date.now() - startedAt;
4173
+ pushBounded(
4174
+ slot.operations.connectionSamples,
4175
+ durationMs,
4176
+ MCP_OPERATION_LIMITS.LATENCY_SAMPLES
4177
+ );
4178
+ this.recordSuccess(slot, (slot.operations.lastFailureAt ?? 0) < startedAt);
4179
+ this.recordOperation(
4180
+ slot,
4181
+ isReconnect ? "reconnect" : "connect",
4182
+ "connected",
4183
+ void 0,
4184
+ durationMs
4185
+ );
4186
+ slot.lastUsed = Date.now();
4187
+ if (slot.lazy) this.ensureIdleSweep();
4188
+ this.events.emit(isReconnect ? "mcp.server.reconnected" : "mcp.server.connected", {
4189
+ name: slot.cfg.name,
4190
+ toolCount: slot.toolNames.length
4191
+ });
4192
+ return;
4193
+ } catch (err) {
4194
+ this.recordFailure(slot, "transport", "connect-attempt-failed", Date.now() - startedAt);
4195
+ this.log.warn(`MCP server "${slot.cfg.name}" connect attempt ${attempt} failed`, err);
4196
+ if (client) {
4197
+ client.removeExitListener(this.onChildExit);
4198
+ if (boundDisconnect) client.removeDisconnectListener(boundDisconnect);
4199
+ client.removeToolsChangedListener(this.onToolsChanged);
4200
+ this.removeCatalogListeners(client);
4201
+ await client.close().catch(() => {
4202
+ });
4203
+ }
4204
+ if (attempt >= MAX_ATTEMPTS) {
4205
+ this.log.error(
4206
+ `MCP server "${slot.cfg.name}" connect exhausted after ${MAX_ATTEMPTS} attempts`,
4207
+ err
4208
+ );
4209
+ slot.state = "failed";
4210
+ slot.client = void 0;
4211
+ if (slot.reconnectTimer) {
4212
+ clearTimeout(slot.reconnectTimer);
4213
+ slot.reconnectTimer = void 0;
4214
+ }
4215
+ slot.reconnectPending = false;
4216
+ this.events.emit("mcp.server.disconnected", {
4217
+ name: slot.cfg.name,
4218
+ reason: err instanceof Error ? err.message : "unknown"
4219
+ });
4220
+ return;
4221
+ }
4222
+ const delay = 500 * 2 ** attempt;
4223
+ await new Promise((r) => setTimeout(r, delay));
4224
+ }
4225
+ }
2150
4226
  }
2151
- cfg.enabled = true;
2152
- servers[name] = cfg;
2153
- await persist(deps.configPath, full, servers);
2154
- return startServer(name, cfg, deps, `Server "${name}" enabled`, { restart: true });
2155
- }
2156
- async function disableMcp(name, deps) {
2157
- if (!name) return { ok: false, message: "Server name is required" };
2158
- const { full, servers } = await readServers(deps.configPath);
2159
- const cfg = servers[name];
2160
- if (!cfg) return { ok: false, message: `Server "${name}" is not in config.` };
2161
- await safeStop(name, deps);
2162
- cfg.enabled = false;
2163
- servers[name] = cfg;
2164
- await persist(deps.configPath, full, servers);
2165
- return {
2166
- ok: true,
2167
- message: `Server "${name}" disabled`,
2168
- server: projectServer(name, cfg, deps.registry)
2169
- };
2170
- }
2171
- async function restartMcp(name, deps) {
2172
- if (!name) return { ok: false, message: "Server name is required" };
2173
- const registered = deps.registry.list().some((s) => s.name === name);
2174
- if (registered) {
2175
- try {
2176
- await deps.registry.restart(name);
2177
- const { state, tools } = liveState(name, deps.registry);
2178
- return { ok: true, message: `Server "${name}" restarted`, state, tools };
2179
- } catch (err) {
2180
- return { ok: false, message: `Failed to restart "${name}": ${errMessage(err)}` };
4227
+ };
4228
+ var MAX_CATALOG_PAGES = 100;
4229
+ var MAX_CATALOG_ITEMS = 1e4;
4230
+ async function collectPages(load, select) {
4231
+ const items = [];
4232
+ const seenCursors = /* @__PURE__ */ new Set();
4233
+ let cursor;
4234
+ for (let pageNumber = 0; pageNumber < MAX_CATALOG_PAGES; pageNumber++) {
4235
+ const page = await load(cursor);
4236
+ items.push(...select(page));
4237
+ if (items.length > MAX_CATALOG_ITEMS) {
4238
+ throw new Error(`MCP catalog exceeds ${MAX_CATALOG_ITEMS} items`);
2181
4239
  }
4240
+ const next = page.nextCursor;
4241
+ if (!next) return items;
4242
+ if (seenCursors.has(next)) throw new Error(`MCP catalog repeated cursor "${next}"`);
4243
+ seenCursors.add(next);
4244
+ cursor = next;
2182
4245
  }
2183
- const { servers } = await readServers(deps.configPath);
2184
- const cfg = servers[name];
2185
- if (!cfg) return { ok: false, message: `Server "${name}" is not in config.` };
2186
- return startServer(name, { ...cfg, name }, deps, `Server "${name}" started`, { restart: true });
4246
+ throw new Error(`MCP catalog exceeds ${MAX_CATALOG_PAGES} pages`);
2187
4247
  }
2188
- async function discoverMcp(name, deps) {
2189
- if (!name) return { ok: false, message: "Server name is required" };
2190
- const result = await restartMcp(name, deps);
2191
- if (!result.ok) return result;
2192
- const { state, tools } = liveState(name, deps.registry);
4248
+ function cloneRecords(records) {
4249
+ return structuredClone(records);
4250
+ }
4251
+ function catalogSnapshot(slot) {
2193
4252
  return {
2194
- ok: true,
2195
- message: `Discovered ${tools.length} tool${tools.length === 1 ? "" : "s"} from "${name}"`,
2196
- state,
2197
- tools
4253
+ name: slot.cfg.name,
4254
+ state: slot.state,
4255
+ serverMetadata: slot.serverMetadata ? structuredClone(slot.serverMetadata) : void 0,
4256
+ resources: slot.resources ? cloneRecords(slot.resources) : void 0,
4257
+ resourceTemplates: slot.resourceTemplates ? cloneRecords(slot.resourceTemplates) : void 0,
4258
+ prompts: slot.prompts ? cloneRecords(slot.prompts) : void 0
2198
4259
  };
2199
4260
  }
2200
- async function startServer(name, cfg, deps, okMessage, opts) {
2201
- try {
2202
- const alreadyRegistered = deps.registry.list().some((s) => s.name === name);
2203
- if (alreadyRegistered && opts?.restart) {
2204
- await deps.registry.restart(name);
2205
- } else if (alreadyRegistered) {
2206
- await deps.registry.restart(name);
2207
- } else {
2208
- await deps.registry.start({ ...cfg, enabled: true });
2209
- }
2210
- const { state, tools } = liveState(name, deps.registry);
2211
- return {
2212
- ok: true,
2213
- message: okMessage,
2214
- server: projectServer(name, cfg, deps.registry),
2215
- state,
2216
- tools
2217
- };
2218
- } catch (err) {
2219
- const message = errMessage(err);
2220
- return {
2221
- ok: true,
2222
- // config persisted — surface a soft warning, not a hard failure
2223
- message: `${okMessage} in config, but failed to start: ${message}`,
2224
- server: projectServer(name, cfg, deps.registry),
2225
- registryError: message
2226
- };
2227
- }
2228
- }
2229
- async function safeStop(name, deps) {
2230
- try {
2231
- await deps.registry.stop(name);
2232
- } catch {
2233
- }
2234
- }
2235
4261
 
2236
4262
  // src/server.ts
2237
4263
  import { createServer } from "node:http";
@@ -2245,6 +4271,8 @@ var MCPServer = class {
2245
4271
  host;
2246
4272
  serverInfo;
2247
4273
  logger;
4274
+ resources;
4275
+ prompts;
2248
4276
  constructor(opts) {
2249
4277
  this.host = opts.host;
2250
4278
  this.serverInfo = opts.serverInfo ?? {
@@ -2252,6 +4280,8 @@ var MCPServer = class {
2252
4280
  version: MCP_CONSTANTS.CLIENT_INFO.version
2253
4281
  };
2254
4282
  this.logger = opts.logger;
4283
+ this.resources = structuredClone(opts.resources ?? []);
4284
+ this.prompts = structuredClone(opts.prompts ?? []);
2255
4285
  }
2256
4286
  /**
2257
4287
  * Handle one raw JSON-RPC line. Returns the response JSON string for
@@ -2296,7 +4326,11 @@ var MCPServer = class {
2296
4326
  case "initialize":
2297
4327
  return {
2298
4328
  protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
2299
- capabilities: { tools: { listChanged: false } },
4329
+ capabilities: {
4330
+ tools: { listChanged: false },
4331
+ ...this.resources.length > 0 ? { resources: { subscribe: false, listChanged: false } } : {},
4332
+ ...this.prompts.length > 0 ? { prompts: { listChanged: false } } : {}
4333
+ },
2300
4334
  serverInfo: this.serverInfo
2301
4335
  };
2302
4336
  case "ping":
@@ -2314,6 +4348,54 @@ var MCPServer = class {
2314
4348
  const res = await this.host.callTool(p.name, args);
2315
4349
  return { content: toContentBlocks(res.content), isError: res.isError };
2316
4350
  }
4351
+ case "resources/list": {
4352
+ if (this.resources.length === 0) return METHOD_NOT_FOUND_SENTINEL;
4353
+ const page = paginate(this.resources, params);
4354
+ return {
4355
+ resources: page.items.map(({ contents: _contents, ...resource }) => resource),
4356
+ ...page.nextCursor ? { nextCursor: page.nextCursor } : {}
4357
+ };
4358
+ }
4359
+ case "resources/templates/list":
4360
+ if (this.resources.length === 0) return METHOD_NOT_FOUND_SENTINEL;
4361
+ return { resourceTemplates: [] };
4362
+ case "resources/read": {
4363
+ if (this.resources.length === 0) return METHOD_NOT_FOUND_SENTINEL;
4364
+ const uri = requiredParamString(params, "uri", "resources/read");
4365
+ const resource = this.resources.find((candidate) => candidate.uri === uri);
4366
+ if (!resource) throw new Error(`Resource not found: ${uri}`);
4367
+ return { contents: structuredClone(resource.contents) };
4368
+ }
4369
+ case "prompts/list": {
4370
+ if (this.prompts.length === 0) return METHOD_NOT_FOUND_SENTINEL;
4371
+ const page = paginate(this.prompts, params);
4372
+ return {
4373
+ prompts: page.items.map(
4374
+ ({ messages: _messages, template: _template, ...prompt }) => prompt
4375
+ ),
4376
+ ...page.nextCursor ? { nextCursor: page.nextCursor } : {}
4377
+ };
4378
+ }
4379
+ case "prompts/get": {
4380
+ if (this.prompts.length === 0) return METHOD_NOT_FOUND_SENTINEL;
4381
+ const name = requiredParamString(params, "name", "prompts/get");
4382
+ const prompt = this.prompts.find((candidate) => candidate.name === name);
4383
+ if (!prompt) throw new Error(`Prompt not found: ${name}`);
4384
+ const input = paramsRecord(params);
4385
+ const args = stringRecord(input["arguments"], "prompts/get arguments");
4386
+ for (const argument of prompt.arguments ?? []) {
4387
+ if (argument.required && args[argument.name] === void 0) {
4388
+ throw new Error(`Prompt "${name}" requires argument "${argument.name}"`);
4389
+ }
4390
+ }
4391
+ const messages = prompt.template ? [
4392
+ {
4393
+ role: "user",
4394
+ content: { type: "text", text: renderPromptTemplate(prompt.template, args) }
4395
+ }
4396
+ ] : structuredClone(prompt.messages ?? []);
4397
+ return { description: prompt.description, messages };
4398
+ }
2317
4399
  default:
2318
4400
  return METHOD_NOT_FOUND_SENTINEL;
2319
4401
  }
@@ -2322,6 +4404,53 @@ var MCPServer = class {
2322
4404
  return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
2323
4405
  }
2324
4406
  };
4407
+ var SERVER_PAGE_SIZE = 100;
4408
+ function paginate(items, params) {
4409
+ const cursor = paramsRecord(params)["cursor"];
4410
+ let offset = 0;
4411
+ if (cursor !== void 0) {
4412
+ if (typeof cursor !== "string" || !/^\d+$/.test(cursor)) {
4413
+ throw new Error("MCP pagination cursor must be a non-negative integer string");
4414
+ }
4415
+ offset = Number(cursor);
4416
+ if (!Number.isSafeInteger(offset)) throw new Error("MCP pagination cursor is too large");
4417
+ }
4418
+ const page = items.slice(offset, offset + SERVER_PAGE_SIZE);
4419
+ const next = offset + page.length;
4420
+ return {
4421
+ items: page,
4422
+ ...next < items.length ? { nextCursor: String(next) } : {}
4423
+ };
4424
+ }
4425
+ function paramsRecord(params) {
4426
+ return params && typeof params === "object" && !Array.isArray(params) ? params : {};
4427
+ }
4428
+ function requiredParamString(params, field, method) {
4429
+ const value = paramsRecord(params)[field];
4430
+ if (typeof value !== "string" || value.length === 0) {
4431
+ throw new Error(`${method} requires a non-empty string "${field}"`);
4432
+ }
4433
+ return value;
4434
+ }
4435
+ function stringRecord(value, label) {
4436
+ if (value === void 0) return {};
4437
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
4438
+ throw new Error(`${label} must be an object`);
4439
+ }
4440
+ const result = {};
4441
+ for (const [key, item] of Object.entries(value)) {
4442
+ if (typeof item !== "string") throw new Error(`${label}.${key} must be a string`);
4443
+ result[key] = item;
4444
+ }
4445
+ return result;
4446
+ }
4447
+ function renderPromptTemplate(template, args) {
4448
+ return template.replace(/\{\{([A-Za-z_][A-Za-z0-9_.-]*)\}\}/g, (_match, name) => {
4449
+ const value = args[name];
4450
+ if (value === void 0) throw new Error(`Missing prompt template argument "${name}"`);
4451
+ return value;
4452
+ });
4453
+ }
2325
4454
  var METHOD_NOT_FOUND_SENTINEL = /* @__PURE__ */ Symbol("method-not-found");
2326
4455
  function toContentBlocks(content) {
2327
4456
  if (typeof content === "string") return [{ type: "text", text: content }];
@@ -2512,28 +4641,349 @@ async function handleHttpRequest(server, req, res, token, log) {
2512
4641
  });
2513
4642
  });
2514
4643
  }
4644
+
4645
+ // src/token-store.ts
4646
+ import * as fs3 from "node:fs/promises";
4647
+ import { atomicWrite, withFileLock } from "@wrongstack/core/utils";
4648
+ var TOKEN_STORE_VERSION = 1;
4649
+ var MAX_STORE_BYTES = 1024 * 1024;
4650
+ var MAX_ENTRIES = 256;
4651
+ var DEFAULT_REFRESH_SKEW_MS = 6e4;
4652
+ var MCPVaultTokenStore = class {
4653
+ constructor(filePath, vault) {
4654
+ this.filePath = filePath;
4655
+ this.vault = vault;
4656
+ }
4657
+ filePath;
4658
+ vault;
4659
+ async load(serverName, resource) {
4660
+ const canonicalResource = canonicalMcpResource(resource);
4661
+ return withFileLock(this.filePath, async () => {
4662
+ const file = await this.readFile();
4663
+ const entry = file.entries.find(
4664
+ (candidate) => candidate.serverName === serverName && candidate.resource === canonicalResource
4665
+ );
4666
+ return entry ? this.decryptEntry(entry) : void 0;
4667
+ });
4668
+ }
4669
+ async save(value) {
4670
+ const normalized = normalizeStoredAuthorization(value);
4671
+ await withFileLock(this.filePath, async () => {
4672
+ const file = await this.readFile();
4673
+ const next = file.entries.filter(
4674
+ (entry) => !(entry.serverName === normalized.serverName && entry.resource === normalized.resource)
4675
+ );
4676
+ next.push(this.encryptEntry(normalized));
4677
+ if (next.length > MAX_ENTRIES)
4678
+ throw new Error(`MCP token store exceeds ${MAX_ENTRIES} entries`);
4679
+ await this.writeFile(next);
4680
+ });
4681
+ }
4682
+ async remove(serverName, resource) {
4683
+ const canonicalResource = canonicalMcpResource(resource);
4684
+ return withFileLock(this.filePath, async () => {
4685
+ const file = await this.readFile();
4686
+ const next = file.entries.filter(
4687
+ (entry) => !(entry.serverName === serverName && entry.resource === canonicalResource)
4688
+ );
4689
+ if (next.length === file.entries.length) return false;
4690
+ await this.writeFile(next);
4691
+ return true;
4692
+ });
4693
+ }
4694
+ async readFile() {
4695
+ let raw;
4696
+ try {
4697
+ const stat2 = await fs3.stat(this.filePath);
4698
+ if (stat2.size > MAX_STORE_BYTES) throw new Error("MCP token store exceeds size limit");
4699
+ raw = await fs3.readFile(this.filePath, "utf8");
4700
+ } catch (error) {
4701
+ if (error.code === "ENOENT") return emptyFile();
4702
+ throw error;
4703
+ }
4704
+ let parsed;
4705
+ try {
4706
+ parsed = JSON.parse(raw);
4707
+ } catch {
4708
+ throw new Error("MCP token store is not valid JSON");
4709
+ }
4710
+ return validateStoreFile(parsed);
4711
+ }
4712
+ async writeFile(entries) {
4713
+ const file = {
4714
+ version: TOKEN_STORE_VERSION,
4715
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4716
+ entries
4717
+ };
4718
+ await atomicWrite(this.filePath, `${JSON.stringify(file, null, 2)}
4719
+ `, { mode: 384 });
4720
+ }
4721
+ encryptEntry(value) {
4722
+ const accessToken = this.vault.encrypt(value.tokenSet.accessToken);
4723
+ const refreshToken = value.tokenSet.refreshToken ? this.vault.encrypt(value.tokenSet.refreshToken) : void 0;
4724
+ if (!this.vault.isEncrypted(accessToken) || refreshToken && !this.vault.isEncrypted(refreshToken)) {
4725
+ throw new Error("MCP token store requires an encrypting SecretVault");
4726
+ }
4727
+ return {
4728
+ serverName: value.serverName,
4729
+ resource: value.resource,
4730
+ clientId: value.clientId,
4731
+ authorizationServer: value.authorizationServer,
4732
+ accessToken,
4733
+ refreshToken,
4734
+ tokenType: value.tokenSet.tokenType ?? "Bearer",
4735
+ expiresAt: value.tokenSet.expiresAt,
4736
+ scopes: [...value.tokenSet.scopes ?? []],
4737
+ updatedAt: value.updatedAt
4738
+ };
4739
+ }
4740
+ decryptEntry(entry) {
4741
+ if (!this.vault.isEncrypted(entry.accessToken) || entry.refreshToken !== void 0 && !this.vault.isEncrypted(entry.refreshToken)) {
4742
+ throw new Error("MCP token store contains an unencrypted token");
4743
+ }
4744
+ const value = {
4745
+ serverName: entry.serverName,
4746
+ resource: entry.resource,
4747
+ clientId: entry.clientId,
4748
+ authorizationServer: entry.authorizationServer,
4749
+ tokenSet: {
4750
+ accessToken: this.vault.decrypt(entry.accessToken),
4751
+ refreshToken: entry.refreshToken ? this.vault.decrypt(entry.refreshToken) : void 0,
4752
+ tokenType: entry.tokenType,
4753
+ resource: entry.resource,
4754
+ expiresAt: entry.expiresAt,
4755
+ scopes: [...entry.scopes]
4756
+ },
4757
+ updatedAt: entry.updatedAt
4758
+ };
4759
+ return normalizeStoredAuthorization(value);
4760
+ }
4761
+ };
4762
+ var MCPRefreshingAuthorizationProvider = class {
4763
+ constructor(options) {
4764
+ this.options = options;
4765
+ this.resource = canonicalMcpResource(options.resource);
4766
+ this.refreshSkewMs = options.refreshSkewMs ?? DEFAULT_REFRESH_SKEW_MS;
4767
+ }
4768
+ options;
4769
+ refreshPromise;
4770
+ resource;
4771
+ refreshSkewMs;
4772
+ async getAccessToken(context) {
4773
+ this.assertContext(context);
4774
+ let state = await this.options.store.load(this.options.serverName, this.resource);
4775
+ if (!state) return void 0;
4776
+ if (state.tokenSet.expiresAt !== void 0 && state.tokenSet.expiresAt <= Date.now() + this.refreshSkewMs) {
4777
+ state = await this.refresh(state, context.signal);
4778
+ }
4779
+ if (!state) return void 0;
4780
+ if (state.tokenSet.expiresAt !== void 0 && state.tokenSet.expiresAt <= Date.now()) {
4781
+ this.emit("reauth_required", state);
4782
+ return void 0;
4783
+ }
4784
+ authorizationHeaderForToken(state.tokenSet, this.resource);
4785
+ return { ...state.tokenSet, scopes: [...state.tokenSet.scopes ?? []] };
4786
+ }
4787
+ async handleUnauthorized(challenge, context) {
4788
+ this.assertContext(context);
4789
+ if (challenge.resource !== this.resource) return false;
4790
+ const state = await this.options.store.load(this.options.serverName, this.resource);
4791
+ if (!state?.tokenSet.refreshToken) {
4792
+ if (state) this.emit("reauth_required", state);
4793
+ return false;
4794
+ }
4795
+ return await this.refresh(state, context.signal) !== void 0;
4796
+ }
4797
+ refresh(state, signal) {
4798
+ if (this.refreshPromise) return this.refreshPromise;
4799
+ this.refreshPromise = this.refreshInner(state, signal).finally(() => {
4800
+ this.refreshPromise = void 0;
4801
+ });
4802
+ return this.refreshPromise;
4803
+ }
4804
+ async refreshInner(state, signal) {
4805
+ const refreshToken = state.tokenSet.refreshToken;
4806
+ if (!refreshToken) {
4807
+ this.emit("reauth_required", state);
4808
+ return void 0;
4809
+ }
4810
+ const tokenSet = await refreshMcpAccessToken({
4811
+ authorizationServer: state.authorizationServer,
4812
+ clientId: state.clientId,
4813
+ resource: state.resource,
4814
+ refreshToken,
4815
+ signal
4816
+ });
4817
+ const next = normalizeStoredAuthorization({
4818
+ ...state,
4819
+ tokenSet,
4820
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4821
+ });
4822
+ await this.options.store.save(next);
4823
+ this.emit("refreshed", next);
4824
+ return next;
4825
+ }
4826
+ assertContext(context) {
4827
+ if (context.serverName !== this.options.serverName || context.resource !== this.resource) {
4828
+ throw new Error("MCP authorization provider context does not match its server/resource");
4829
+ }
4830
+ }
4831
+ emit(state, value) {
4832
+ this.options.onStateChange?.({
4833
+ serverName: value.serverName,
4834
+ state,
4835
+ resource: value.resource,
4836
+ expiresAt: value.tokenSet.expiresAt,
4837
+ scopes: [...value.tokenSet.scopes ?? []]
4838
+ });
4839
+ }
4840
+ };
4841
+ function createVaultBackedMcpAuthorizationProviderFactory(options) {
4842
+ const providers = /* @__PURE__ */ new Map();
4843
+ return (server) => {
4844
+ if (server.transport === "stdio" || !server.url) return void 0;
4845
+ const resource = canonicalMcpResource(server.url);
4846
+ const key = `${server.name}\0${resource}`;
4847
+ let provider = providers.get(key);
4848
+ if (!provider) {
4849
+ provider = new MCPRefreshingAuthorizationProvider({
4850
+ serverName: server.name,
4851
+ resource,
4852
+ store: options.store,
4853
+ refreshSkewMs: options.refreshSkewMs,
4854
+ onStateChange: options.onStateChange
4855
+ });
4856
+ providers.set(key, provider);
4857
+ }
4858
+ return provider;
4859
+ };
4860
+ }
4861
+ function emptyFile() {
4862
+ return { version: TOKEN_STORE_VERSION, updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(), entries: [] };
4863
+ }
4864
+ function validateStoreFile(value) {
4865
+ if (!isRecord(value) || value["version"] !== TOKEN_STORE_VERSION || !Array.isArray(value["entries"])) {
4866
+ throw new Error("MCP token store has an unsupported or malformed structure");
4867
+ }
4868
+ if (value["entries"].length > MAX_ENTRIES)
4869
+ throw new Error("MCP token store has too many entries");
4870
+ return {
4871
+ version: TOKEN_STORE_VERSION,
4872
+ updatedAt: boundedString(value["updatedAt"], "updatedAt", 128),
4873
+ entries: value["entries"].map(validateEncryptedEntry)
4874
+ };
4875
+ }
4876
+ function validateEncryptedEntry(value) {
4877
+ if (!isRecord(value)) throw new Error("MCP token store entry must be an object");
4878
+ const resource = canonicalMcpResource(boundedString(value["resource"], "resource", 4096));
4879
+ const authorizationServer = validateMcpAuthorizationServerMetadata(value["authorizationServer"]);
4880
+ const scopes = stringArray(value["scopes"], "scopes", 128);
4881
+ const expiresAt = value["expiresAt"];
4882
+ if (expiresAt !== void 0 && (typeof expiresAt !== "number" || !Number.isFinite(expiresAt))) {
4883
+ throw new Error("MCP token store expiresAt must be a finite number");
4884
+ }
4885
+ return {
4886
+ serverName: boundedString(value["serverName"], "serverName", 256),
4887
+ resource,
4888
+ clientId: boundedString(value["clientId"], "clientId", 4096),
4889
+ authorizationServer,
4890
+ accessToken: boundedString(value["accessToken"], "accessToken", 32768),
4891
+ refreshToken: value["refreshToken"] === void 0 ? void 0 : boundedString(value["refreshToken"], "refreshToken", 32768),
4892
+ tokenType: boundedString(value["tokenType"], "tokenType", 64),
4893
+ expiresAt,
4894
+ scopes,
4895
+ updatedAt: boundedString(value["updatedAt"], "updatedAt", 128)
4896
+ };
4897
+ }
4898
+ function normalizeStoredAuthorization(value) {
4899
+ const serverName = boundedString(value.serverName, "serverName", 256);
4900
+ const resource = canonicalMcpResource(value.resource);
4901
+ const authorizationServer = validateMcpAuthorizationServerMetadata(value.authorizationServer);
4902
+ if (canonicalMcpResource(value.tokenSet.resource) !== resource) {
4903
+ throw new Error("MCP token resource mismatch");
4904
+ }
4905
+ const tokenSet = {
4906
+ ...value.tokenSet,
4907
+ resource,
4908
+ scopes: stringArray(value.tokenSet.scopes ?? [], "scopes", 128)
4909
+ };
4910
+ authorizationHeaderForToken({ ...tokenSet, expiresAt: void 0 }, resource);
4911
+ return {
4912
+ serverName,
4913
+ resource,
4914
+ clientId: boundedString(value.clientId, "clientId", 4096),
4915
+ authorizationServer,
4916
+ tokenSet,
4917
+ updatedAt: boundedString(value.updatedAt, "updatedAt", 128)
4918
+ };
4919
+ }
4920
+ function isRecord(value) {
4921
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4922
+ }
4923
+ function boundedString(value, field, maxLength) {
4924
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength || /[\r\n]/.test(value)) {
4925
+ throw new Error(`MCP token store field "${field}" is invalid`);
4926
+ }
4927
+ return value;
4928
+ }
4929
+ function stringArray(value, field, maxItems) {
4930
+ if (!Array.isArray(value) || value.length > maxItems) {
4931
+ throw new Error(`MCP token store field "${field}" must be a bounded array`);
4932
+ }
4933
+ return [...new Set(value.map((entry) => boundedString(entry, field, 256)))];
4934
+ }
2515
4935
  export {
4936
+ DEFAULT_MCP_INSERTION_MAX_BYTES,
4937
+ DEFAULT_MCP_RESOURCE_SCHEMES,
4938
+ MCPAuthorizationManager,
2516
4939
  MCPClient,
4940
+ MCPRefreshingAuthorizationProvider,
2517
4941
  MCPRegistry,
2518
4942
  MCPServer,
4943
+ MCPVaultTokenStore,
2519
4944
  MCP_CONSTANTS,
4945
+ MCP_OPERATION_LIMITS,
2520
4946
  SSEReader,
2521
4947
  SSETransport,
2522
4948
  StreamableHTTPTransport,
2523
4949
  addMcp,
4950
+ authorizationHeaderForToken,
4951
+ authorizationServerMetadataUrls,
4952
+ canonicalMcpResource,
4953
+ createMcpAuthorizationRequest,
4954
+ createVaultBackedMcpAuthorizationProviderFactory,
2524
4955
  disableMcp,
2525
4956
  discoverMcp,
4957
+ discoverMcpAuthorization,
2526
4958
  enableMcp,
4959
+ exchangeMcpAuthorizationCode,
2527
4960
  listMcp,
2528
4961
  manifestConfigHash,
4962
+ parseAuthorizationServerMetadata,
4963
+ parseGetPromptResult,
4964
+ parseListPromptsResult,
4965
+ parseListResourceTemplatesResult,
4966
+ parseListResourcesResult,
4967
+ parseMcpAuthorizationCallback,
4968
+ parseMcpBearerChallenge,
4969
+ parseProtectedResourceMetadata,
4970
+ parseReadResourceResult,
4971
+ parseServerMetadata,
4972
+ preparePromptInsertion,
4973
+ prepareResourceInsertion,
4974
+ protectedResourceMetadataUrls,
4975
+ readCapabilityManifest,
2529
4976
  readManifest,
4977
+ refreshMcpAccessToken,
2530
4978
  removeMcp,
2531
4979
  restartMcp,
2532
4980
  serveHttp,
2533
4981
  serveStdio,
2534
4982
  toContentBlocks,
2535
4983
  updateMcp,
4984
+ validateMcpAuthorizationServerMetadata,
2536
4985
  wrapMCPTool,
4986
+ writeCapabilityManifest,
2537
4987
  writeManifest
2538
4988
  };
2539
4989
  //# sourceMappingURL=index.js.map