@wrongstack/mcp 0.285.0 → 0.287.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/authorization-manager.d.ts +65 -0
  2. package/dist/authorization-manager.d.ts.map +1 -0
  3. package/dist/authorization.d.ts +128 -0
  4. package/dist/authorization.d.ts.map +1 -0
  5. package/dist/client.d.ts +31 -0
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/content-selection.d.ts +33 -0
  8. package/dist/content-selection.d.ts.map +1 -0
  9. package/dist/index.d.ts +13 -7
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3085 -619
  12. package/dist/index.js.map +4 -4
  13. package/dist/manage.d.ts +3 -1
  14. package/dist/manage.d.ts.map +1 -1
  15. package/dist/manifest-cache.d.ts +15 -0
  16. package/dist/manifest-cache.d.ts.map +1 -1
  17. package/dist/operations.d.ts +100 -0
  18. package/dist/operations.d.ts.map +1 -0
  19. package/dist/protocol.d.ts +91 -0
  20. package/dist/protocol.d.ts.map +1 -0
  21. package/dist/registry.d.ts +73 -0
  22. package/dist/registry.d.ts.map +1 -1
  23. package/dist/server.d.ts +25 -0
  24. package/dist/server.d.ts.map +1 -1
  25. package/dist/sse-reader.d.ts +17 -0
  26. package/dist/sse-reader.d.ts.map +1 -0
  27. package/dist/token-store.d.ts +56 -0
  28. package/dist/token-store.d.ts.map +1 -0
  29. package/dist/transport-base.d.ts +90 -0
  30. package/dist/transport-base.d.ts.map +1 -0
  31. package/dist/transport-jsonrpc.d.ts +32 -0
  32. package/dist/transport-jsonrpc.d.ts.map +1 -0
  33. package/dist/transport-security.d.ts +13 -0
  34. package/dist/transport-security.d.ts.map +1 -0
  35. package/dist/transport-sse.d.ts +31 -0
  36. package/dist/transport-sse.d.ts.map +1 -0
  37. package/dist/transport-streamable.d.ts +27 -0
  38. package/dist/transport-streamable.d.ts.map +1 -0
  39. package/dist/transport.d.ts +7 -144
  40. package/dist/transport.d.ts.map +1 -1
  41. package/dist/wrap-tool.d.ts +8 -1
  42. package/dist/wrap-tool.d.ts.map +1 -1
  43. package/package.json +2 -2
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 [];
@@ -74,66 +1014,96 @@ function normalizeMCPTools(value) {
74
1014
  return tools;
75
1015
  }
76
1016
 
77
- // src/transport.ts
78
- import { randomBytes } from "node:crypto";
79
- import * as https from "node:https";
80
- import * as net from "node:net";
81
- import { ConfigError, ToolError } from "@wrongstack/core";
82
- function isTlsUnsafeAllowed() {
83
- return process.env["WRONGSTACK_UNSAFE_MCP_TLS"] === "1";
84
- }
85
- function validateTransportUrl(rawUrl) {
86
- let url;
87
- try {
88
- url = new URL(rawUrl);
89
- } catch {
90
- throw new ConfigError({
91
- message: `MCP transport: invalid URL "${rawUrl}"`,
92
- code: "CONFIG_INVALID",
93
- context: { field: "url", rawUrl }
94
- });
95
- }
96
- if (url.protocol !== "http:" && url.protocol !== "https:") {
97
- throw new ConfigError({
98
- message: `MCP transport: unsupported protocol "${url.protocol}" \u2014 only http/https allowed`,
99
- code: "CONFIG_INVALID",
100
- context: { field: "url", rawUrl, protocol: url.protocol }
101
- });
1017
+ // src/transport-jsonrpc.ts
1018
+ import { ToolError } from "@wrongstack/core";
1019
+ function isJsonRpcResult(v) {
1020
+ if (typeof v !== "object" || v === null) return false;
1021
+ const r = v;
1022
+ if (r["jsonrpc"] !== "2.0" || typeof r["id"] !== "number") return false;
1023
+ if (Object.hasOwn(r, "method")) return false;
1024
+ const hasResult = Object.hasOwn(r, "result");
1025
+ const hasError = Object.hasOwn(r, "error");
1026
+ if (hasResult === hasError) return false;
1027
+ if (hasError) {
1028
+ const error = r["error"];
1029
+ return typeof error === "object" && error !== null && typeof error["code"] === "number" && typeof error["message"] === "string";
102
1030
  }
103
- const hostname = url.hostname;
104
- const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
105
- const ipVersion = net.isIP(host);
106
- if (ipVersion === 4) {
107
- const parts = host.split(".").map(Number);
108
- if (parts[0] === 169 && parts[1] === 254) {
109
- throw new ConfigError({
110
- message: `MCP transport: blocked link-local/IMDS address "${hostname}" \u2014 likely not a valid MCP server`,
111
- code: "CONFIG_INVALID",
112
- context: { field: "url", rawUrl, hostname }
113
- });
1031
+ return true;
1032
+ }
1033
+ function isJsonRpcMethodEnvelope(v) {
1034
+ if (typeof v !== "object" || v === null) return false;
1035
+ const envelope = v;
1036
+ if (envelope["jsonrpc"] !== "2.0" || typeof envelope["method"] !== "string") return false;
1037
+ const id = envelope["id"];
1038
+ return id === void 0 || typeof id === "number" || typeof id === "string";
1039
+ }
1040
+ function extractJsonRpcEnvelopes(text) {
1041
+ const out = [];
1042
+ let dataBuf = [];
1043
+ const flush = () => {
1044
+ if (dataBuf.length === 0) return;
1045
+ const joined = dataBuf.join("\n").trim();
1046
+ dataBuf = [];
1047
+ if (!joined) return;
1048
+ try {
1049
+ const parsed = JSON.parse(joined);
1050
+ if (isJsonRpcResult(parsed) || isJsonRpcMethodEnvelope(parsed)) out.push(parsed);
1051
+ } catch {
114
1052
  }
115
- } else if (ipVersion === 6) {
116
- const lower = host.toLowerCase();
117
- const linkLocal = /^fe[89ab]/.test(lower);
118
- if (linkLocal || lower === "fd00:ec2::254") {
119
- throw new ConfigError({
120
- message: `MCP transport: blocked link-local/IMDS address "${hostname}" \u2014 likely not a valid MCP server`,
121
- code: "CONFIG_INVALID",
122
- context: { field: "url", rawUrl, hostname }
123
- });
1053
+ };
1054
+ for (const raw of text.split("\n")) {
1055
+ const line = raw.replace(/\r$/, "");
1056
+ if (line === "") {
1057
+ flush();
1058
+ continue;
124
1059
  }
125
- }
126
- if (url.protocol === "http:") {
127
- const isLoopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
128
- if (!isLoopback) {
129
- throw new ConfigError({
130
- message: `MCP transport: http:// is only allowed for loopback addresses; use https:// for "${hostname}"`,
131
- code: "CONFIG_INVALID",
132
- context: { field: "url", rawUrl, hostname, protocol: url.protocol }
133
- });
1060
+ if (line.startsWith(":")) continue;
1061
+ if (line.startsWith("data:")) {
1062
+ let v = line.slice(5);
1063
+ if (v.startsWith(" ")) v = v.slice(1);
1064
+ dataBuf.push(v);
1065
+ continue;
1066
+ }
1067
+ if (line.startsWith("event:") || line.startsWith("id:") || line.startsWith("retry:")) {
1068
+ continue;
1069
+ }
1070
+ const trimmed = line.trim();
1071
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
1072
+ try {
1073
+ const parsed = JSON.parse(trimmed);
1074
+ if (isJsonRpcResult(parsed) || isJsonRpcMethodEnvelope(parsed)) out.push(parsed);
1075
+ } catch {
1076
+ }
134
1077
  }
135
1078
  }
1079
+ flush();
1080
+ return out;
1081
+ }
1082
+ function extractJsonRpcResults(text) {
1083
+ return extractJsonRpcEnvelopes(text).filter(isJsonRpcResult);
1084
+ }
1085
+ function assertMatchingJsonRpcResult(data, expectedId, method) {
1086
+ if (!isJsonRpcResult(data)) {
1087
+ throw new ToolError({
1088
+ message: "Invalid JSON-RPC response: not a JSON-RPC 2.0 envelope",
1089
+ code: "TOOL_EXECUTION_FAILED",
1090
+ toolName: "mcp_transport_jsonrpc",
1091
+ context: { method, expectedId, reason: "not-jsonrpc-envelope" }
1092
+ });
1093
+ }
1094
+ if (data.id !== expectedId) {
1095
+ throw new ToolError({
1096
+ message: `Invalid JSON-RPC response: id mismatch for ${method} (expected ${expectedId}, got ${data.id})`,
1097
+ code: "TOOL_EXECUTION_FAILED",
1098
+ toolName: "mcp_transport_jsonrpc",
1099
+ context: { method, expectedId, actualId: data.id, reason: "id-mismatch" }
1100
+ });
1101
+ }
1102
+ return data;
136
1103
  }
1104
+
1105
+ // src/sse-reader.ts
1106
+ import { ToolError as ToolError2 } from "@wrongstack/core";
137
1107
  var SSE_READER_MAX_BUFFER = 256 * 1024;
138
1108
  var SSE_READER_MAX_DATA_LINES = 1024;
139
1109
  var SSEReader = class {
@@ -149,7 +1119,7 @@ var SSEReader = class {
149
1119
  }
150
1120
  feed(chunk) {
151
1121
  if (chunk.length > SSE_READER_MAX_BUFFER) {
152
- throw new ToolError({
1122
+ throw new ToolError2({
153
1123
  message: `SSE: chunk size ${chunk.length} exceeds max buffer ${SSE_READER_MAX_BUFFER} \u2014 refusing to accumulate`,
154
1124
  code: "TOOL_EXECUTION_FAILED",
155
1125
  toolName: "mcp_transport_sse_reader",
@@ -158,11 +1128,15 @@ var SSEReader = class {
158
1128
  }
159
1129
  this.buffer += chunk;
160
1130
  if (this.buffer.length > SSE_READER_MAX_BUFFER) {
161
- throw new ToolError({
1131
+ throw new ToolError2({
162
1132
  message: `SSE: pending line exceeds ${SSE_READER_MAX_BUFFER} bytes \u2014 upstream is not framing events`,
163
1133
  code: "TOOL_EXECUTION_FAILED",
164
1134
  toolName: "mcp_transport_sse_reader",
165
- context: { phase: "feed", bufferLength: this.buffer.length, maxBuffer: SSE_READER_MAX_BUFFER }
1135
+ context: {
1136
+ phase: "feed",
1137
+ bufferLength: this.buffer.length,
1138
+ maxBuffer: SSE_READER_MAX_BUFFER
1139
+ }
166
1140
  });
167
1141
  }
168
1142
  let idx = this.buffer.indexOf("\n");
@@ -186,11 +1160,15 @@ var SSEReader = class {
186
1160
  if (field === "event") {
187
1161
  } else if (field === "data") {
188
1162
  if (this.dataLines.length >= SSE_READER_MAX_DATA_LINES) {
189
- throw new ToolError({
1163
+ throw new ToolError2({
190
1164
  message: `SSE: exceeded ${SSE_READER_MAX_DATA_LINES} data lines per event \u2014 upstream is not sending blank-line delimiters`,
191
1165
  code: "TOOL_EXECUTION_FAILED",
192
1166
  toolName: "mcp_transport_sse_reader",
193
- context: { phase: "processLine", dataLineCount: this.dataLines.length, maxDataLines: SSE_READER_MAX_DATA_LINES }
1167
+ context: {
1168
+ phase: "processLine",
1169
+ dataLineCount: this.dataLines.length,
1170
+ maxDataLines: SSE_READER_MAX_DATA_LINES
1171
+ }
194
1172
  });
195
1173
  }
196
1174
  this.dataLines.push(value);
@@ -223,88 +1201,71 @@ var SSEReader = class {
223
1201
  this.listeners = [];
224
1202
  }
225
1203
  };
226
- function isJsonRpcResult(v) {
227
- if (typeof v !== "object" || v === null) return false;
228
- 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";
232
- }
233
- return "result" in r || r.id === void 0;
234
- }
235
- function extractJsonRpcResults(text) {
236
- const out = [];
237
- let dataBuf = [];
238
- const flush = () => {
239
- if (dataBuf.length === 0) return;
240
- const joined = dataBuf.join("\n").trim();
241
- dataBuf = [];
242
- if (!joined) return;
243
- try {
244
- const parsed = JSON.parse(joined);
245
- if (isJsonRpcResult(parsed)) out.push(parsed);
246
- } catch {
247
- }
248
- };
249
- for (const raw of text.split("\n")) {
250
- const line = raw.replace(/\r$/, "");
251
- if (line === "") {
252
- flush();
253
- continue;
254
- }
255
- if (line.startsWith(":")) continue;
256
- if (line.startsWith("data:")) {
257
- let v = line.slice(5);
258
- if (v.startsWith(" ")) v = v.slice(1);
259
- dataBuf.push(v);
260
- continue;
261
- }
262
- if (line.startsWith("event:") || line.startsWith("id:") || line.startsWith("retry:")) {
263
- continue;
264
- }
265
- const trimmed = line.trim();
266
- if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
267
- try {
268
- const parsed = JSON.parse(trimmed);
269
- if (isJsonRpcResult(parsed)) out.push(parsed);
270
- } catch {
271
- }
272
- }
273
- }
274
- flush();
275
- return out;
276
- }
277
- function pickJsonRpcResult(text, id) {
278
- const results = extractJsonRpcResults(text);
279
- return results.find((r) => r.id === id) ?? results[0];
1204
+
1205
+ // src/transport-base.ts
1206
+ import * as https2 from "node:https";
1207
+ import { ConfigError as ConfigError2 } from "@wrongstack/core";
1208
+
1209
+ // src/transport-security.ts
1210
+ import * as net2 from "node:net";
1211
+ import { ConfigError } from "@wrongstack/core";
1212
+ function isTlsUnsafeAllowed() {
1213
+ return process.env["WRONGSTACK_UNSAFE_MCP_TLS"] === "1";
280
1214
  }
281
- function assertMatchingJsonRpcResult(data, expectedId, method) {
282
- if (!isJsonRpcResult(data)) {
283
- throw new ToolError({
284
- message: "Invalid JSON-RPC response: not a JSON-RPC 2.0 envelope",
285
- code: "TOOL_EXECUTION_FAILED",
286
- toolName: "mcp_transport_jsonrpc",
287
- context: { method, expectedId, reason: "not-jsonrpc-envelope" }
1215
+ function validateTransportUrl(rawUrl) {
1216
+ let url;
1217
+ try {
1218
+ url = new URL(rawUrl);
1219
+ } catch {
1220
+ throw new ConfigError({
1221
+ message: `MCP transport: invalid URL "${rawUrl}"`,
1222
+ code: "CONFIG_INVALID",
1223
+ context: { field: "url", rawUrl }
288
1224
  });
289
1225
  }
290
- if (data.id !== void 0 && data.id !== expectedId) {
291
- throw new ToolError({
292
- message: `Invalid JSON-RPC response: id mismatch for ${method} (expected ${expectedId}, got ${data.id})`,
293
- code: "TOOL_EXECUTION_FAILED",
294
- toolName: "mcp_transport_jsonrpc",
295
- context: { method, expectedId, actualId: data.id, reason: "id-mismatch" }
1226
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
1227
+ throw new ConfigError({
1228
+ message: `MCP transport: unsupported protocol "${url.protocol}" \u2014 only http/https allowed`,
1229
+ code: "CONFIG_INVALID",
1230
+ context: { field: "url", rawUrl, protocol: url.protocol }
296
1231
  });
297
1232
  }
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
- });
1233
+ const hostname = url.hostname;
1234
+ const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
1235
+ const ipVersion = net2.isIP(host);
1236
+ if (ipVersion === 4) {
1237
+ const parts = host.split(".").map(Number);
1238
+ if (parts[0] === 169 && parts[1] === 254) {
1239
+ throw new ConfigError({
1240
+ message: `MCP transport: blocked link-local/IMDS address "${hostname}" \u2014 likely not a valid MCP server`,
1241
+ code: "CONFIG_INVALID",
1242
+ context: { field: "url", rawUrl, hostname }
1243
+ });
1244
+ }
1245
+ } else if (ipVersion === 6) {
1246
+ const lower = host.toLowerCase();
1247
+ const linkLocal = /^fe[89ab]/.test(lower);
1248
+ if (linkLocal || lower === "fd00:ec2::254") {
1249
+ throw new ConfigError({
1250
+ message: `MCP transport: blocked link-local/IMDS address "${hostname}" \u2014 likely not a valid MCP server`,
1251
+ code: "CONFIG_INVALID",
1252
+ context: { field: "url", rawUrl, hostname }
1253
+ });
1254
+ }
1255
+ }
1256
+ if (url.protocol === "http:") {
1257
+ const isLoopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
1258
+ if (!isLoopback) {
1259
+ throw new ConfigError({
1260
+ message: `MCP transport: http:// is only allowed for loopback addresses; use https:// for "${hostname}"`,
1261
+ code: "CONFIG_INVALID",
1262
+ context: { field: "url", rawUrl, hostname, protocol: url.protocol }
1263
+ });
1264
+ }
305
1265
  }
306
- return data;
307
1266
  }
1267
+
1268
+ // src/transport-base.ts
308
1269
  function makeAbortError(method) {
309
1270
  const err = new Error(`MCP request "${method}" aborted by client`);
310
1271
  err.name = "AbortError";
@@ -336,22 +1297,32 @@ var BaseHTTPTransport = class {
336
1297
  headers;
337
1298
  timeout;
338
1299
  requestTimeout;
1300
+ name;
1301
+ authorizationProvider;
1302
+ authorizationResource;
339
1303
  /** Per-request TLS agent — created once from HttpTransportOptions.tls */
340
1304
  tlsAgent;
341
1305
  tools = [];
1306
+ serverMetadata;
342
1307
  abortController;
343
1308
  disconnectHandlers = [];
344
1309
  toolsChangedListeners = /* @__PURE__ */ new Set();
1310
+ resourcesChangedListeners = /* @__PURE__ */ new Set();
1311
+ promptsChangedListeners = /* @__PURE__ */ new Set();
1312
+ protocolVersion;
345
1313
  constructor(opts, transportName) {
346
1314
  validateTransportUrl(opts.url);
1315
+ this.name = opts.name;
347
1316
  this.url = opts.url;
348
1317
  this.headers = { ...opts.headers };
1318
+ this.authorizationProvider = opts.authorizationProvider;
1319
+ this.authorizationResource = canonicalMcpResource(opts.url);
349
1320
  this.timeout = opts.startupTimeoutMs ?? 1e4;
350
1321
  this.requestTimeout = opts.requestTimeoutMs ?? 6e4;
351
1322
  if (opts.tls) {
352
1323
  if (opts.tls.rejectUnauthorized === false) {
353
1324
  if (!isTlsUnsafeAllowed()) {
354
- throw new ConfigError({
1325
+ throw new ConfigError2({
355
1326
  message: `[mcp:${transportName}] TLS verification disabled \u2014 set WRONGSTACK_UNSAFE_MCP_TLS=1 to allow. Rejecting insecure configuration for ${this.url}.`,
356
1327
  code: "CONFIG_INVALID",
357
1328
  context: { field: "tls.rejectUnauthorized", transportName, url: this.url }
@@ -361,7 +1332,7 @@ var BaseHTTPTransport = class {
361
1332
  `[mcp:${transportName}] \u26A0\uFE0F TLS verification DISABLED for ${this.url}. Network attacks are possible \u2014 only use on localhost.`
362
1333
  );
363
1334
  }
364
- this.tlsAgent = new https.Agent({
1335
+ this.tlsAgent = new https2.Agent({
365
1336
  ca: opts.tls.ca,
366
1337
  rejectUnauthorized: opts.tls.rejectUnauthorized
367
1338
  });
@@ -370,9 +1341,52 @@ var BaseHTTPTransport = class {
370
1341
  getState() {
371
1342
  return this.state;
372
1343
  }
1344
+ async fetchWithAuthorization(input, init, signal) {
1345
+ const context = {
1346
+ serverName: this.name,
1347
+ resource: this.authorizationResource,
1348
+ signal
1349
+ };
1350
+ const send = async () => {
1351
+ signal?.throwIfAborted();
1352
+ const headers = new Headers(init.headers);
1353
+ if (this.protocolVersion) headers.set("MCP-Protocol-Version", this.protocolVersion);
1354
+ const token = await this.authorizationProvider?.getAccessToken(context);
1355
+ signal?.throwIfAborted();
1356
+ if (token) {
1357
+ headers.set(
1358
+ "Authorization",
1359
+ authorizationHeaderForToken(token, this.authorizationResource)
1360
+ );
1361
+ }
1362
+ return fetch(input, { ...init, headers });
1363
+ };
1364
+ let response = await send();
1365
+ if (response.status !== 401 || !this.authorizationProvider?.handleUnauthorized) {
1366
+ return response;
1367
+ }
1368
+ const challenge = parseMcpBearerChallenge(
1369
+ response.headers.get("www-authenticate"),
1370
+ this.authorizationResource
1371
+ );
1372
+ const retry = await this.authorizationProvider.handleUnauthorized(challenge, context);
1373
+ if (!retry) return response;
1374
+ await response.body?.cancel().catch(() => void 0);
1375
+ response = await send();
1376
+ return response;
1377
+ }
373
1378
  listTools() {
374
1379
  return [...this.tools];
375
1380
  }
1381
+ getServerMetadata() {
1382
+ const metadata = this.serverMetadata;
1383
+ if (!metadata) return void 0;
1384
+ return {
1385
+ ...metadata,
1386
+ capabilities: { ...metadata.capabilities },
1387
+ serverInfo: { ...metadata.serverInfo }
1388
+ };
1389
+ }
376
1390
  onDisconnect(cb) {
377
1391
  this.disconnectHandlers.push(cb);
378
1392
  return () => {
@@ -386,6 +1400,14 @@ var BaseHTTPTransport = class {
386
1400
  this.toolsChangedListeners.delete(cb);
387
1401
  };
388
1402
  }
1403
+ onResourcesChanged(cb) {
1404
+ this.resourcesChangedListeners.add(cb);
1405
+ return () => this.resourcesChangedListeners.delete(cb);
1406
+ }
1407
+ onPromptsChanged(cb) {
1408
+ this.promptsChangedListeners.add(cb);
1409
+ return () => this.promptsChangedListeners.delete(cb);
1410
+ }
389
1411
  /**
390
1412
  * Fire all disconnect handlers. Subclasses call this when the connection
391
1413
  * drops so the registry can schedule reconnects.
@@ -398,6 +1420,22 @@ var BaseHTTPTransport = class {
398
1420
  }
399
1421
  }
400
1422
  }
1423
+ notifyResourcesChanged() {
1424
+ for (const cb of this.resourcesChangedListeners) {
1425
+ try {
1426
+ cb();
1427
+ } catch {
1428
+ }
1429
+ }
1430
+ }
1431
+ notifyPromptsChanged() {
1432
+ for (const cb of this.promptsChangedListeners) {
1433
+ try {
1434
+ cb();
1435
+ } catch {
1436
+ }
1437
+ }
1438
+ }
401
1439
  /**
402
1440
  * Apply the pinned TLS agent (if configured) to a `RequestInit` object.
403
1441
  * Uses `HttpDispatcher` from `@wrongstack/core`'s dispatcher-types shim,
@@ -411,6 +1449,10 @@ var BaseHTTPTransport = class {
411
1449
  }
412
1450
  }
413
1451
  };
1452
+
1453
+ // src/transport-sse.ts
1454
+ import { randomBytes as randomBytes2 } from "node:crypto";
1455
+ import { ToolError as ToolError3 } from "@wrongstack/core";
414
1456
  var SSETransport = class extends BaseHTTPTransport {
415
1457
  _nextId = 1;
416
1458
  readerDone = false;
@@ -444,6 +1486,7 @@ var SSETransport = class extends BaseHTTPTransport {
444
1486
  }
445
1487
  async connect() {
446
1488
  this.state = "connecting";
1489
+ this.serverMetadata = void 0;
447
1490
  this.abortController = new AbortController();
448
1491
  const signal = this.abortController.signal;
449
1492
  const startupTimer = setTimeout(() => this.abortController?.abort(), this.timeout);
@@ -454,9 +1497,9 @@ var SSETransport = class extends BaseHTTPTransport {
454
1497
  signal
455
1498
  };
456
1499
  this.applyTlsAgent(fetchOpts);
457
- const response = await fetch(sseUrl, fetchOpts);
1500
+ const response = await this.fetchWithAuthorization(sseUrl, fetchOpts, signal);
458
1501
  if (!response.ok) {
459
- throw new ToolError({
1502
+ throw new ToolError3({
460
1503
  message: `SSE connect HTTP ${response.status}: ${response.statusText}`,
461
1504
  code: "TOOL_EXECUTION_FAILED",
462
1505
  toolName: "mcp_transport_sse_connect",
@@ -464,7 +1507,7 @@ var SSETransport = class extends BaseHTTPTransport {
464
1507
  });
465
1508
  }
466
1509
  if (!response.body) {
467
- throw new ToolError({
1510
+ throw new ToolError3({
468
1511
  message: "SSE response has no body",
469
1512
  code: "TOOL_EXECUTION_FAILED",
470
1513
  toolName: "mcp_transport_sse_connect",
@@ -478,6 +1521,10 @@ var SSETransport = class extends BaseHTTPTransport {
478
1521
  if (msg.method && !msg.id) {
479
1522
  if (msg.method === "notifications/tools/list_changed") {
480
1523
  void this.handleToolsListChanged();
1524
+ } else if (msg.method === "notifications/resources/list_changed") {
1525
+ this.notifyResourcesChanged();
1526
+ } else if (msg.method === "notifications/prompts/list_changed") {
1527
+ this.notifyPromptsChanged();
481
1528
  }
482
1529
  }
483
1530
  });
@@ -493,13 +1540,15 @@ var SSETransport = class extends BaseHTTPTransport {
493
1540
  clientInfo: MCP_CONSTANTS.CLIENT_INFO
494
1541
  });
495
1542
  if (initRes.error) {
496
- throw new ToolError({
1543
+ throw new ToolError3({
497
1544
  message: `initialize failed: ${initRes.error.message}`,
498
1545
  code: "TOOL_EXECUTION_FAILED",
499
1546
  toolName: "mcp_transport_initialize",
500
1547
  context: { transport: "sse", url: this.url }
501
1548
  });
502
1549
  }
1550
+ this.serverMetadata = parseServerMetadata(initRes.result);
1551
+ this.protocolVersion = this.serverMetadata.protocolVersion;
503
1552
  try {
504
1553
  await this.httpPost("notifications/initialized", {});
505
1554
  } catch {
@@ -509,11 +1558,7 @@ var SSETransport = class extends BaseHTTPTransport {
509
1558
  this.tools.splice(0, this.tools.length);
510
1559
  } else {
511
1560
  const result = toolsRes.result;
512
- this.tools.splice(
513
- 0,
514
- this.tools.length,
515
- ...normalizeMCPTools(result?.tools)
516
- );
1561
+ this.tools.splice(0, this.tools.length, ...normalizeMCPTools(result?.tools));
517
1562
  }
518
1563
  this.state = "connected";
519
1564
  clearTimeout(startupTimer);
@@ -542,7 +1587,7 @@ var SSETransport = class extends BaseHTTPTransport {
542
1587
  buildSSEUrl() {
543
1588
  try {
544
1589
  const url = new URL(this.url);
545
- url.searchParams.set("session", randomBytes(16).toString("hex"));
1590
+ url.searchParams.set("session", randomBytes2(16).toString("hex"));
546
1591
  return url.toString();
547
1592
  } catch {
548
1593
  return this.url;
@@ -565,12 +1610,12 @@ var SSETransport = class extends BaseHTTPTransport {
565
1610
  };
566
1611
  this.applyTlsAgent(fetchOpts);
567
1612
  try {
568
- const res = await fetch(this.url, fetchOpts);
1613
+ const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
569
1614
  if (!res.ok) {
570
1615
  const body2 = await res.text();
571
1616
  const cap = MCP_CONSTANTS.REQUEST_LOG_CAP;
572
1617
  const snippet = body2.length > cap ? `${body2.slice(0, cap)}\u2026 [${body2.length} bytes total]` : body2;
573
- throw new ToolError({
1618
+ throw new ToolError3({
574
1619
  message: `HTTP ${res.status}: ${snippet}`,
575
1620
  code: "TOOL_EXECUTION_FAILED",
576
1621
  toolName: method,
@@ -581,7 +1626,7 @@ var SSETransport = class extends BaseHTTPTransport {
581
1626
  try {
582
1627
  data = await res.json();
583
1628
  } catch (err) {
584
- throw new ToolError({
1629
+ throw new ToolError3({
585
1630
  message: `Invalid JSON-RPC response: ${err instanceof Error ? err.message : "parse failed"}`,
586
1631
  code: "TOOL_EXECUTION_FAILED",
587
1632
  toolName: method,
@@ -606,7 +1651,7 @@ var SSETransport = class extends BaseHTTPTransport {
606
1651
  }
607
1652
  async callTool(name, input, opts) {
608
1653
  if (this.state !== "connected") {
609
- throw new ToolError({
1654
+ throw new ToolError3({
610
1655
  message: `SSE transport not connected (state=${this.state})`,
611
1656
  code: "TOOL_EXECUTION_FAILED",
612
1657
  toolName: name,
@@ -624,13 +1669,12 @@ var SSETransport = class extends BaseHTTPTransport {
624
1669
  };
625
1670
  }
626
1671
  /** Generic JSON-RPC request — used by MCPClient.request() for SSE transports. */
627
- async request(method, params, timeoutMs) {
1672
+ async request(method, params, timeoutMs, opts) {
628
1673
  const id = this.genId();
629
1674
  const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
630
- const timeoutSignal = createTimeoutSignal(
631
- this.abortController?.signal,
632
- timeoutMs ?? this.requestTimeout
633
- );
1675
+ const external = opts?.signal;
1676
+ const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
1677
+ const timeoutSignal = createTimeoutSignal(parent, timeoutMs ?? this.requestTimeout);
634
1678
  const fetchOpts = {
635
1679
  method: "POST",
636
1680
  headers: {
@@ -642,9 +1686,9 @@ var SSETransport = class extends BaseHTTPTransport {
642
1686
  };
643
1687
  this.applyTlsAgent(fetchOpts);
644
1688
  try {
645
- const res = await fetch(this.url, fetchOpts);
1689
+ const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
646
1690
  if (!res.ok) {
647
- throw new ToolError({
1691
+ throw new ToolError3({
648
1692
  message: `HTTP ${res.status}: ${res.statusText}`,
649
1693
  code: "TOOL_EXECUTION_FAILED",
650
1694
  toolName: method,
@@ -660,7 +1704,7 @@ var SSETransport = class extends BaseHTTPTransport {
660
1704
  try {
661
1705
  data = await res.json();
662
1706
  } catch (err) {
663
- throw new ToolError({
1707
+ throw new ToolError3({
664
1708
  message: `Invalid JSON-RPC response: ${err instanceof Error ? err.message : "parse failed"}`,
665
1709
  code: "TOOL_EXECUTION_FAILED",
666
1710
  toolName: method,
@@ -670,6 +1714,16 @@ var SSETransport = class extends BaseHTTPTransport {
670
1714
  }
671
1715
  const result = assertMatchingJsonRpcResult(data, id, method);
672
1716
  return { jsonrpc: "2.0", id, result: result.result, error: result.error };
1717
+ } catch (err) {
1718
+ if (external?.aborted && !method.startsWith("notifications/")) {
1719
+ void this.httpPost("notifications/cancelled", {
1720
+ requestId: id,
1721
+ reason: "client aborted"
1722
+ }).catch(() => {
1723
+ });
1724
+ throw makeAbortError(method);
1725
+ }
1726
+ throw err;
673
1727
  } finally {
674
1728
  timeoutSignal.dispose();
675
1729
  }
@@ -691,6 +1745,8 @@ var SSETransport = class extends BaseHTTPTransport {
691
1745
  this.state = "disconnected";
692
1746
  }
693
1747
  };
1748
+
1749
+ // src/transport-streamable.ts
694
1750
  var StreamableHTTPTransport = class extends BaseHTTPTransport {
695
1751
  _nextId = 1;
696
1752
  sessionId;
@@ -700,13 +1756,50 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
700
1756
  genId() {
701
1757
  return this._nextId++;
702
1758
  }
703
- async connect() {
704
- this.state = "connecting";
705
- this.abortController = new AbortController();
706
- const signal = this.abortController.signal;
707
- const startupTimer = setTimeout(() => this.abortController?.abort(), this.timeout);
1759
+ consumeResponseText(text, requestId) {
1760
+ const envelopes = extractJsonRpcEnvelopes(text);
1761
+ for (const envelope of envelopes) {
1762
+ if ("method" in envelope && envelope.id === void 0) {
1763
+ this.handleNotification(envelope.method);
1764
+ }
1765
+ }
1766
+ const responses = envelopes.filter(isJsonRpcResult);
1767
+ return responses.find((envelope) => envelope.id === requestId) ?? responses.find((envelope) => envelope.id !== void 0) ?? responses[0];
1768
+ }
1769
+ handleNotification(method) {
1770
+ if (method === "notifications/resources/list_changed") {
1771
+ this.notifyResourcesChanged();
1772
+ } else if (method === "notifications/prompts/list_changed") {
1773
+ this.notifyPromptsChanged();
1774
+ } else if (method === "notifications/tools/list_changed") {
1775
+ void this.refreshTools();
1776
+ }
1777
+ }
1778
+ async refreshTools() {
708
1779
  try {
709
- const initFetchOpts = {
1780
+ const response = await this.postRaw("tools/list", {});
1781
+ if (response.error) return;
1782
+ const tools = normalizeMCPTools(
1783
+ response.result?.tools
1784
+ );
1785
+ this.tools.splice(0, this.tools.length, ...tools);
1786
+ for (const listener of this.toolsChangedListeners) {
1787
+ try {
1788
+ listener([...tools]);
1789
+ } catch {
1790
+ }
1791
+ }
1792
+ } catch {
1793
+ }
1794
+ }
1795
+ async connect() {
1796
+ this.state = "connecting";
1797
+ this.serverMetadata = void 0;
1798
+ this.abortController = new AbortController();
1799
+ const signal = this.abortController.signal;
1800
+ const startupTimer = setTimeout(() => this.abortController?.abort(), this.timeout);
1801
+ try {
1802
+ const initFetchOpts = {
710
1803
  method: "POST",
711
1804
  headers: {
712
1805
  "Content-Type": "application/json",
@@ -726,7 +1819,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
726
1819
  signal
727
1820
  };
728
1821
  this.applyTlsAgent(initFetchOpts);
729
- const initRes = await fetch(this.url, initFetchOpts);
1822
+ const initRes = await this.fetchWithAuthorization(this.url, initFetchOpts, signal);
730
1823
  if (!initRes.ok) {
731
1824
  throw new Error(`initialize HTTP ${initRes.status}: ${initRes.statusText}`);
732
1825
  }
@@ -745,6 +1838,8 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
745
1838
  if (data.error) {
746
1839
  throw new Error(`initialize failed: ${data.error.message}`);
747
1840
  }
1841
+ this.serverMetadata = parseServerMetadata(data.result);
1842
+ this.protocolVersion = this.serverMetadata.protocolVersion;
748
1843
  this.sessionId = initRes.headers.get("mcp-session-id") ?? void 0;
749
1844
  await this.postRaw("notifications/initialized", {});
750
1845
  const toolsRes = await this.postRaw("tools/list", {});
@@ -782,7 +1877,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
782
1877
  };
783
1878
  this.applyTlsAgent(fetchOpts);
784
1879
  try {
785
- const res = await fetch(this.url, fetchOpts);
1880
+ const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
786
1881
  if (!res.ok) {
787
1882
  throw new Error(`HTTP ${res.status}: ${res.statusText}`);
788
1883
  }
@@ -790,7 +1885,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
790
1885
  await res.text().catch(() => void 0);
791
1886
  return { jsonrpc: "2.0" };
792
1887
  }
793
- const match = pickJsonRpcResult(await res.text(), id);
1888
+ const match = this.consumeResponseText(await res.text(), id);
794
1889
  if (match) {
795
1890
  return assertMatchingJsonRpcResult(match, id, method);
796
1891
  }
@@ -810,13 +1905,12 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
810
1905
  }
811
1906
  }
812
1907
  /** Generic JSON-RPC request — used by MCPClient.request() for SSE/streamable-http transports. */
813
- async request(method, params, timeoutMs) {
1908
+ async request(method, params, timeoutMs, opts) {
814
1909
  const id = this.genId();
815
1910
  const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
816
- const timeoutSignal = createTimeoutSignal(
817
- this.abortController?.signal,
818
- timeoutMs ?? this.requestTimeout
819
- );
1911
+ const external = opts?.signal;
1912
+ const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
1913
+ const timeoutSignal = createTimeoutSignal(parent, timeoutMs ?? this.requestTimeout);
820
1914
  const fetchOpts = {
821
1915
  method: "POST",
822
1916
  headers: {
@@ -829,8 +1923,8 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
829
1923
  signal: timeoutSignal.signal
830
1924
  };
831
1925
  this.applyTlsAgent(fetchOpts);
832
- const res = await fetch(this.url, fetchOpts);
833
1926
  try {
1927
+ const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
834
1928
  if (!res.ok) {
835
1929
  throw new Error(`HTTP ${res.status}: ${res.statusText}`);
836
1930
  }
@@ -838,7 +1932,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
838
1932
  await res.text().catch(() => void 0);
839
1933
  return { jsonrpc: "2.0", id };
840
1934
  }
841
- const parsed = pickJsonRpcResult(await res.text(), id);
1935
+ const parsed = this.consumeResponseText(await res.text(), id);
842
1936
  if (parsed) {
843
1937
  return {
844
1938
  jsonrpc: "2.0",
@@ -848,6 +1942,16 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
848
1942
  };
849
1943
  }
850
1944
  throw new Error("Could not parse response as JSON-RPC");
1945
+ } catch (err) {
1946
+ if (external?.aborted && !method.startsWith("notifications/")) {
1947
+ void this.postRaw("notifications/cancelled", {
1948
+ requestId: id,
1949
+ reason: "client aborted"
1950
+ }).catch(() => {
1951
+ });
1952
+ throw makeAbortError(method);
1953
+ }
1954
+ throw err;
851
1955
  } finally {
852
1956
  timeoutSignal.dispose();
853
1957
  }
@@ -875,6 +1979,18 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
875
1979
  };
876
1980
 
877
1981
  // src/client.ts
1982
+ function isJsonRpcResponse(value) {
1983
+ if (typeof value !== "object" || value === null) return false;
1984
+ const response = value;
1985
+ if (response["jsonrpc"] !== "2.0" || typeof response["id"] !== "number") return false;
1986
+ if (Object.hasOwn(response, "method")) return false;
1987
+ const hasResult = Object.hasOwn(response, "result");
1988
+ const hasError = Object.hasOwn(response, "error");
1989
+ if (hasResult === hasError) return false;
1990
+ if (!hasError) return true;
1991
+ const error = response["error"];
1992
+ return typeof error === "object" && error !== null && typeof error["code"] === "number" && typeof error["message"] === "string";
1993
+ }
878
1994
  var MCPClient = class {
879
1995
  constructor(opts) {
880
1996
  this.opts = opts;
@@ -891,6 +2007,8 @@ var MCPClient = class {
891
2007
  pending = /* @__PURE__ */ new Map();
892
2008
  rxBuffer = "";
893
2009
  _tools = [];
2010
+ /** Server-declared handshake metadata. Populated for stdio in the first protocol slice. */
2011
+ _serverMetadata;
894
2012
  /** Cached tool list — survives reconnects so the registry can re-register without re-discovering. */
895
2013
  _toolsCache;
896
2014
  _drainPending = false;
@@ -902,11 +2020,22 @@ var MCPClient = class {
902
2020
  exitListeners = /* @__PURE__ */ new Set();
903
2021
  /** Notified when the server announces a tools/list_changed notification. */
904
2022
  toolsChangedListeners = /* @__PURE__ */ new Set();
2023
+ resourcesChangedListeners = /* @__PURE__ */ new Set();
2024
+ promptsChangedListeners = /* @__PURE__ */ new Set();
905
2025
  /** Notified when an HTTP transport (SSE or streamable-http) disconnects. */
906
2026
  disconnectListeners = /* @__PURE__ */ new Set();
907
2027
  getState() {
908
2028
  return this.state;
909
2029
  }
2030
+ getServerMetadata() {
2031
+ const metadata = this._serverMetadata;
2032
+ if (!metadata) return void 0;
2033
+ return {
2034
+ ...metadata,
2035
+ capabilities: { ...metadata.capabilities },
2036
+ serverInfo: { ...metadata.serverInfo }
2037
+ };
2038
+ }
910
2039
  listTools() {
911
2040
  return this._tools.length > 0 ? [...this._tools] : this._toolsCache ? [...this._toolsCache] : [];
912
2041
  }
@@ -936,6 +2065,7 @@ var MCPClient = class {
936
2065
  }
937
2066
  async connect() {
938
2067
  this.state = "connecting";
2068
+ this._serverMetadata = void 0;
939
2069
  if (this.opts.transport === "stdio") {
940
2070
  await this.connectStdio();
941
2071
  } else if (this.opts.transport === "sse") {
@@ -1007,6 +2137,12 @@ var MCPClient = class {
1007
2137
  this.state = "failed";
1008
2138
  throw new Error(`MCP initialize failed: ${initialize.error.message}`);
1009
2139
  }
2140
+ try {
2141
+ this._serverMetadata = parseServerMetadata(initialize.result);
2142
+ } catch (err) {
2143
+ this.state = "failed";
2144
+ throw new Error(`MCP initialize returned malformed server metadata: ${toErrorMessage(err)}`);
2145
+ }
1010
2146
  try {
1011
2147
  await this.notify("notifications/initialized", {});
1012
2148
  } catch (err) {
@@ -1034,7 +2170,8 @@ var MCPClient = class {
1034
2170
  url: this.opts.url,
1035
2171
  headers: this.opts.headers,
1036
2172
  startupTimeoutMs: this.opts.startupTimeoutMs,
1037
- requestTimeoutMs: this.opts.requestTimeoutMs
2173
+ requestTimeoutMs: this.opts.requestTimeoutMs,
2174
+ authorizationProvider: this.opts.authorizationProvider
1038
2175
  };
1039
2176
  this.sseTransport = new SSETransport(httpOpts);
1040
2177
  this.sseTransport.onDisconnect(() => {
@@ -1056,6 +2193,8 @@ var MCPClient = class {
1056
2193
  }
1057
2194
  }
1058
2195
  });
2196
+ this.sseTransport.onResourcesChanged(() => this.emitCapabilityChanged("resources"));
2197
+ this.sseTransport.onPromptsChanged(() => this.emitCapabilityChanged("prompts"));
1059
2198
  try {
1060
2199
  await this.sseTransport.connect();
1061
2200
  } catch (err) {
@@ -1068,6 +2207,7 @@ var MCPClient = class {
1068
2207
  }
1069
2208
  this._tools = this.sseTransport.listTools();
1070
2209
  this._toolsCache = this._tools;
2210
+ this._serverMetadata = this.sseTransport.getServerMetadata();
1071
2211
  this.state = "connected";
1072
2212
  }
1073
2213
  async connectStreamableHTTP() {
@@ -1080,7 +2220,8 @@ var MCPClient = class {
1080
2220
  url: this.opts.url,
1081
2221
  headers: this.opts.headers,
1082
2222
  startupTimeoutMs: this.opts.startupTimeoutMs,
1083
- requestTimeoutMs: this.opts.requestTimeoutMs
2223
+ requestTimeoutMs: this.opts.requestTimeoutMs,
2224
+ authorizationProvider: this.opts.authorizationProvider
1084
2225
  };
1085
2226
  this.httpTransport = new StreamableHTTPTransport(httpOpts);
1086
2227
  this.httpTransport.onDisconnect(() => {
@@ -1102,6 +2243,8 @@ var MCPClient = class {
1102
2243
  }
1103
2244
  }
1104
2245
  });
2246
+ this.httpTransport.onResourcesChanged(() => this.emitCapabilityChanged("resources"));
2247
+ this.httpTransport.onPromptsChanged(() => this.emitCapabilityChanged("prompts"));
1105
2248
  try {
1106
2249
  await this.httpTransport.connect();
1107
2250
  } catch (err) {
@@ -1114,6 +2257,7 @@ var MCPClient = class {
1114
2257
  }
1115
2258
  this._tools = this.httpTransport.listTools();
1116
2259
  this._toolsCache = this._tools;
2260
+ this._serverMetadata = this.httpTransport.getServerMetadata();
1117
2261
  this.state = "connected";
1118
2262
  }
1119
2263
  async callTool(name, input, opts) {
@@ -1136,6 +2280,79 @@ var MCPClient = class {
1136
2280
  isError: Boolean(result?.isError)
1137
2281
  };
1138
2282
  }
2283
+ async listResources(opts = {}) {
2284
+ const params = pageParams(opts.cursor, "resources/list cursor");
2285
+ return this.requestCapability(
2286
+ "resources",
2287
+ "resources/list",
2288
+ params,
2289
+ parseListResourcesResult,
2290
+ opts
2291
+ );
2292
+ }
2293
+ async listResourceTemplates(opts = {}) {
2294
+ const params = pageParams(opts.cursor, "resources/templates/list cursor");
2295
+ return this.requestCapability(
2296
+ "resources",
2297
+ "resources/templates/list",
2298
+ params,
2299
+ parseListResourceTemplatesResult,
2300
+ opts
2301
+ );
2302
+ }
2303
+ async readResource(uri, opts = {}) {
2304
+ validateProtocolString(uri, "resource URI");
2305
+ return this.requestCapability(
2306
+ "resources",
2307
+ "resources/read",
2308
+ { uri },
2309
+ parseReadResourceResult,
2310
+ opts
2311
+ );
2312
+ }
2313
+ async subscribeResource(uri, opts = {}) {
2314
+ validateProtocolString(uri, "resource URI");
2315
+ this.requireResourceSubscriptions("resources/subscribe");
2316
+ await this.requestCapability(
2317
+ "resources",
2318
+ "resources/subscribe",
2319
+ { uri },
2320
+ parseEmptyResult,
2321
+ opts
2322
+ );
2323
+ }
2324
+ async unsubscribeResource(uri, opts = {}) {
2325
+ validateProtocolString(uri, "resource URI");
2326
+ this.requireResourceSubscriptions("resources/unsubscribe");
2327
+ await this.requestCapability(
2328
+ "resources",
2329
+ "resources/unsubscribe",
2330
+ { uri },
2331
+ parseEmptyResult,
2332
+ opts
2333
+ );
2334
+ }
2335
+ async listPrompts(opts = {}) {
2336
+ const params = pageParams(opts.cursor, "prompts/list cursor");
2337
+ return this.requestCapability("prompts", "prompts/list", params, parseListPromptsResult, opts);
2338
+ }
2339
+ async getPrompt(name, args, opts = {}) {
2340
+ validateProtocolString(name, "prompt name");
2341
+ if (args && Object.keys(args).length > 64) {
2342
+ throw new Error("MCP prompt arguments exceed the limit of 64");
2343
+ }
2344
+ for (const [key, value] of Object.entries(args ?? {})) {
2345
+ validateProtocolString(key, "prompt argument name");
2346
+ validateProtocolString(value, `prompt argument "${key}"`, true);
2347
+ }
2348
+ return this.requestCapability(
2349
+ "prompts",
2350
+ "prompts/get",
2351
+ args === void 0 ? { name } : { name, arguments: args },
2352
+ parseGetPromptResult,
2353
+ opts
2354
+ );
2355
+ }
1139
2356
  async close() {
1140
2357
  if (this.child) {
1141
2358
  const child = this.child;
@@ -1170,8 +2387,8 @@ var MCPClient = class {
1170
2387
  this.state = "disconnected";
1171
2388
  }
1172
2389
  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);
2390
+ if (this.sseTransport) return this.sseTransport.request(method, params, timeoutMs, opts);
2391
+ if (this.httpTransport) return this.httpTransport.request(method, params, timeoutMs, opts);
1175
2392
  const signal = opts?.signal;
1176
2393
  if (signal?.aborted) {
1177
2394
  const err = new Error(`MCP "${this.opts.name}" request "${method}" aborted before send`);
@@ -1238,6 +2455,37 @@ var MCPClient = class {
1238
2455
  }
1239
2456
  });
1240
2457
  }
2458
+ async requestCapability(capability, method, params, parse, opts) {
2459
+ if (this.state !== "connected") {
2460
+ throw new Error(`MCP client "${this.opts.name}" not connected (state=${this.state})`);
2461
+ }
2462
+ const metadata = this._serverMetadata;
2463
+ if (!metadata) {
2464
+ throw new Error(
2465
+ `MCP server "${this.opts.name}" capability metadata is unavailable for ${method}`
2466
+ );
2467
+ }
2468
+ if (!metadata.capabilities[capability]) {
2469
+ throw new Error(
2470
+ `MCP server "${this.opts.name}" does not advertise the ${capability} capability`
2471
+ );
2472
+ }
2473
+ const response = await this.request(method, params, void 0, opts);
2474
+ if (response.error) {
2475
+ throw new Error(`MCP ${method} failed: ${response.error.message}`);
2476
+ }
2477
+ return parse(response.result);
2478
+ }
2479
+ requireResourceSubscriptions(method) {
2480
+ if (this.state !== "connected") {
2481
+ throw new Error(`MCP client "${this.opts.name}" not connected (state=${this.state})`);
2482
+ }
2483
+ if (this._serverMetadata?.capabilities.resources?.subscribe !== true) {
2484
+ throw new Error(
2485
+ `MCP server "${this.opts.name}" does not advertise resource subscriptions for ${method}`
2486
+ );
2487
+ }
2488
+ }
1241
2489
  /**
1242
2490
  * Reject every in-flight {@link request} call. Used when the underlying
1243
2491
  * transport dies — without this, callers awaiting `tools/call` over a
@@ -1305,68 +2553,702 @@ var MCPClient = class {
1305
2553
  throw new Error(`[MCP] notify("${method}") failed: ${toErrorMessage(err)}`);
1306
2554
  }
1307
2555
  }
1308
- onData(s) {
1309
- this.rxBuffer += s;
1310
- let idx = this.rxBuffer.indexOf("\n");
1311
- while (idx !== -1) {
1312
- const line = this.rxBuffer.slice(0, idx).trim();
1313
- this.rxBuffer = this.rxBuffer.slice(idx + 1);
1314
- if (line) this.onLine(line);
1315
- idx = this.rxBuffer.indexOf("\n");
1316
- }
2556
+ onData(s) {
2557
+ this.rxBuffer += s;
2558
+ let idx = this.rxBuffer.indexOf("\n");
2559
+ while (idx !== -1) {
2560
+ const line = this.rxBuffer.slice(0, idx).trim();
2561
+ this.rxBuffer = this.rxBuffer.slice(idx + 1);
2562
+ if (line) this.onLine(line);
2563
+ idx = this.rxBuffer.indexOf("\n");
2564
+ }
2565
+ }
2566
+ onLine(line) {
2567
+ let msg;
2568
+ try {
2569
+ msg = JSON.parse(line);
2570
+ } catch {
2571
+ return;
2572
+ }
2573
+ if (typeof msg !== "object" || msg === null) return;
2574
+ const envelope = msg;
2575
+ if (envelope["jsonrpc"] !== "2.0") return;
2576
+ if (typeof envelope["method"] === "string") {
2577
+ const id = envelope["id"];
2578
+ if (typeof id === "number" || typeof id === "string") {
2579
+ this.handleServerRequest({
2580
+ jsonrpc: "2.0",
2581
+ id,
2582
+ method: envelope["method"],
2583
+ params: envelope["params"]
2584
+ });
2585
+ return;
2586
+ }
2587
+ if (Object.hasOwn(envelope, "id")) return;
2588
+ if (envelope["method"] === "notifications/tools/list_changed") {
2589
+ void this.handleToolsListChanged();
2590
+ } else if (envelope["method"] === "notifications/resources/list_changed") {
2591
+ this.emitCapabilityChanged("resources");
2592
+ } else if (envelope["method"] === "notifications/prompts/list_changed") {
2593
+ this.emitCapabilityChanged("prompts");
2594
+ }
2595
+ return;
2596
+ }
2597
+ if (!isJsonRpcResponse(msg)) return;
2598
+ if (this.pending.has(msg.id)) {
2599
+ const entry = this.pending.get(msg.id);
2600
+ this.pending.delete(msg.id);
2601
+ entry?.resolve(msg);
2602
+ }
2603
+ }
2604
+ handleServerRequest(request3) {
2605
+ const message = request3.method === "sampling/createMessage" ? "Client sampling is disabled by policy" : `Method not found: ${request3.method}`;
2606
+ const response = {
2607
+ jsonrpc: "2.0",
2608
+ id: request3.id,
2609
+ error: { code: -32601, message }
2610
+ };
2611
+ try {
2612
+ this.child?.stdin?.write(`${JSON.stringify(response)}
2613
+ `);
2614
+ } catch {
2615
+ }
2616
+ }
2617
+ /**
2618
+ * L2-C: refresh the cached tool list when the server announces a
2619
+ * `tools/list_changed`. Listeners (the registry) re-wrap and
2620
+ * re-register. Failures are swallowed — a stale cache is preferable
2621
+ * to a hard crash on a transient notification glitch.
2622
+ */
2623
+ async handleToolsListChanged() {
2624
+ try {
2625
+ const toolsRes = await this.request("tools/list", {});
2626
+ const tools = normalizeMCPTools(
2627
+ toolsRes.result?.tools
2628
+ );
2629
+ this._tools = tools;
2630
+ this._toolsCache = tools;
2631
+ for (const listener of this.toolsChangedListeners) {
2632
+ try {
2633
+ listener(this.opts.name, [...tools]);
2634
+ } catch {
2635
+ }
2636
+ }
2637
+ } catch {
2638
+ }
2639
+ }
2640
+ addToolsChangedListener(listener) {
2641
+ this.toolsChangedListeners.add(listener);
2642
+ }
2643
+ removeToolsChangedListener(listener) {
2644
+ this.toolsChangedListeners.delete(listener);
2645
+ }
2646
+ addResourcesChangedListener(listener) {
2647
+ this.resourcesChangedListeners.add(listener);
2648
+ }
2649
+ removeResourcesChangedListener(listener) {
2650
+ this.resourcesChangedListeners.delete(listener);
2651
+ }
2652
+ addPromptsChangedListener(listener) {
2653
+ this.promptsChangedListeners.add(listener);
2654
+ }
2655
+ removePromptsChangedListener(listener) {
2656
+ this.promptsChangedListeners.delete(listener);
2657
+ }
2658
+ emitCapabilityChanged(capability) {
2659
+ const listeners = capability === "resources" ? this.resourcesChangedListeners : this.promptsChangedListeners;
2660
+ for (const listener of listeners) {
2661
+ try {
2662
+ listener(this.opts.name);
2663
+ } catch {
2664
+ }
2665
+ }
2666
+ }
2667
+ };
2668
+ function quoteWindowsArg(arg) {
2669
+ if (!/[\s"]/.test(arg)) return arg;
2670
+ return `"${arg.replace(/"/g, '""')}"`;
2671
+ }
2672
+ var MAX_PROTOCOL_INPUT_CHARS = 8192;
2673
+ function validateProtocolString(value, label, allowEmpty = false) {
2674
+ if (typeof value !== "string" || !allowEmpty && value.length === 0) {
2675
+ throw new Error(`MCP ${label} must be ${allowEmpty ? "a string" : "a non-empty string"}`);
2676
+ }
2677
+ if (value.length > MAX_PROTOCOL_INPUT_CHARS) {
2678
+ throw new Error(`MCP ${label} exceeds ${MAX_PROTOCOL_INPUT_CHARS} characters`);
2679
+ }
2680
+ }
2681
+ function pageParams(cursor, label) {
2682
+ if (cursor === void 0) return {};
2683
+ validateProtocolString(cursor, label);
2684
+ return { cursor };
2685
+ }
2686
+ function parseEmptyResult(value) {
2687
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2688
+ throw new Error("Malformed MCP empty result: expected object");
2689
+ }
2690
+ }
2691
+
2692
+ // src/content-selection.ts
2693
+ var DEFAULT_MCP_INSERTION_MAX_BYTES = 256 * 1024;
2694
+ var DEFAULT_MCP_RESOURCE_SCHEMES = [
2695
+ "file",
2696
+ "git",
2697
+ "http",
2698
+ "https",
2699
+ "mcp",
2700
+ "mem",
2701
+ "repo",
2702
+ "resource"
2703
+ ];
2704
+ function prepareResourceInsertion(serverName, requestedUri, result, policy = {}) {
2705
+ requireIdentity(serverName, "server name");
2706
+ validateUri(requestedUri, policy);
2707
+ if (result.contents.length > 64) {
2708
+ throw new Error("MCP resource insertion exceeds the limit of 64 content blocks");
2709
+ }
2710
+ let byteSize = 0;
2711
+ for (const content of result.contents) {
2712
+ validateUri(content.uri, policy);
2713
+ if (content.text !== void 0) byteSize += utf8Bytes(content.text);
2714
+ if (content.blob !== void 0) byteSize += base64DecodedBytes(content.blob);
2715
+ enforceSize(byteSize, policy);
2716
+ }
2717
+ return {
2718
+ kind: "resource",
2719
+ untrusted: true,
2720
+ byteSize,
2721
+ provenance: {
2722
+ origin: "mcp",
2723
+ serverName,
2724
+ capability: "resource",
2725
+ resourceUri: requestedUri
2726
+ },
2727
+ contents: structuredClone(result.contents)
2728
+ };
2729
+ }
2730
+ function preparePromptInsertion(serverName, promptName, args, result, policy = {}) {
2731
+ requireIdentity(serverName, "server name");
2732
+ requireIdentity(promptName, "prompt name");
2733
+ if (result.messages.length > 128) {
2734
+ throw new Error("MCP prompt insertion exceeds the limit of 128 messages");
2735
+ }
2736
+ for (const message of result.messages) validateEmbeddedUris(message.content, policy, 0);
2737
+ let serialized;
2738
+ try {
2739
+ serialized = JSON.stringify(result.messages);
2740
+ } catch {
2741
+ throw new Error("MCP prompt insertion contains non-serializable content");
2742
+ }
2743
+ const byteSize = utf8Bytes(serialized);
2744
+ enforceSize(byteSize, policy);
2745
+ return {
2746
+ kind: "prompt",
2747
+ untrusted: true,
2748
+ byteSize,
2749
+ provenance: {
2750
+ origin: "mcp",
2751
+ serverName,
2752
+ capability: "prompt",
2753
+ promptName,
2754
+ promptArgumentNames: Object.keys(args ?? {}).sort()
2755
+ },
2756
+ description: result.description,
2757
+ messages: structuredClone(result.messages)
2758
+ };
2759
+ }
2760
+ function validateEmbeddedUris(value, policy, depth) {
2761
+ if (depth > 32) throw new Error("MCP prompt insertion exceeds the nesting depth limit");
2762
+ if (Array.isArray(value)) {
2763
+ for (const item of value) validateEmbeddedUris(item, policy, depth + 1);
2764
+ return;
2765
+ }
2766
+ if (!value || typeof value !== "object") return;
2767
+ for (const [key, nested] of Object.entries(value)) {
2768
+ if (key === "uri" && typeof nested === "string") validateUri(nested, policy);
2769
+ validateEmbeddedUris(nested, policy, depth + 1);
2770
+ }
2771
+ }
2772
+ function validateUri(uri, policy) {
2773
+ if (uri.length === 0 || uri.length > 8192) {
2774
+ throw new Error("MCP insertion URI must contain 1\u20138192 characters");
2775
+ }
2776
+ let parsed;
2777
+ try {
2778
+ parsed = new URL(uri);
2779
+ } catch {
2780
+ throw new Error("MCP insertion URI must be absolute");
2781
+ }
2782
+ const scheme = parsed.protocol.slice(0, -1).toLowerCase();
2783
+ const allowed = new Set(
2784
+ (policy.allowedUriSchemes ?? DEFAULT_MCP_RESOURCE_SCHEMES).map((value) => value.toLowerCase())
2785
+ );
2786
+ if (!allowed.has(scheme)) {
2787
+ throw new Error(`MCP insertion URI scheme "${scheme}" is not allowed`);
2788
+ }
2789
+ if ((scheme === "http" || scheme === "https") && (parsed.username || parsed.password)) {
2790
+ throw new Error("MCP insertion URI must not contain credentials");
2791
+ }
2792
+ }
2793
+ function enforceSize(byteSize, policy) {
2794
+ const maxBytes = policy.maxBytes ?? DEFAULT_MCP_INSERTION_MAX_BYTES;
2795
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
2796
+ throw new Error("MCP insertion maxBytes must be a positive safe integer");
2797
+ }
2798
+ if (byteSize > maxBytes) {
2799
+ throw new Error(`MCP insertion exceeds the ${maxBytes}-byte content limit`);
2800
+ }
2801
+ }
2802
+ function base64DecodedBytes(blob) {
2803
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(blob)) {
2804
+ throw new Error("MCP resource insertion contains invalid base64 content");
2805
+ }
2806
+ const padding = blob.endsWith("==") ? 2 : blob.endsWith("=") ? 1 : 0;
2807
+ return blob.length / 4 * 3 - padding;
2808
+ }
2809
+ function utf8Bytes(value) {
2810
+ return new TextEncoder().encode(value).byteLength;
2811
+ }
2812
+ function requireIdentity(value, label) {
2813
+ if (value.length === 0 || value.length > 256) {
2814
+ throw new Error(`MCP insertion ${label} must contain 1\u2013256 characters`);
2815
+ }
2816
+ }
2817
+
2818
+ // src/manage.ts
2819
+ import { randomBytes as randomBytes3 } from "node:crypto";
2820
+ import * as fs from "node:fs/promises";
2821
+ async function readConfig(path2) {
2822
+ try {
2823
+ return JSON.parse(await fs.readFile(path2, "utf8"));
2824
+ } catch {
2825
+ return {};
2826
+ }
2827
+ }
2828
+ async function writeConfig(path2, cfg) {
2829
+ const raw = JSON.stringify(cfg, null, 2);
2830
+ const tmp = `${path2}.${process.pid}.${randomBytes3(6).toString("hex")}.tmp`;
2831
+ await fs.writeFile(tmp, raw, "utf8");
2832
+ try {
2833
+ await fs.rename(tmp, path2);
2834
+ } catch (err) {
2835
+ await fs.rm(tmp, { force: true }).catch(() => void 0);
2836
+ throw err;
2837
+ }
2838
+ }
2839
+ function isMcpServerRecord(value) {
2840
+ return !!value && typeof value === "object" && !Array.isArray(value);
2841
+ }
2842
+ async function readServers(configPath) {
2843
+ const full = await readConfig(configPath);
2844
+ const servers = isMcpServerRecord(full.mcpServers) ? { ...full.mcpServers } : {};
2845
+ return { full, servers };
2846
+ }
2847
+ async function persist(configPath, full, servers) {
2848
+ full.mcpServers = servers;
2849
+ await writeConfig(configPath, full);
2850
+ }
2851
+ function normalizeTransport(t) {
2852
+ if (t === "sse") return "sse";
2853
+ if (t === "http" || t === "streamable-http") return "streamable-http";
2854
+ return "stdio";
2855
+ }
2856
+ function buildConfig(input, base) {
2857
+ const cfg = {
2858
+ name: input.name,
2859
+ transport: input.transport ? normalizeTransport(String(input.transport)) : base?.transport ?? "stdio"
2860
+ };
2861
+ const description = input.description ?? base?.description;
2862
+ if (description !== void 0) cfg.description = description;
2863
+ const command = input.command ?? base?.command;
2864
+ if (command !== void 0) cfg.command = command;
2865
+ const args = input.args ?? base?.args;
2866
+ if (args !== void 0) cfg.args = args;
2867
+ const env = input.env ?? base?.env;
2868
+ if (env !== void 0) cfg.env = env;
2869
+ const url = input.url ?? base?.url;
2870
+ if (url !== void 0) cfg.url = url;
2871
+ const headers = input.headers ?? base?.headers;
2872
+ if (headers !== void 0) cfg.headers = headers;
2873
+ const allowedTools = input.allowedTools ?? base?.allowedTools;
2874
+ if (allowedTools !== void 0) cfg.allowedTools = allowedTools;
2875
+ const permission = input.permission ?? base?.permission;
2876
+ if (permission !== void 0) cfg.permission = permission;
2877
+ const enabled = input.enabled ?? base?.enabled;
2878
+ if (enabled !== void 0) cfg.enabled = enabled;
2879
+ const lazy = input.lazy ?? base?.lazy;
2880
+ if (lazy !== void 0) cfg.lazy = lazy;
2881
+ const passthroughEnv = input.passthroughEnv ?? base?.passthroughEnv;
2882
+ if (passthroughEnv !== void 0) cfg.passthroughEnv = passthroughEnv;
2883
+ const health = input.health ?? base?.health;
2884
+ if (health !== void 0) cfg.health = health;
2885
+ return cfg;
2886
+ }
2887
+ function projectServer(name, cfg, registry) {
2888
+ const live = registry.list().find((s) => s.name === name);
2889
+ const info = {
2890
+ name,
2891
+ transport: cfg.transport,
2892
+ enabled: cfg.enabled !== false,
2893
+ status: live ? live.state : "stopped",
2894
+ tools: live?.tools ?? []
2895
+ };
2896
+ if (cfg.description !== void 0) info.description = cfg.description;
2897
+ if (cfg.url !== void 0) info.url = cfg.url;
2898
+ if (cfg.command !== void 0) info.command = cfg.command;
2899
+ if (cfg.lazy !== void 0) info.lazy = cfg.lazy;
2900
+ return info;
2901
+ }
2902
+ function liveState(name, registry) {
2903
+ const live = registry.list().find((s) => s.name === name);
2904
+ return { state: live?.state ?? "stopped", tools: live?.tools ?? [] };
2905
+ }
2906
+ function errMessage(err) {
2907
+ return err instanceof Error ? err.message : String(err);
2908
+ }
2909
+ async function listMcp(deps) {
2910
+ const { servers } = await readServers(deps.configPath);
2911
+ return Object.entries(servers).map(
2912
+ ([name, cfg]) => projectServer(name, { ...cfg, name }, deps.registry)
2913
+ );
2914
+ }
2915
+ async function addMcp(input, deps) {
2916
+ if (!input.name) return { ok: false, message: "Server name is required" };
2917
+ const { full, servers } = await readServers(deps.configPath);
2918
+ if (servers[input.name]) {
2919
+ return { ok: false, message: `Server "${input.name}" already exists` };
2920
+ }
2921
+ const preset = deps.presets?.[input.name];
2922
+ const hasExplicitConfig = !!(input.transport || input.command || input.url);
2923
+ const cfg = hasExplicitConfig ? buildConfig(input, preset) : preset ? buildConfig({ ...input, name: input.name }, preset) : buildConfig(input);
2924
+ if (!hasExplicitConfig && !preset) {
2925
+ const known = Object.keys(deps.presets ?? {}).join(", ");
2926
+ return {
2927
+ ok: false,
2928
+ message: known ? `Unknown server "${input.name}". Available presets: ${known}` : `No configuration provided for "${input.name}"`
2929
+ };
2930
+ }
2931
+ cfg.enabled = input.enabled ?? false;
2932
+ servers[input.name] = cfg;
2933
+ await persist(deps.configPath, full, servers);
2934
+ if (cfg.enabled) {
2935
+ return startServer(input.name, cfg, deps, `Server "${input.name}" added`);
2936
+ }
2937
+ trackDisabled(deps.registry, cfg);
2938
+ return {
2939
+ ok: true,
2940
+ message: `Server "${input.name}" added (disabled)`,
2941
+ server: projectServer(input.name, cfg, deps.registry)
2942
+ };
2943
+ }
2944
+ async function updateMcp(input, deps) {
2945
+ if (!input.name) return { ok: false, message: "Server name is required" };
2946
+ const { full, servers } = await readServers(deps.configPath);
2947
+ const existing = servers[input.name];
2948
+ if (!existing) return { ok: false, message: `Server "${input.name}" not found` };
2949
+ const cfg = buildConfig(input, { ...existing, name: input.name });
2950
+ servers[input.name] = cfg;
2951
+ await persist(deps.configPath, full, servers);
2952
+ if (cfg.enabled !== false) {
2953
+ return startServer(input.name, cfg, deps, `Server "${input.name}" updated`, { restart: true });
2954
+ }
2955
+ await safeStop(input.name, deps);
2956
+ trackDisabled(deps.registry, cfg);
2957
+ return {
2958
+ ok: true,
2959
+ message: `Server "${input.name}" updated`,
2960
+ server: projectServer(input.name, cfg, deps.registry)
2961
+ };
2962
+ }
2963
+ async function removeMcp(name, deps) {
2964
+ if (!name) return { ok: false, message: "Server name is required" };
2965
+ const { full, servers } = await readServers(deps.configPath);
2966
+ if (!servers[name]) return { ok: false, message: `Server "${name}" not found` };
2967
+ await safeStop(name, deps);
2968
+ forgetRegistryState(deps.registry, name);
2969
+ delete servers[name];
2970
+ await persist(deps.configPath, full, servers);
2971
+ return { ok: true, message: `Server "${name}" removed` };
2972
+ }
2973
+ async function enableMcp(name, deps) {
2974
+ if (!name) return { ok: false, message: "Server name is required" };
2975
+ const { full, servers } = await readServers(deps.configPath);
2976
+ const cfg = servers[name];
2977
+ if (!cfg) {
2978
+ return { ok: false, message: `Server "${name}" is not in config. Add it first.` };
2979
+ }
2980
+ cfg.enabled = true;
2981
+ servers[name] = cfg;
2982
+ await persist(deps.configPath, full, servers);
2983
+ return startServer(name, cfg, deps, `Server "${name}" enabled`, { restart: true });
2984
+ }
2985
+ async function disableMcp(name, deps) {
2986
+ if (!name) return { ok: false, message: "Server name is required" };
2987
+ const { full, servers } = await readServers(deps.configPath);
2988
+ const cfg = servers[name];
2989
+ if (!cfg) return { ok: false, message: `Server "${name}" is not in config.` };
2990
+ await safeStop(name, deps);
2991
+ cfg.enabled = false;
2992
+ trackDisabled(deps.registry, { ...cfg, name });
2993
+ servers[name] = cfg;
2994
+ await persist(deps.configPath, full, servers);
2995
+ return {
2996
+ ok: true,
2997
+ message: `Server "${name}" disabled`,
2998
+ server: projectServer(name, cfg, deps.registry)
2999
+ };
3000
+ }
3001
+ async function restartMcp(name, deps) {
3002
+ if (!name) return { ok: false, message: "Server name is required" };
3003
+ const registered = deps.registry.list().some((s) => s.name === name);
3004
+ if (registered) {
3005
+ try {
3006
+ await deps.registry.restart(name);
3007
+ const { state, tools } = liveState(name, deps.registry);
3008
+ return { ok: true, message: `Server "${name}" restarted`, state, tools };
3009
+ } catch (err) {
3010
+ return { ok: false, message: `Failed to restart "${name}": ${errMessage(err)}` };
3011
+ }
3012
+ }
3013
+ const { servers } = await readServers(deps.configPath);
3014
+ const cfg = servers[name];
3015
+ if (!cfg) return { ok: false, message: `Server "${name}" is not in config.` };
3016
+ return startServer(name, { ...cfg, name }, deps, `Server "${name}" started`, { restart: true });
3017
+ }
3018
+ async function discoverMcp(name, deps) {
3019
+ if (!name) return { ok: false, message: "Server name is required" };
3020
+ const result = await restartMcp(name, deps);
3021
+ if (!result.ok) return result;
3022
+ const { state, tools } = liveState(name, deps.registry);
3023
+ return {
3024
+ ok: true,
3025
+ message: `Discovered ${tools.length} tool${tools.length === 1 ? "" : "s"} from "${name}"`,
3026
+ state,
3027
+ tools
3028
+ };
3029
+ }
3030
+ async function startServer(name, cfg, deps, okMessage, opts) {
3031
+ try {
3032
+ const alreadyRegistered = deps.registry.list().some((s) => s.name === name);
3033
+ if (alreadyRegistered && opts?.restart) {
3034
+ await deps.registry.restart(name);
3035
+ } else if (alreadyRegistered) {
3036
+ await deps.registry.restart(name);
3037
+ } else {
3038
+ await deps.registry.start({ ...cfg, enabled: true });
3039
+ }
3040
+ const { state, tools } = liveState(name, deps.registry);
3041
+ return {
3042
+ ok: true,
3043
+ message: okMessage,
3044
+ server: projectServer(name, cfg, deps.registry),
3045
+ state,
3046
+ tools
3047
+ };
3048
+ } catch (err) {
3049
+ const message = errMessage(err);
3050
+ return {
3051
+ ok: true,
3052
+ // config persisted — surface a soft warning, not a hard failure
3053
+ message: `${okMessage} in config, but failed to start: ${message}`,
3054
+ server: projectServer(name, cfg, deps.registry),
3055
+ registryError: message
3056
+ };
3057
+ }
3058
+ }
3059
+ async function safeStop(name, deps) {
3060
+ try {
3061
+ await deps.registry.stop(name);
3062
+ } catch {
1317
3063
  }
1318
- onLine(line) {
1319
- let msg;
1320
- try {
1321
- msg = JSON.parse(line);
1322
- } catch {
1323
- return;
1324
- }
1325
- if (msg.id !== void 0 && this.pending.has(msg.id)) {
1326
- const entry = this.pending.get(msg.id);
1327
- this.pending.delete(msg.id);
1328
- entry?.resolve(msg);
1329
- return;
1330
- }
1331
- if (typeof msg.method === "string" && msg.method === "notifications/tools/list_changed") {
1332
- void this.handleToolsListChanged();
1333
- }
3064
+ }
3065
+ function trackDisabled(registry, cfg) {
3066
+ if (typeof registry.markDisabled === "function") registry.markDisabled(cfg);
3067
+ }
3068
+ function forgetRegistryState(registry, name) {
3069
+ if (typeof registry.forget === "function") registry.forget(name);
3070
+ }
3071
+
3072
+ // src/manifest-cache.ts
3073
+ import { createHash as createHash2 } from "node:crypto";
3074
+ import * as fs2 from "node:fs/promises";
3075
+ import * as path from "node:path";
3076
+ function manifestConfigHash(cfg) {
3077
+ const basis = JSON.stringify({
3078
+ transport: cfg.transport,
3079
+ command: cfg.command ?? null,
3080
+ args: cfg.args ?? null,
3081
+ url: cfg.url ?? null
3082
+ });
3083
+ return createHash2("sha256").update(basis).digest("hex").slice(0, 16);
3084
+ }
3085
+ function manifestFile(cacheDir, name) {
3086
+ const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
3087
+ return path.join(cacheDir, "mcp-tools", `${safe}.json`);
3088
+ }
3089
+ async function readManifest(cacheDir, name, configHash) {
3090
+ const manifest = await readCapabilityManifest(cacheDir, name, configHash);
3091
+ return manifest?.tools ?? null;
3092
+ }
3093
+ async function readCapabilityManifest(cacheDir, name, configHash) {
3094
+ try {
3095
+ const raw = await fs2.readFile(manifestFile(cacheDir, name), "utf8");
3096
+ const parsed = JSON.parse(raw);
3097
+ if (parsed.configHash !== configHash || !Array.isArray(parsed.tools)) return null;
3098
+ return {
3099
+ tools: parsed.tools,
3100
+ serverMetadata: parsed.serverMetadata === void 0 ? void 0 : parseServerMetadata(parsed.serverMetadata),
3101
+ resources: parsed.resources === void 0 ? void 0 : parseListResourcesResult({ resources: parsed.resources }).resources,
3102
+ resourceTemplates: parsed.resourceTemplates === void 0 ? void 0 : parseListResourceTemplatesResult({ resourceTemplates: parsed.resourceTemplates }).resourceTemplates,
3103
+ prompts: parsed.prompts === void 0 ? void 0 : parseListPromptsResult({ prompts: parsed.prompts }).prompts
3104
+ };
3105
+ } catch {
3106
+ return null;
1334
3107
  }
1335
- /**
1336
- * L2-C: refresh the cached tool list when the server announces a
1337
- * `tools/list_changed`. Listeners (the registry) re-wrap and
1338
- * re-register. Failures are swallowed — a stale cache is preferable
1339
- * to a hard crash on a transient notification glitch.
1340
- */
1341
- async handleToolsListChanged() {
1342
- try {
1343
- const toolsRes = await this.request("tools/list", {});
1344
- const tools = normalizeMCPTools(
1345
- toolsRes.result?.tools
1346
- );
1347
- this._tools = tools;
1348
- this._toolsCache = tools;
1349
- for (const listener of this.toolsChangedListeners) {
1350
- try {
1351
- listener(this.opts.name, [...tools]);
1352
- } catch {
1353
- }
1354
- }
1355
- } catch {
1356
- }
3108
+ }
3109
+ async function writeManifest(cacheDir, name, configHash, tools) {
3110
+ const previous = await readCapabilityManifest(cacheDir, name, configHash);
3111
+ await writeCapabilityManifest(cacheDir, name, configHash, {
3112
+ ...previous,
3113
+ tools
3114
+ });
3115
+ }
3116
+ async function writeCapabilityManifest(cacheDir, name, configHash, manifest) {
3117
+ try {
3118
+ const file = manifestFile(cacheDir, name);
3119
+ await fs2.mkdir(path.dirname(file), { recursive: true });
3120
+ const body = { version: 2, configHash, ...manifest };
3121
+ const tmp = `${file}.tmp`;
3122
+ await fs2.writeFile(tmp, JSON.stringify(body, null, 2), "utf8");
3123
+ await fs2.rename(tmp, file);
3124
+ } catch {
1357
3125
  }
1358
- addToolsChangedListener(listener) {
1359
- this.toolsChangedListeners.add(listener);
3126
+ }
3127
+
3128
+ // src/operations.ts
3129
+ var MCP_OPERATION_LIMITS = Object.freeze({
3130
+ LATENCY_SAMPLES: 128,
3131
+ RECENT_EVENTS: 32,
3132
+ REASON_CHARS: 64
3133
+ });
3134
+ var SAFE_OPERATION_REASONS = /* @__PURE__ */ new Set([
3135
+ "automatic",
3136
+ "complete",
3137
+ "connect-attempt-failed",
3138
+ "connected",
3139
+ "http-disconnect",
3140
+ "http-disconnect-lazy",
3141
+ "idle-timeout",
3142
+ "lazy-demand",
3143
+ "manual",
3144
+ "ok",
3145
+ "process-exit",
3146
+ "process-exit-lazy",
3147
+ "prompt-discovery-failed",
3148
+ "reconnect-exhausted",
3149
+ "resource-discovery-failed",
3150
+ "resource-template-discovery-failed",
3151
+ "started",
3152
+ "tool-call-failed"
3153
+ ]);
3154
+ function createMCPServerOperationState() {
3155
+ return {
3156
+ consecutiveFailures: 0,
3157
+ failures: { transport: 0, protocol: 0, tool: 0 },
3158
+ reconnectCount: 0,
3159
+ wakeCount: 0,
3160
+ sleepCount: 0,
3161
+ restartCount: 0,
3162
+ connectionSamples: [],
3163
+ discoverySamples: [],
3164
+ callSamples: [],
3165
+ inFlightCalls: 0,
3166
+ peakInFlightCalls: 0,
3167
+ recentEvents: []
3168
+ };
3169
+ }
3170
+ function healthStateFor(connectionState, operations, enabled = true) {
3171
+ if (!enabled) return "disabled";
3172
+ if (connectionState === "dormant") return "dormant";
3173
+ if (connectionState === "connecting" || connectionState === "reconnecting" || connectionState === "idle") {
3174
+ return "connecting";
1360
3175
  }
1361
- removeToolsChangedListener(listener) {
1362
- this.toolsChangedListeners.delete(listener);
3176
+ if (connectionState === "failed") return "failed";
3177
+ if (connectionState === "disconnected" || operations.consecutiveFailures > 0) return "degraded";
3178
+ return "healthy";
3179
+ }
3180
+ function evaluateHealthThresholds(operations, thresholds) {
3181
+ if (!thresholds) return [];
3182
+ const checks = [];
3183
+ if (thresholds.connectionLatencyP95Ms !== void 0 && operations.connectionSamples.length > 0) {
3184
+ const value = percentile([...operations.connectionSamples].sort((a, b) => a - b), 0.95);
3185
+ checks.push({
3186
+ name: "connection-latency-p95",
3187
+ passed: value <= thresholds.connectionLatencyP95Ms,
3188
+ value,
3189
+ threshold: thresholds.connectionLatencyP95Ms
3190
+ });
1363
3191
  }
1364
- };
1365
- function quoteWindowsArg(arg) {
1366
- if (!/[\s"]/.test(arg)) return arg;
1367
- return `"${arg.replace(/"/g, '""')}"`;
3192
+ if (thresholds.discoveryLatencyP95Ms !== void 0 && operations.discoverySamples.length > 0) {
3193
+ const value = percentile([...operations.discoverySamples].sort((a, b) => a - b), 0.95);
3194
+ checks.push({
3195
+ name: "discovery-latency-p95",
3196
+ passed: value <= thresholds.discoveryLatencyP95Ms,
3197
+ value,
3198
+ threshold: thresholds.discoveryLatencyP95Ms
3199
+ });
3200
+ }
3201
+ if (thresholds.callLatencyP95Ms !== void 0 && operations.callSamples.length > 0) {
3202
+ const value = percentile([...operations.callSamples].sort((a, b) => a - b), 0.95);
3203
+ checks.push({
3204
+ name: "call-latency-p95",
3205
+ passed: value <= thresholds.callLatencyP95Ms,
3206
+ value,
3207
+ threshold: thresholds.callLatencyP95Ms
3208
+ });
3209
+ }
3210
+ if (thresholds.inFlightCalls !== void 0) {
3211
+ checks.push({
3212
+ name: "in-flight-calls",
3213
+ passed: operations.peakInFlightCalls <= thresholds.inFlightCalls,
3214
+ value: operations.peakInFlightCalls,
3215
+ threshold: thresholds.inFlightCalls
3216
+ });
3217
+ }
3218
+ return checks;
3219
+ }
3220
+ function applyHealthThresholds(state, checks) {
3221
+ if (state !== "healthy") return state;
3222
+ return checks.some((c) => !c.passed) ? "degraded" : "healthy";
3223
+ }
3224
+ function summarizeLatency(samples) {
3225
+ if (samples.length === 0) return { count: 0 };
3226
+ const sorted = [...samples].sort((a, b) => a - b);
3227
+ return {
3228
+ count: samples.length,
3229
+ lastMs: samples[samples.length - 1],
3230
+ minMs: sorted[0],
3231
+ maxMs: sorted[sorted.length - 1],
3232
+ p50Ms: percentile(sorted, 0.5),
3233
+ p95Ms: percentile(sorted, 0.95)
3234
+ };
3235
+ }
3236
+ function pushBounded(target, value, limit) {
3237
+ target.push(value);
3238
+ if (target.length > limit) target.splice(0, target.length - limit);
3239
+ }
3240
+ function safeOperationReason(reason) {
3241
+ const normalized = reason.toLowerCase().replace(/[^a-z0-9_.:-]+/g, "-");
3242
+ const bounded = normalized.slice(0, MCP_OPERATION_LIMITS.REASON_CHARS);
3243
+ return SAFE_OPERATION_REASONS.has(bounded) ? bounded : "other";
3244
+ }
3245
+ function percentile(sorted, ratio) {
3246
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1))];
1368
3247
  }
1369
3248
 
3249
+ // src/registry.ts
3250
+ import { expectDefined } from "@wrongstack/core";
3251
+
1370
3252
  // src/wrap-tool.ts
1371
3253
  import { ToolCapabilities } from "@wrongstack/core";
1372
3254
  var MUTATING_RE = /create|update|delete|write|send|set|put|post|patch|remove|rename|move/i;
@@ -1383,7 +3265,7 @@ function isMutatingTool(mcpTool) {
1383
3265
  }
1384
3266
  return false;
1385
3267
  }
1386
- function wrapMCPTool(serverName, mcpTool, client, permission = "confirm") {
3268
+ function wrapMCPTool(serverName, mcpTool, client, permission = "confirm", observer) {
1387
3269
  const qualifiedName = `mcp__${serverName}__${mcpTool.name}`;
1388
3270
  return {
1389
3271
  name: qualifiedName,
@@ -1394,12 +3276,20 @@ function wrapMCPTool(serverName, mcpTool, client, permission = "confirm") {
1394
3276
  capabilities: [ToolCapabilities.MCP_PROXY],
1395
3277
  inputSchema: mcpTool.inputSchema ?? { type: "object", properties: {} },
1396
3278
  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));
3279
+ const startedAt = Date.now();
3280
+ observer?.onStart();
3281
+ let ok = false;
3282
+ try {
3283
+ const live = typeof client === "function" ? await client() : client;
3284
+ const res = await live.callTool(mcpTool.name, input, { signal: opts.signal });
3285
+ if (res.isError) {
3286
+ throw new Error(stringify(res.content));
3287
+ }
3288
+ ok = true;
3289
+ return stringify(res.content);
3290
+ } finally {
3291
+ observer?.onFinish({ durationMs: Date.now() - startedAt, ok });
1401
3292
  }
1402
- return stringify(res.content);
1403
3293
  }
1404
3294
  };
1405
3295
  }
@@ -1424,57 +3314,20 @@ function stringify(c) {
1424
3314
  return String(c ?? "");
1425
3315
  }
1426
3316
 
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);
1442
- }
1443
- function manifestFile(cacheDir, name) {
1444
- const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
1445
- return path.join(cacheDir, "mcp-tools", `${safe}.json`);
1446
- }
1447
- async function readManifest(cacheDir, name, configHash) {
1448
- 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;
1453
- } catch {
1454
- return null;
1455
- }
1456
- }
1457
- async function writeManifest(cacheDir, name, configHash, tools) {
1458
- 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);
1465
- } catch {
1466
- }
1467
- }
1468
-
1469
3317
  // src/registry.ts
1470
3318
  var MCPRegistry = class _MCPRegistry {
1471
3319
  servers = /* @__PURE__ */ new Map();
3320
+ /** Configured-off servers are tracked without creating a transport/client. */
3321
+ disabledServers = /* @__PURE__ */ new Map();
1472
3322
  toolRegistry;
1473
3323
  events;
1474
3324
  log;
1475
3325
  lazyMode;
1476
3326
  cacheDir;
1477
3327
  idleTimeoutMs;
3328
+ authorizationProviderFactory;
3329
+ authorizationManager;
3330
+ operationListeners = /* @__PURE__ */ new Set();
1478
3331
  /** Single shared idle sweep timer (started lazily; unref'd; cleared on stopAll). */
1479
3332
  idleTimer;
1480
3333
  constructor(opts) {
@@ -1484,9 +3337,61 @@ var MCPRegistry = class _MCPRegistry {
1484
3337
  this.lazyMode = opts.lazyMode ?? false;
1485
3338
  this.cacheDir = opts.cacheDir;
1486
3339
  this.idleTimeoutMs = opts.idleTimeoutMs ?? MCP_CONSTANTS.IDLE.DEFAULT_TIMEOUT_MS;
3340
+ this.authorizationProviderFactory = opts.authorizationProviderFactory;
3341
+ this.authorizationManager = opts.authorizationManager;
3342
+ }
3343
+ requireSlot(name) {
3344
+ const slot = this.servers.get(name);
3345
+ if (!slot) throw new Error(`MCP server "${name}" not registered`);
3346
+ return slot;
3347
+ }
3348
+ async beginAuthorization(name, input) {
3349
+ const manager = this.requireAuthorizationManager();
3350
+ const cfg = this.requireHttpServerConfig(name);
3351
+ return manager.begin({
3352
+ serverName: name,
3353
+ resource: cfg.url,
3354
+ ...input
3355
+ });
3356
+ }
3357
+ async completeAuthorization(name, callbackUrl, signal) {
3358
+ const manager = this.requireAuthorizationManager();
3359
+ const cfg = this.requireHttpServerConfig(name);
3360
+ return manager.complete({ serverName: name, resource: cfg.url, callbackUrl, signal });
3361
+ }
3362
+ async authorizationStatus(name) {
3363
+ const manager = this.requireAuthorizationManager();
3364
+ const cfg = this.requireHttpServerConfig(name);
3365
+ return manager.status(name, cfg.url);
3366
+ }
3367
+ async disconnectAuthorization(name) {
3368
+ const manager = this.requireAuthorizationManager();
3369
+ const cfg = this.requireHttpServerConfig(name);
3370
+ return manager.disconnect(name, cfg.url);
3371
+ }
3372
+ requireAuthorizationManager() {
3373
+ if (!this.authorizationManager) {
3374
+ throw new Error("MCP authorization management is not configured for this host");
3375
+ }
3376
+ return this.authorizationManager;
3377
+ }
3378
+ requireHttpServerConfig(name) {
3379
+ const cfg = this.servers.get(name)?.cfg ?? this.disabledServers.get(name);
3380
+ if (!cfg) throw new Error(`MCP server "${name}" not registered`);
3381
+ if (cfg.transport === "stdio" || !cfg.url) {
3382
+ throw new Error(`MCP server "${name}" does not use an HTTP transport`);
3383
+ }
3384
+ return cfg;
1487
3385
  }
1488
3386
  async start(cfg) {
1489
- if (cfg.enabled === false) return;
3387
+ if (cfg.enabled === false) {
3388
+ if (this.servers.has(cfg.name)) {
3389
+ await this.stop(cfg.name);
3390
+ }
3391
+ this.markDisabled(cfg);
3392
+ return;
3393
+ }
3394
+ this.disabledServers.delete(cfg.name);
1490
3395
  if (this.servers.has(cfg.name)) {
1491
3396
  throw new Error(
1492
3397
  `MCP server "${cfg.name}" is already registered \u2014 use restart() to re-cycle a running server`
@@ -1503,7 +3408,8 @@ var MCPRegistry = class _MCPRegistry {
1503
3408
  reconnectCycles: 0,
1504
3409
  lazy,
1505
3410
  lastUsed: Date.now(),
1506
- registeredLazy: false
3411
+ registeredLazy: false,
3412
+ operations: createMCPServerOperationState()
1507
3413
  };
1508
3414
  this.servers.set(cfg.name, slot);
1509
3415
  if (lazy) {
@@ -1512,6 +3418,16 @@ var MCPRegistry = class _MCPRegistry {
1512
3418
  await this.attemptConnect(slot);
1513
3419
  }
1514
3420
  }
3421
+ /** Record an intentionally disabled configuration without opening a transport. */
3422
+ markDisabled(cfg) {
3423
+ this.servers.delete(cfg.name);
3424
+ this.disabledServers.set(cfg.name, { ...cfg, enabled: false });
3425
+ }
3426
+ /** Remove residual operational/configuration state after a management delete. */
3427
+ forget(name) {
3428
+ this.servers.delete(name);
3429
+ this.disabledServers.delete(name);
3430
+ }
1515
3431
  /**
1516
3432
  * Boot a lazy server WITHOUT spawning it. If a tool manifest is cached (from a
1517
3433
  * prior connect with matching config), register resolver-backed wrappers and
@@ -1525,13 +3441,17 @@ var MCPRegistry = class _MCPRegistry {
1525
3441
  return;
1526
3442
  }
1527
3443
  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);
3444
+ const cached = await readCapabilityManifest(cacheDir, slot.cfg.name, hash);
3445
+ if (cached) {
3446
+ slot.serverMetadata = cached.serverMetadata;
3447
+ slot.resources = cached.resources;
3448
+ slot.resourceTemplates = cached.resourceTemplates;
3449
+ slot.prompts = cached.prompts;
3450
+ this.applyTools(slot, cached.tools);
1531
3451
  slot.state = "dormant";
1532
3452
  this.ensureIdleSweep();
1533
3453
  this.log.info(
1534
- `MCP server "${slot.cfg.name}" registered lazily from cache (${cached.length} tools, dormant)`
3454
+ `MCP server "${slot.cfg.name}" registered lazily from cache (${cached.tools.length} tools, dormant)`
1535
3455
  );
1536
3456
  return;
1537
3457
  }
@@ -1547,6 +3467,11 @@ var MCPRegistry = class _MCPRegistry {
1547
3467
  slot.lastUsed = Date.now();
1548
3468
  if (slot.client && slot.state === "connected") return slot.client;
1549
3469
  if (slot.connecting) return slot.connecting;
3470
+ const waking = slot.state === "dormant";
3471
+ if (waking) {
3472
+ slot.operations.wakeCount++;
3473
+ this.recordOperation(slot, "wake", "lazy-demand");
3474
+ }
1550
3475
  slot.connecting = (async () => {
1551
3476
  try {
1552
3477
  slot.attempts = 0;
@@ -1627,6 +3552,7 @@ var MCPRegistry = class _MCPRegistry {
1627
3552
  slot.client.removeExitListener(this.onChildExit);
1628
3553
  if (slot.onDisconnect) slot.client.removeDisconnectListener(slot.onDisconnect);
1629
3554
  slot.client.removeToolsChangedListener(this.onToolsChanged);
3555
+ this.removeCatalogListeners(slot.client);
1630
3556
  await slot.client.close();
1631
3557
  slot.client = void 0;
1632
3558
  }
@@ -1635,13 +3561,20 @@ var MCPRegistry = class _MCPRegistry {
1635
3561
  for (const t of slot.toolNames) this.toolRegistry.unregister(t);
1636
3562
  slot.toolNames = [];
1637
3563
  slot.lazyTools = [];
3564
+ slot.serverMetadata = void 0;
3565
+ slot.resources = void 0;
3566
+ slot.resourceTemplates = void 0;
3567
+ slot.prompts = void 0;
1638
3568
  slot.registeredLazy = false;
1639
3569
  slot.state = "disconnected";
3570
+ this.recordOperation(slot, "stop", "manual");
1640
3571
  this.events.emit("mcp.server.disconnected", { name, reason: "stop" });
1641
3572
  }
1642
3573
  async restart(name) {
1643
3574
  const slot = this.servers.get(name);
1644
3575
  if (!slot) throw new Error(`MCP server "${name}" not registered`);
3576
+ slot.operations.restartCount++;
3577
+ this.recordOperation(slot, "restart", "manual");
1645
3578
  await this.stop(name);
1646
3579
  slot.attempts = 0;
1647
3580
  slot.reconnectCycles = 0;
@@ -1658,6 +3591,131 @@ var MCPRegistry = class _MCPRegistry {
1658
3591
  };
1659
3592
  });
1660
3593
  }
3594
+ /**
3595
+ * Subscribe to payload-free operational signals. Callers must still avoid
3596
+ * using `serverName` as an unbounded metric label.
3597
+ */
3598
+ onOperation(listener) {
3599
+ this.operationListeners.add(listener);
3600
+ return () => this.operationListeners.delete(listener);
3601
+ }
3602
+ /** Detailed, defensively-copied operational snapshots for CLI/WebUI/HQ. */
3603
+ operationalHealth() {
3604
+ const active = Array.from(this.servers.values()).map((slot) => {
3605
+ const op = slot.operations;
3606
+ const baseHealth = healthStateFor(slot.state, op, slot.cfg.enabled !== false);
3607
+ const checks = evaluateHealthThresholds(op, slot.cfg.health?.thresholds);
3608
+ return {
3609
+ name: slot.cfg.name,
3610
+ connectionState: slot.state,
3611
+ healthState: applyHealthThresholds(baseHealth, checks),
3612
+ lastSuccessAt: op.lastSuccessAt,
3613
+ lastFailureAt: op.lastFailureAt,
3614
+ lastFailureKind: op.lastFailureKind,
3615
+ lastReason: op.lastReason,
3616
+ consecutiveFailures: op.consecutiveFailures,
3617
+ failures: { ...op.failures },
3618
+ reconnectCount: op.reconnectCount,
3619
+ wakeCount: op.wakeCount,
3620
+ sleepCount: op.sleepCount,
3621
+ restartCount: op.restartCount,
3622
+ connectionLatency: summarizeLatency(op.connectionSamples),
3623
+ discoveryLatency: summarizeLatency(op.discoverySamples),
3624
+ callLatency: summarizeLatency(op.callSamples),
3625
+ inFlightCalls: op.inFlightCalls,
3626
+ peakInFlightCalls: op.peakInFlightCalls,
3627
+ recentEvents: op.recentEvents.map((event) => ({ ...event })),
3628
+ healthChecks: checks
3629
+ };
3630
+ });
3631
+ const disabled = Array.from(this.disabledServers.values()).map((cfg) => {
3632
+ const operations = createMCPServerOperationState();
3633
+ return {
3634
+ name: cfg.name,
3635
+ connectionState: "idle",
3636
+ healthState: "disabled",
3637
+ consecutiveFailures: 0,
3638
+ failures: { ...operations.failures },
3639
+ reconnectCount: 0,
3640
+ wakeCount: 0,
3641
+ sleepCount: 0,
3642
+ restartCount: 0,
3643
+ connectionLatency: summarizeLatency([]),
3644
+ discoveryLatency: summarizeLatency([]),
3645
+ callLatency: summarizeLatency([]),
3646
+ inFlightCalls: 0,
3647
+ peakInFlightCalls: 0,
3648
+ recentEvents: [],
3649
+ healthChecks: []
3650
+ };
3651
+ });
3652
+ return [...active, ...disabled];
3653
+ }
3654
+ getCatalog(name) {
3655
+ const slot = this.servers.get(name);
3656
+ if (!slot) return void 0;
3657
+ return catalogSnapshot(slot);
3658
+ }
3659
+ async listResources(name, opts = {}) {
3660
+ const slot = this.requireSlot(name);
3661
+ if (!opts.refresh && slot.resources) return cloneRecords(slot.resources);
3662
+ const client = await this.ensureConnected(name);
3663
+ if (!client.getServerMetadata()?.capabilities.resources) return [];
3664
+ slot.resources = await collectPages(
3665
+ (cursor) => client.listResources(cursor ? { cursor } : {}),
3666
+ (page) => page.resources
3667
+ );
3668
+ await this.persistCapabilityManifest(slot);
3669
+ return cloneRecords(slot.resources);
3670
+ }
3671
+ async listResourceTemplates(name, opts = {}) {
3672
+ const slot = this.requireSlot(name);
3673
+ if (!opts.refresh && slot.resourceTemplates) return cloneRecords(slot.resourceTemplates);
3674
+ const client = await this.ensureConnected(name);
3675
+ if (!client.getServerMetadata()?.capabilities.resources) return [];
3676
+ slot.resourceTemplates = await collectPages(
3677
+ (cursor) => client.listResourceTemplates(cursor ? { cursor } : {}),
3678
+ (page) => page.resourceTemplates
3679
+ );
3680
+ await this.persistCapabilityManifest(slot);
3681
+ return cloneRecords(slot.resourceTemplates);
3682
+ }
3683
+ async readResource(name, uri) {
3684
+ return (await this.ensureConnected(name)).readResource(uri);
3685
+ }
3686
+ async selectResourceForInsertion(name, uri, policy) {
3687
+ return prepareResourceInsertion(name, uri, await this.readResource(name, uri), policy);
3688
+ }
3689
+ async subscribeResource(name, uri) {
3690
+ await (await this.ensureConnected(name)).subscribeResource(uri);
3691
+ }
3692
+ async unsubscribeResource(name, uri) {
3693
+ await (await this.ensureConnected(name)).unsubscribeResource(uri);
3694
+ }
3695
+ async listPrompts(name, opts = {}) {
3696
+ const slot = this.requireSlot(name);
3697
+ if (!opts.refresh && slot.prompts) return cloneRecords(slot.prompts);
3698
+ const client = await this.ensureConnected(name);
3699
+ if (!client.getServerMetadata()?.capabilities.prompts) return [];
3700
+ slot.prompts = await collectPages(
3701
+ (cursor) => client.listPrompts(cursor ? { cursor } : {}),
3702
+ (page) => page.prompts
3703
+ );
3704
+ await this.persistCapabilityManifest(slot);
3705
+ return cloneRecords(slot.prompts);
3706
+ }
3707
+ async getPrompt(serverName, promptName, args) {
3708
+ return (await this.ensureConnected(serverName)).getPrompt(promptName, args);
3709
+ }
3710
+ async selectPromptForInsertion(serverName, promptName, args, policy) {
3711
+ return preparePromptInsertion(
3712
+ serverName,
3713
+ promptName,
3714
+ args,
3715
+ await this.getPrompt(serverName, promptName, args),
3716
+ policy
3717
+ );
3718
+ }
1661
3719
  /**
1662
3720
  * Resolve the live tool names for a slot — the registered names in normal
1663
3721
  * mode, or the cached lazy-tool names when running in lazy mode (where
@@ -1679,7 +3737,30 @@ var MCPRegistry = class _MCPRegistry {
1679
3737
  const filtered = tools.filter((t) => !allowed || allowed.includes(t.name));
1680
3738
  const clientArg = slot.lazy ? () => this.ensureConnected(slot.cfg.name) : expectDefined(client);
1681
3739
  const wrapped = filtered.map(
1682
- (t) => wrapMCPTool(slot.cfg.name, t, clientArg, slot.cfg.permission ?? "confirm")
3740
+ (t) => wrapMCPTool(slot.cfg.name, t, clientArg, slot.cfg.permission ?? "confirm", {
3741
+ onStart: () => {
3742
+ slot.operations.inFlightCalls++;
3743
+ slot.operations.peakInFlightCalls = Math.max(
3744
+ slot.operations.peakInFlightCalls,
3745
+ slot.operations.inFlightCalls
3746
+ );
3747
+ this.recordOperation(slot, "call", "started", void 0, void 0, false);
3748
+ },
3749
+ onFinish: ({ durationMs, ok }) => {
3750
+ slot.operations.inFlightCalls = Math.max(0, slot.operations.inFlightCalls - 1);
3751
+ pushBounded(
3752
+ slot.operations.callSamples,
3753
+ durationMs,
3754
+ MCP_OPERATION_LIMITS.LATENCY_SAMPLES
3755
+ );
3756
+ if (ok) {
3757
+ this.recordSuccess(slot);
3758
+ this.recordOperation(slot, "call", "ok", void 0, durationMs, false);
3759
+ } else {
3760
+ this.recordFailure(slot, "tool", "tool-call-failed", durationMs);
3761
+ }
3762
+ }
3763
+ })
1683
3764
  );
1684
3765
  if (this.lazyMode) {
1685
3766
  slot.lazyTools = wrapped;
@@ -1693,7 +3774,71 @@ var MCPRegistry = class _MCPRegistry {
1693
3774
  this.log.warn(`MCP tool "${tool.name}" not registered`, err);
1694
3775
  }
1695
3776
  }
1696
- if (slot.lazy) slot.registeredLazy = true;
3777
+ if (slot.lazy && wrapped.length > 0) slot.registeredLazy = true;
3778
+ }
3779
+ async discoverCapabilities(slot, client) {
3780
+ const startedAt = Date.now();
3781
+ slot.serverMetadata = client.getServerMetadata();
3782
+ const capabilities = slot.serverMetadata?.capabilities;
3783
+ if (capabilities?.resources) {
3784
+ try {
3785
+ slot.resources = await collectPages(
3786
+ (cursor) => client.listResources(cursor ? { cursor } : {}),
3787
+ (page) => page.resources
3788
+ );
3789
+ } catch (err) {
3790
+ slot.resources = void 0;
3791
+ this.recordFailure(slot, "protocol", "resource-discovery-failed");
3792
+ this.log.warn(`MCP server "${slot.cfg.name}" resource discovery failed`, err);
3793
+ }
3794
+ try {
3795
+ slot.resourceTemplates = await collectPages(
3796
+ (cursor) => client.listResourceTemplates(cursor ? { cursor } : {}),
3797
+ (page) => page.resourceTemplates
3798
+ );
3799
+ } catch (err) {
3800
+ slot.resourceTemplates = void 0;
3801
+ this.recordFailure(slot, "protocol", "resource-template-discovery-failed");
3802
+ this.log.warn(`MCP server "${slot.cfg.name}" resource template discovery failed`, err);
3803
+ }
3804
+ } else {
3805
+ slot.resources = void 0;
3806
+ slot.resourceTemplates = void 0;
3807
+ }
3808
+ if (capabilities?.prompts) {
3809
+ try {
3810
+ slot.prompts = await collectPages(
3811
+ (cursor) => client.listPrompts(cursor ? { cursor } : {}),
3812
+ (page) => page.prompts
3813
+ );
3814
+ } catch (err) {
3815
+ slot.prompts = void 0;
3816
+ this.recordFailure(slot, "protocol", "prompt-discovery-failed");
3817
+ this.log.warn(`MCP server "${slot.cfg.name}" prompt discovery failed`, err);
3818
+ }
3819
+ } else {
3820
+ slot.prompts = void 0;
3821
+ }
3822
+ const durationMs = Date.now() - startedAt;
3823
+ pushBounded(slot.operations.discoverySamples, durationMs, MCP_OPERATION_LIMITS.LATENCY_SAMPLES);
3824
+ this.recordOperation(slot, "discover", "complete", void 0, durationMs, false);
3825
+ }
3826
+ async persistCapabilityManifest(slot) {
3827
+ if (!slot.lazy || !this.cacheDir) return;
3828
+ const cacheDir = this.cacheDir;
3829
+ const previous = slot.manifestWrite ?? Promise.resolve();
3830
+ const pending = previous.then(
3831
+ () => writeCapabilityManifest(cacheDir, slot.cfg.name, manifestConfigHash(slot.cfg), {
3832
+ tools: slot.client?.listTools() ?? [],
3833
+ serverMetadata: slot.serverMetadata,
3834
+ resources: slot.resources,
3835
+ resourceTemplates: slot.resourceTemplates,
3836
+ prompts: slot.prompts
3837
+ })
3838
+ );
3839
+ slot.manifestWrite = pending;
3840
+ await pending;
3841
+ if (slot.manifestWrite === pending) slot.manifestWrite = void 0;
1697
3842
  }
1698
3843
  /** Start the shared idle sweep timer once (unref'd so it never holds the process). */
1699
3844
  ensureIdleSweep() {
@@ -1728,11 +3873,14 @@ var MCPRegistry = class _MCPRegistry {
1728
3873
  slot.client.removeExitListener(this.onChildExit);
1729
3874
  if (slot.onDisconnect) slot.client.removeDisconnectListener(slot.onDisconnect);
1730
3875
  slot.client.removeToolsChangedListener(this.onToolsChanged);
3876
+ this.removeCatalogListeners(slot.client);
1731
3877
  await slot.client.close();
1732
3878
  slot.client = void 0;
1733
3879
  }
1734
3880
  slot.onDisconnect = void 0;
1735
3881
  slot.state = "dormant";
3882
+ slot.operations.sleepCount++;
3883
+ this.recordOperation(slot, "sleep", "idle-timeout");
1736
3884
  this.log.info(`MCP server "${slot.cfg.name}" idle \u2014 sleeping (tools stay registered)`);
1737
3885
  this.events.emit("mcp.server.disconnected", { name: slot.cfg.name, reason: "idle-sleep" });
1738
3886
  }
@@ -1743,7 +3891,7 @@ var MCPRegistry = class _MCPRegistry {
1743
3891
  * triggering connections.
1744
3892
  */
1745
3893
  describe() {
1746
- return Array.from(this.servers.values()).map((s) => {
3894
+ const active = Array.from(this.servers.values()).map((s) => {
1747
3895
  const tools = this.toolNamesForSlot(s);
1748
3896
  return {
1749
3897
  name: s.cfg.name,
@@ -1753,6 +3901,14 @@ var MCPRegistry = class _MCPRegistry {
1753
3901
  tools
1754
3902
  };
1755
3903
  });
3904
+ const disabled = Array.from(this.disabledServers.values()).map((cfg) => ({
3905
+ name: cfg.name,
3906
+ state: "idle",
3907
+ toolCount: 0,
3908
+ enabled: false,
3909
+ tools: []
3910
+ }));
3911
+ return [...active, ...disabled];
1756
3912
  }
1757
3913
  async stopAll() {
1758
3914
  if (this.idleTimer) {
@@ -1762,6 +3918,7 @@ var MCPRegistry = class _MCPRegistry {
1762
3918
  for (const name of Array.from(this.servers.keys())) {
1763
3919
  await this.stop(name);
1764
3920
  }
3921
+ this.disabledServers.clear();
1765
3922
  }
1766
3923
  /**
1767
3924
  * Health check — returns 'ok' for connected servers, the current state otherwise.
@@ -1792,10 +3949,8 @@ var MCPRegistry = class _MCPRegistry {
1792
3949
  slot.toolNames = [];
1793
3950
  slot.registeredLazy = false;
1794
3951
  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
3952
  this.applyTools(slot, discovered, slot.client);
3953
+ void this.persistCapabilityManifest(slot);
1799
3954
  this.events.emit("mcp.server.connected", {
1800
3955
  name: slot.cfg.name,
1801
3956
  toolCount: slot.toolNames.length
@@ -1804,12 +3959,36 @@ var MCPRegistry = class _MCPRegistry {
1804
3959
  `MCP server "${slot.cfg.name}" tools refreshed (${this.toolNamesForSlot(slot).length} active)`
1805
3960
  );
1806
3961
  };
3962
+ onResourcesChanged = (name) => {
3963
+ const slot = this.servers.get(name);
3964
+ if (!slot) return;
3965
+ slot.resources = void 0;
3966
+ slot.resourceTemplates = void 0;
3967
+ void this.persistCapabilityManifest(slot);
3968
+ this.log.info(`MCP server "${name}" resource catalog invalidated`);
3969
+ };
3970
+ onPromptsChanged = (name) => {
3971
+ const slot = this.servers.get(name);
3972
+ if (!slot) return;
3973
+ slot.prompts = void 0;
3974
+ void this.persistCapabilityManifest(slot);
3975
+ this.log.info(`MCP server "${name}" prompt catalog invalidated`);
3976
+ };
3977
+ addCatalogListeners(client) {
3978
+ client.addResourcesChangedListener(this.onResourcesChanged);
3979
+ client.addPromptsChangedListener(this.onPromptsChanged);
3980
+ }
3981
+ removeCatalogListeners(client) {
3982
+ client.removeResourcesChangedListener(this.onResourcesChanged);
3983
+ client.removePromptsChangedListener(this.onPromptsChanged);
3984
+ }
1807
3985
  onChildExit = (name, code, _signal) => {
1808
3986
  const slot = this.servers.get(name);
1809
3987
  if (!slot) return;
1810
3988
  if (slot.lazy) {
1811
3989
  slot.client = void 0;
1812
3990
  slot.state = "dormant";
3991
+ this.recordFailure(slot, "transport", "process-exit-lazy");
1813
3992
  this.events.emit("mcp.server.disconnected", {
1814
3993
  name,
1815
3994
  reason: `exit:${code ?? "unknown"} (dormant)`
@@ -1824,7 +4003,12 @@ var MCPRegistry = class _MCPRegistry {
1824
4003
  }
1825
4004
  slot.toolNames = [];
1826
4005
  slot.lazyTools = [];
4006
+ slot.serverMetadata = void 0;
4007
+ slot.resources = void 0;
4008
+ slot.resourceTemplates = void 0;
4009
+ slot.prompts = void 0;
1827
4010
  slot.state = "disconnected";
4011
+ this.recordFailure(slot, "transport", "process-exit");
1828
4012
  this.events.emit("mcp.server.disconnected", { name, reason: `exit:${code ?? "unknown"}` });
1829
4013
  this.scheduleReconnect(slot);
1830
4014
  };
@@ -1835,6 +4019,7 @@ var MCPRegistry = class _MCPRegistry {
1835
4019
  if (slot.lazy) {
1836
4020
  slot.client = void 0;
1837
4021
  slot.state = "dormant";
4022
+ this.recordFailure(slot, "transport", "http-disconnect-lazy");
1838
4023
  this.events.emit("mcp.server.disconnected", { name, reason: "http-disconnect (dormant)" });
1839
4024
  return;
1840
4025
  }
@@ -1846,7 +4031,12 @@ var MCPRegistry = class _MCPRegistry {
1846
4031
  }
1847
4032
  slot.toolNames = [];
1848
4033
  slot.lazyTools = [];
4034
+ slot.serverMetadata = void 0;
4035
+ slot.resources = void 0;
4036
+ slot.resourceTemplates = void 0;
4037
+ slot.prompts = void 0;
1849
4038
  slot.state = "disconnected";
4039
+ this.recordFailure(slot, "transport", "http-disconnect");
1850
4040
  this.events.emit("mcp.server.disconnected", { name, reason: "http-disconnect" });
1851
4041
  this.scheduleReconnect(slot);
1852
4042
  };
@@ -1865,6 +4055,7 @@ var MCPRegistry = class _MCPRegistry {
1865
4055
  if (slot.reconnectPending) return;
1866
4056
  if (slot.reconnectCycles >= _MCPRegistry.MAX_RECONNECT_CYCLES) {
1867
4057
  slot.state = "failed";
4058
+ this.recordFailure(slot, "transport", "reconnect-exhausted");
1868
4059
  this.log.error(
1869
4060
  `MCP server "${slot.cfg.name}" giving up after ${slot.reconnectCycles} reconnect cycles. Use \`/mcp restart ${slot.cfg.name}\` to retry.`
1870
4061
  );
@@ -1893,345 +4084,196 @@ var MCPRegistry = class _MCPRegistry {
1893
4084
  async attemptReconnect(slot) {
1894
4085
  slot.reconnectPending = false;
1895
4086
  slot.reconnectCycles++;
4087
+ slot.operations.reconnectCount++;
4088
+ this.recordOperation(slot, "reconnect", "automatic");
1896
4089
  await this.attemptConnect(slot);
1897
4090
  }
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;
4091
+ recordSuccess(slot, resetFailures = true) {
4092
+ const operations = this.operationsFor(slot);
4093
+ operations.lastSuccessAt = Date.now();
4094
+ if (resetFailures) operations.consecutiveFailures = 0;
2013
4095
  }
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` };
4096
+ recordFailure(slot, failureKind, reason, durationMs) {
4097
+ const operations = this.operationsFor(slot);
4098
+ const safeReason = safeOperationReason(reason);
4099
+ operations.lastFailureAt = Date.now();
4100
+ operations.lastFailureKind = failureKind;
4101
+ operations.lastReason = safeReason;
4102
+ operations.consecutiveFailures++;
4103
+ operations.failures[failureKind]++;
4104
+ this.recordOperation(slot, "failure", safeReason, failureKind, durationMs);
2094
4105
  }
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}"`
4106
+ recordOperation(slot, kind, reason, failureKind, durationMs, retain = true) {
4107
+ const operations = this.operationsFor(slot);
4108
+ const baseHealth = healthStateFor(slot.state, operations, slot.cfg.enabled !== false);
4109
+ const checks = evaluateHealthThresholds(operations, slot.cfg.health?.thresholds);
4110
+ const event = {
4111
+ serverName: slot.cfg.name,
4112
+ kind,
4113
+ at: Date.now(),
4114
+ connectionState: slot.state,
4115
+ healthState: applyHealthThresholds(baseHealth, checks)
2103
4116
  };
4117
+ if (reason !== void 0) event.reason = safeOperationReason(reason);
4118
+ if (failureKind !== void 0) event.failureKind = failureKind;
4119
+ if (durationMs !== void 0) event.durationMs = Math.max(0, Math.round(durationMs));
4120
+ if (retain) {
4121
+ pushBounded(operations.recentEvents, event, MCP_OPERATION_LIMITS.RECENT_EVENTS);
4122
+ }
4123
+ for (const listener of this.operationListeners) {
4124
+ try {
4125
+ listener({ ...event });
4126
+ } catch {
4127
+ }
4128
+ }
2104
4129
  }
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 });
4130
+ /** Keeps private-method unit fixtures from needing to duplicate every slot field. */
4131
+ operationsFor(slot) {
4132
+ if (!slot.operations) slot.operations = createMCPServerOperationState();
4133
+ return slot.operations;
2127
4134
  }
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.` };
4135
+ async attemptConnect(slot) {
4136
+ const MAX_ATTEMPTS = MCP_CONSTANTS.RECONNECT.MAX_ATTEMPTS;
4137
+ let attempt = 0;
4138
+ while (attempt < MAX_ATTEMPTS) {
4139
+ attempt++;
4140
+ const startedAt = Date.now();
4141
+ slot.state = attempt === 1 ? "connecting" : "reconnecting";
4142
+ slot.attempts = attempt;
4143
+ let client;
4144
+ let boundDisconnect;
4145
+ try {
4146
+ client = new MCPClient({
4147
+ name: slot.cfg.name,
4148
+ transport: slot.cfg.transport,
4149
+ command: slot.cfg.command,
4150
+ args: slot.cfg.args,
4151
+ env: slot.cfg.env,
4152
+ url: slot.cfg.url,
4153
+ headers: slot.cfg.headers,
4154
+ startupTimeoutMs: slot.cfg.startupTimeoutMs,
4155
+ requestTimeoutMs: slot.cfg.requestTimeoutMs,
4156
+ passthroughEnv: slot.cfg.passthroughEnv,
4157
+ authorizationProvider: this.authorizationProviderFactory?.(slot.cfg)
4158
+ });
4159
+ if (slot.cfg.transport === "stdio") {
4160
+ client.addExitListener(this.onChildExit);
4161
+ } else {
4162
+ boundDisconnect = () => this.onTransportDisconnect(slot.cfg.name);
4163
+ client.addDisconnectListener(boundDisconnect);
4164
+ }
4165
+ client.addToolsChangedListener(this.onToolsChanged);
4166
+ this.addCatalogListeners(client);
4167
+ await client.connect();
4168
+ if (slot.client && slot.client !== client) {
4169
+ const prior = slot.client;
4170
+ const priorDisconnect = slot.onDisconnect;
4171
+ slot.client.removeExitListener(this.onChildExit);
4172
+ if (priorDisconnect) prior.removeDisconnectListener(priorDisconnect);
4173
+ prior.removeToolsChangedListener(this.onToolsChanged);
4174
+ this.removeCatalogListeners(prior);
4175
+ prior.close().catch(() => {
4176
+ });
4177
+ }
4178
+ slot.client = client;
4179
+ slot.onDisconnect = boundDisconnect;
4180
+ const isReconnect = slot.reconnectCycles > 0 || attempt > 1;
4181
+ slot.state = "connected";
4182
+ slot.reconnectCycles = 0;
4183
+ const mc = client;
4184
+ const discovered = mc.listTools();
4185
+ await this.discoverCapabilities(slot, mc);
4186
+ await this.persistCapabilityManifest(slot);
4187
+ this.applyTools(slot, discovered, mc);
4188
+ const durationMs = Date.now() - startedAt;
4189
+ pushBounded(
4190
+ slot.operations.connectionSamples,
4191
+ durationMs,
4192
+ MCP_OPERATION_LIMITS.LATENCY_SAMPLES
4193
+ );
4194
+ this.recordSuccess(slot, (slot.operations.lastFailureAt ?? 0) < startedAt);
4195
+ this.recordOperation(
4196
+ slot,
4197
+ isReconnect ? "reconnect" : "connect",
4198
+ "connected",
4199
+ void 0,
4200
+ durationMs
4201
+ );
4202
+ slot.lastUsed = Date.now();
4203
+ if (slot.lazy) this.ensureIdleSweep();
4204
+ this.events.emit(isReconnect ? "mcp.server.reconnected" : "mcp.server.connected", {
4205
+ name: slot.cfg.name,
4206
+ toolCount: slot.toolNames.length
4207
+ });
4208
+ return;
4209
+ } catch (err) {
4210
+ this.recordFailure(slot, "transport", "connect-attempt-failed", Date.now() - startedAt);
4211
+ this.log.warn(`MCP server "${slot.cfg.name}" connect attempt ${attempt} failed`, err);
4212
+ if (client) {
4213
+ client.removeExitListener(this.onChildExit);
4214
+ if (boundDisconnect) client.removeDisconnectListener(boundDisconnect);
4215
+ client.removeToolsChangedListener(this.onToolsChanged);
4216
+ this.removeCatalogListeners(client);
4217
+ await client.close().catch(() => {
4218
+ });
4219
+ }
4220
+ if (attempt >= MAX_ATTEMPTS) {
4221
+ this.log.error(
4222
+ `MCP server "${slot.cfg.name}" connect exhausted after ${MAX_ATTEMPTS} attempts`,
4223
+ err
4224
+ );
4225
+ slot.state = "failed";
4226
+ slot.client = void 0;
4227
+ if (slot.reconnectTimer) {
4228
+ clearTimeout(slot.reconnectTimer);
4229
+ slot.reconnectTimer = void 0;
4230
+ }
4231
+ slot.reconnectPending = false;
4232
+ this.events.emit("mcp.server.disconnected", {
4233
+ name: slot.cfg.name,
4234
+ reason: err instanceof Error ? err.message : "unknown"
4235
+ });
4236
+ return;
4237
+ }
4238
+ const delay = 500 * 2 ** attempt;
4239
+ await new Promise((r) => setTimeout(r, delay));
4240
+ }
4241
+ }
2150
4242
  }
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)}` };
4243
+ };
4244
+ var MAX_CATALOG_PAGES = 100;
4245
+ var MAX_CATALOG_ITEMS = 1e4;
4246
+ async function collectPages(load, select) {
4247
+ const items = [];
4248
+ const seenCursors = /* @__PURE__ */ new Set();
4249
+ let cursor;
4250
+ for (let pageNumber = 0; pageNumber < MAX_CATALOG_PAGES; pageNumber++) {
4251
+ const page = await load(cursor);
4252
+ items.push(...select(page));
4253
+ if (items.length > MAX_CATALOG_ITEMS) {
4254
+ throw new Error(`MCP catalog exceeds ${MAX_CATALOG_ITEMS} items`);
2181
4255
  }
4256
+ const next = page.nextCursor;
4257
+ if (!next) return items;
4258
+ if (seenCursors.has(next)) throw new Error(`MCP catalog repeated cursor "${next}"`);
4259
+ seenCursors.add(next);
4260
+ cursor = next;
2182
4261
  }
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 });
4262
+ throw new Error(`MCP catalog exceeds ${MAX_CATALOG_PAGES} pages`);
2187
4263
  }
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);
4264
+ function cloneRecords(records) {
4265
+ return structuredClone(records);
4266
+ }
4267
+ function catalogSnapshot(slot) {
2193
4268
  return {
2194
- ok: true,
2195
- message: `Discovered ${tools.length} tool${tools.length === 1 ? "" : "s"} from "${name}"`,
2196
- state,
2197
- tools
4269
+ name: slot.cfg.name,
4270
+ state: slot.state,
4271
+ serverMetadata: slot.serverMetadata ? structuredClone(slot.serverMetadata) : void 0,
4272
+ resources: slot.resources ? cloneRecords(slot.resources) : void 0,
4273
+ resourceTemplates: slot.resourceTemplates ? cloneRecords(slot.resourceTemplates) : void 0,
4274
+ prompts: slot.prompts ? cloneRecords(slot.prompts) : void 0
2198
4275
  };
2199
4276
  }
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
4277
 
2236
4278
  // src/server.ts
2237
4279
  import { createServer } from "node:http";
@@ -2245,6 +4287,8 @@ var MCPServer = class {
2245
4287
  host;
2246
4288
  serverInfo;
2247
4289
  logger;
4290
+ resources;
4291
+ prompts;
2248
4292
  constructor(opts) {
2249
4293
  this.host = opts.host;
2250
4294
  this.serverInfo = opts.serverInfo ?? {
@@ -2252,6 +4296,8 @@ var MCPServer = class {
2252
4296
  version: MCP_CONSTANTS.CLIENT_INFO.version
2253
4297
  };
2254
4298
  this.logger = opts.logger;
4299
+ this.resources = structuredClone(opts.resources ?? []);
4300
+ this.prompts = structuredClone(opts.prompts ?? []);
2255
4301
  }
2256
4302
  /**
2257
4303
  * Handle one raw JSON-RPC line. Returns the response JSON string for
@@ -2296,7 +4342,11 @@ var MCPServer = class {
2296
4342
  case "initialize":
2297
4343
  return {
2298
4344
  protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
2299
- capabilities: { tools: { listChanged: false } },
4345
+ capabilities: {
4346
+ tools: { listChanged: false },
4347
+ ...this.resources.length > 0 ? { resources: { subscribe: false, listChanged: false } } : {},
4348
+ ...this.prompts.length > 0 ? { prompts: { listChanged: false } } : {}
4349
+ },
2300
4350
  serverInfo: this.serverInfo
2301
4351
  };
2302
4352
  case "ping":
@@ -2314,6 +4364,54 @@ var MCPServer = class {
2314
4364
  const res = await this.host.callTool(p.name, args);
2315
4365
  return { content: toContentBlocks(res.content), isError: res.isError };
2316
4366
  }
4367
+ case "resources/list": {
4368
+ if (this.resources.length === 0) return METHOD_NOT_FOUND_SENTINEL;
4369
+ const page = paginate(this.resources, params);
4370
+ return {
4371
+ resources: page.items.map(({ contents: _contents, ...resource }) => resource),
4372
+ ...page.nextCursor ? { nextCursor: page.nextCursor } : {}
4373
+ };
4374
+ }
4375
+ case "resources/templates/list":
4376
+ if (this.resources.length === 0) return METHOD_NOT_FOUND_SENTINEL;
4377
+ return { resourceTemplates: [] };
4378
+ case "resources/read": {
4379
+ if (this.resources.length === 0) return METHOD_NOT_FOUND_SENTINEL;
4380
+ const uri = requiredParamString(params, "uri", "resources/read");
4381
+ const resource = this.resources.find((candidate) => candidate.uri === uri);
4382
+ if (!resource) throw new Error(`Resource not found: ${uri}`);
4383
+ return { contents: structuredClone(resource.contents) };
4384
+ }
4385
+ case "prompts/list": {
4386
+ if (this.prompts.length === 0) return METHOD_NOT_FOUND_SENTINEL;
4387
+ const page = paginate(this.prompts, params);
4388
+ return {
4389
+ prompts: page.items.map(
4390
+ ({ messages: _messages, template: _template, ...prompt }) => prompt
4391
+ ),
4392
+ ...page.nextCursor ? { nextCursor: page.nextCursor } : {}
4393
+ };
4394
+ }
4395
+ case "prompts/get": {
4396
+ if (this.prompts.length === 0) return METHOD_NOT_FOUND_SENTINEL;
4397
+ const name = requiredParamString(params, "name", "prompts/get");
4398
+ const prompt = this.prompts.find((candidate) => candidate.name === name);
4399
+ if (!prompt) throw new Error(`Prompt not found: ${name}`);
4400
+ const input = paramsRecord(params);
4401
+ const args = stringRecord(input["arguments"], "prompts/get arguments");
4402
+ for (const argument of prompt.arguments ?? []) {
4403
+ if (argument.required && args[argument.name] === void 0) {
4404
+ throw new Error(`Prompt "${name}" requires argument "${argument.name}"`);
4405
+ }
4406
+ }
4407
+ const messages = prompt.template ? [
4408
+ {
4409
+ role: "user",
4410
+ content: { type: "text", text: renderPromptTemplate(prompt.template, args) }
4411
+ }
4412
+ ] : structuredClone(prompt.messages ?? []);
4413
+ return { description: prompt.description, messages };
4414
+ }
2317
4415
  default:
2318
4416
  return METHOD_NOT_FOUND_SENTINEL;
2319
4417
  }
@@ -2322,6 +4420,53 @@ var MCPServer = class {
2322
4420
  return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
2323
4421
  }
2324
4422
  };
4423
+ var SERVER_PAGE_SIZE = 100;
4424
+ function paginate(items, params) {
4425
+ const cursor = paramsRecord(params)["cursor"];
4426
+ let offset = 0;
4427
+ if (cursor !== void 0) {
4428
+ if (typeof cursor !== "string" || !/^\d+$/.test(cursor)) {
4429
+ throw new Error("MCP pagination cursor must be a non-negative integer string");
4430
+ }
4431
+ offset = Number(cursor);
4432
+ if (!Number.isSafeInteger(offset)) throw new Error("MCP pagination cursor is too large");
4433
+ }
4434
+ const page = items.slice(offset, offset + SERVER_PAGE_SIZE);
4435
+ const next = offset + page.length;
4436
+ return {
4437
+ items: page,
4438
+ ...next < items.length ? { nextCursor: String(next) } : {}
4439
+ };
4440
+ }
4441
+ function paramsRecord(params) {
4442
+ return params && typeof params === "object" && !Array.isArray(params) ? params : {};
4443
+ }
4444
+ function requiredParamString(params, field, method) {
4445
+ const value = paramsRecord(params)[field];
4446
+ if (typeof value !== "string" || value.length === 0) {
4447
+ throw new Error(`${method} requires a non-empty string "${field}"`);
4448
+ }
4449
+ return value;
4450
+ }
4451
+ function stringRecord(value, label) {
4452
+ if (value === void 0) return {};
4453
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
4454
+ throw new Error(`${label} must be an object`);
4455
+ }
4456
+ const result = {};
4457
+ for (const [key, item] of Object.entries(value)) {
4458
+ if (typeof item !== "string") throw new Error(`${label}.${key} must be a string`);
4459
+ result[key] = item;
4460
+ }
4461
+ return result;
4462
+ }
4463
+ function renderPromptTemplate(template, args) {
4464
+ return template.replace(/\{\{([A-Za-z_][A-Za-z0-9_.-]*)\}\}/g, (_match, name) => {
4465
+ const value = args[name];
4466
+ if (value === void 0) throw new Error(`Missing prompt template argument "${name}"`);
4467
+ return value;
4468
+ });
4469
+ }
2325
4470
  var METHOD_NOT_FOUND_SENTINEL = /* @__PURE__ */ Symbol("method-not-found");
2326
4471
  function toContentBlocks(content) {
2327
4472
  if (typeof content === "string") return [{ type: "text", text: content }];
@@ -2512,28 +4657,349 @@ async function handleHttpRequest(server, req, res, token, log) {
2512
4657
  });
2513
4658
  });
2514
4659
  }
4660
+
4661
+ // src/token-store.ts
4662
+ import * as fs3 from "node:fs/promises";
4663
+ import { atomicWrite, withFileLock } from "@wrongstack/core/utils";
4664
+ var TOKEN_STORE_VERSION = 1;
4665
+ var MAX_STORE_BYTES = 1024 * 1024;
4666
+ var MAX_ENTRIES = 256;
4667
+ var DEFAULT_REFRESH_SKEW_MS = 6e4;
4668
+ var MCPVaultTokenStore = class {
4669
+ constructor(filePath, vault) {
4670
+ this.filePath = filePath;
4671
+ this.vault = vault;
4672
+ }
4673
+ filePath;
4674
+ vault;
4675
+ async load(serverName, resource) {
4676
+ const canonicalResource = canonicalMcpResource(resource);
4677
+ return withFileLock(this.filePath, async () => {
4678
+ const file = await this.readFile();
4679
+ const entry = file.entries.find(
4680
+ (candidate) => candidate.serverName === serverName && candidate.resource === canonicalResource
4681
+ );
4682
+ return entry ? this.decryptEntry(entry) : void 0;
4683
+ });
4684
+ }
4685
+ async save(value) {
4686
+ const normalized = normalizeStoredAuthorization(value);
4687
+ await withFileLock(this.filePath, async () => {
4688
+ const file = await this.readFile();
4689
+ const next = file.entries.filter(
4690
+ (entry) => !(entry.serverName === normalized.serverName && entry.resource === normalized.resource)
4691
+ );
4692
+ next.push(this.encryptEntry(normalized));
4693
+ if (next.length > MAX_ENTRIES)
4694
+ throw new Error(`MCP token store exceeds ${MAX_ENTRIES} entries`);
4695
+ await this.writeFile(next);
4696
+ });
4697
+ }
4698
+ async remove(serverName, resource) {
4699
+ const canonicalResource = canonicalMcpResource(resource);
4700
+ return withFileLock(this.filePath, async () => {
4701
+ const file = await this.readFile();
4702
+ const next = file.entries.filter(
4703
+ (entry) => !(entry.serverName === serverName && entry.resource === canonicalResource)
4704
+ );
4705
+ if (next.length === file.entries.length) return false;
4706
+ await this.writeFile(next);
4707
+ return true;
4708
+ });
4709
+ }
4710
+ async readFile() {
4711
+ let raw;
4712
+ try {
4713
+ const stat2 = await fs3.stat(this.filePath);
4714
+ if (stat2.size > MAX_STORE_BYTES) throw new Error("MCP token store exceeds size limit");
4715
+ raw = await fs3.readFile(this.filePath, "utf8");
4716
+ } catch (error) {
4717
+ if (error.code === "ENOENT") return emptyFile();
4718
+ throw error;
4719
+ }
4720
+ let parsed;
4721
+ try {
4722
+ parsed = JSON.parse(raw);
4723
+ } catch {
4724
+ throw new Error("MCP token store is not valid JSON");
4725
+ }
4726
+ return validateStoreFile(parsed);
4727
+ }
4728
+ async writeFile(entries) {
4729
+ const file = {
4730
+ version: TOKEN_STORE_VERSION,
4731
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4732
+ entries
4733
+ };
4734
+ await atomicWrite(this.filePath, `${JSON.stringify(file, null, 2)}
4735
+ `, { mode: 384 });
4736
+ }
4737
+ encryptEntry(value) {
4738
+ const accessToken = this.vault.encrypt(value.tokenSet.accessToken);
4739
+ const refreshToken = value.tokenSet.refreshToken ? this.vault.encrypt(value.tokenSet.refreshToken) : void 0;
4740
+ if (!this.vault.isEncrypted(accessToken) || refreshToken && !this.vault.isEncrypted(refreshToken)) {
4741
+ throw new Error("MCP token store requires an encrypting SecretVault");
4742
+ }
4743
+ return {
4744
+ serverName: value.serverName,
4745
+ resource: value.resource,
4746
+ clientId: value.clientId,
4747
+ authorizationServer: value.authorizationServer,
4748
+ accessToken,
4749
+ refreshToken,
4750
+ tokenType: value.tokenSet.tokenType ?? "Bearer",
4751
+ expiresAt: value.tokenSet.expiresAt,
4752
+ scopes: [...value.tokenSet.scopes ?? []],
4753
+ updatedAt: value.updatedAt
4754
+ };
4755
+ }
4756
+ decryptEntry(entry) {
4757
+ if (!this.vault.isEncrypted(entry.accessToken) || entry.refreshToken !== void 0 && !this.vault.isEncrypted(entry.refreshToken)) {
4758
+ throw new Error("MCP token store contains an unencrypted token");
4759
+ }
4760
+ const value = {
4761
+ serverName: entry.serverName,
4762
+ resource: entry.resource,
4763
+ clientId: entry.clientId,
4764
+ authorizationServer: entry.authorizationServer,
4765
+ tokenSet: {
4766
+ accessToken: this.vault.decrypt(entry.accessToken),
4767
+ refreshToken: entry.refreshToken ? this.vault.decrypt(entry.refreshToken) : void 0,
4768
+ tokenType: entry.tokenType,
4769
+ resource: entry.resource,
4770
+ expiresAt: entry.expiresAt,
4771
+ scopes: [...entry.scopes]
4772
+ },
4773
+ updatedAt: entry.updatedAt
4774
+ };
4775
+ return normalizeStoredAuthorization(value);
4776
+ }
4777
+ };
4778
+ var MCPRefreshingAuthorizationProvider = class {
4779
+ constructor(options) {
4780
+ this.options = options;
4781
+ this.resource = canonicalMcpResource(options.resource);
4782
+ this.refreshSkewMs = options.refreshSkewMs ?? DEFAULT_REFRESH_SKEW_MS;
4783
+ }
4784
+ options;
4785
+ refreshPromise;
4786
+ resource;
4787
+ refreshSkewMs;
4788
+ async getAccessToken(context) {
4789
+ this.assertContext(context);
4790
+ let state = await this.options.store.load(this.options.serverName, this.resource);
4791
+ if (!state) return void 0;
4792
+ if (state.tokenSet.expiresAt !== void 0 && state.tokenSet.expiresAt <= Date.now() + this.refreshSkewMs) {
4793
+ state = await this.refresh(state, context.signal);
4794
+ }
4795
+ if (!state) return void 0;
4796
+ if (state.tokenSet.expiresAt !== void 0 && state.tokenSet.expiresAt <= Date.now()) {
4797
+ this.emit("reauth_required", state);
4798
+ return void 0;
4799
+ }
4800
+ authorizationHeaderForToken(state.tokenSet, this.resource);
4801
+ return { ...state.tokenSet, scopes: [...state.tokenSet.scopes ?? []] };
4802
+ }
4803
+ async handleUnauthorized(challenge, context) {
4804
+ this.assertContext(context);
4805
+ if (challenge.resource !== this.resource) return false;
4806
+ const state = await this.options.store.load(this.options.serverName, this.resource);
4807
+ if (!state?.tokenSet.refreshToken) {
4808
+ if (state) this.emit("reauth_required", state);
4809
+ return false;
4810
+ }
4811
+ return await this.refresh(state, context.signal) !== void 0;
4812
+ }
4813
+ refresh(state, signal) {
4814
+ if (this.refreshPromise) return this.refreshPromise;
4815
+ this.refreshPromise = this.refreshInner(state, signal).finally(() => {
4816
+ this.refreshPromise = void 0;
4817
+ });
4818
+ return this.refreshPromise;
4819
+ }
4820
+ async refreshInner(state, signal) {
4821
+ const refreshToken = state.tokenSet.refreshToken;
4822
+ if (!refreshToken) {
4823
+ this.emit("reauth_required", state);
4824
+ return void 0;
4825
+ }
4826
+ const tokenSet = await refreshMcpAccessToken({
4827
+ authorizationServer: state.authorizationServer,
4828
+ clientId: state.clientId,
4829
+ resource: state.resource,
4830
+ refreshToken,
4831
+ signal
4832
+ });
4833
+ const next = normalizeStoredAuthorization({
4834
+ ...state,
4835
+ tokenSet,
4836
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4837
+ });
4838
+ await this.options.store.save(next);
4839
+ this.emit("refreshed", next);
4840
+ return next;
4841
+ }
4842
+ assertContext(context) {
4843
+ if (context.serverName !== this.options.serverName || context.resource !== this.resource) {
4844
+ throw new Error("MCP authorization provider context does not match its server/resource");
4845
+ }
4846
+ }
4847
+ emit(state, value) {
4848
+ this.options.onStateChange?.({
4849
+ serverName: value.serverName,
4850
+ state,
4851
+ resource: value.resource,
4852
+ expiresAt: value.tokenSet.expiresAt,
4853
+ scopes: [...value.tokenSet.scopes ?? []]
4854
+ });
4855
+ }
4856
+ };
4857
+ function createVaultBackedMcpAuthorizationProviderFactory(options) {
4858
+ const providers = /* @__PURE__ */ new Map();
4859
+ return (server) => {
4860
+ if (server.transport === "stdio" || !server.url) return void 0;
4861
+ const resource = canonicalMcpResource(server.url);
4862
+ const key = `${server.name}\0${resource}`;
4863
+ let provider = providers.get(key);
4864
+ if (!provider) {
4865
+ provider = new MCPRefreshingAuthorizationProvider({
4866
+ serverName: server.name,
4867
+ resource,
4868
+ store: options.store,
4869
+ refreshSkewMs: options.refreshSkewMs,
4870
+ onStateChange: options.onStateChange
4871
+ });
4872
+ providers.set(key, provider);
4873
+ }
4874
+ return provider;
4875
+ };
4876
+ }
4877
+ function emptyFile() {
4878
+ return { version: TOKEN_STORE_VERSION, updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(), entries: [] };
4879
+ }
4880
+ function validateStoreFile(value) {
4881
+ if (!isRecord(value) || value["version"] !== TOKEN_STORE_VERSION || !Array.isArray(value["entries"])) {
4882
+ throw new Error("MCP token store has an unsupported or malformed structure");
4883
+ }
4884
+ if (value["entries"].length > MAX_ENTRIES)
4885
+ throw new Error("MCP token store has too many entries");
4886
+ return {
4887
+ version: TOKEN_STORE_VERSION,
4888
+ updatedAt: boundedString(value["updatedAt"], "updatedAt", 128),
4889
+ entries: value["entries"].map(validateEncryptedEntry)
4890
+ };
4891
+ }
4892
+ function validateEncryptedEntry(value) {
4893
+ if (!isRecord(value)) throw new Error("MCP token store entry must be an object");
4894
+ const resource = canonicalMcpResource(boundedString(value["resource"], "resource", 4096));
4895
+ const authorizationServer = validateMcpAuthorizationServerMetadata(value["authorizationServer"]);
4896
+ const scopes = stringArray(value["scopes"], "scopes", 128);
4897
+ const expiresAt = value["expiresAt"];
4898
+ if (expiresAt !== void 0 && (typeof expiresAt !== "number" || !Number.isFinite(expiresAt))) {
4899
+ throw new Error("MCP token store expiresAt must be a finite number");
4900
+ }
4901
+ return {
4902
+ serverName: boundedString(value["serverName"], "serverName", 256),
4903
+ resource,
4904
+ clientId: boundedString(value["clientId"], "clientId", 4096),
4905
+ authorizationServer,
4906
+ accessToken: boundedString(value["accessToken"], "accessToken", 32768),
4907
+ refreshToken: value["refreshToken"] === void 0 ? void 0 : boundedString(value["refreshToken"], "refreshToken", 32768),
4908
+ tokenType: boundedString(value["tokenType"], "tokenType", 64),
4909
+ expiresAt,
4910
+ scopes,
4911
+ updatedAt: boundedString(value["updatedAt"], "updatedAt", 128)
4912
+ };
4913
+ }
4914
+ function normalizeStoredAuthorization(value) {
4915
+ const serverName = boundedString(value.serverName, "serverName", 256);
4916
+ const resource = canonicalMcpResource(value.resource);
4917
+ const authorizationServer = validateMcpAuthorizationServerMetadata(value.authorizationServer);
4918
+ if (canonicalMcpResource(value.tokenSet.resource) !== resource) {
4919
+ throw new Error("MCP token resource mismatch");
4920
+ }
4921
+ const tokenSet = {
4922
+ ...value.tokenSet,
4923
+ resource,
4924
+ scopes: stringArray(value.tokenSet.scopes ?? [], "scopes", 128)
4925
+ };
4926
+ authorizationHeaderForToken({ ...tokenSet, expiresAt: void 0 }, resource);
4927
+ return {
4928
+ serverName,
4929
+ resource,
4930
+ clientId: boundedString(value.clientId, "clientId", 4096),
4931
+ authorizationServer,
4932
+ tokenSet,
4933
+ updatedAt: boundedString(value.updatedAt, "updatedAt", 128)
4934
+ };
4935
+ }
4936
+ function isRecord(value) {
4937
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4938
+ }
4939
+ function boundedString(value, field, maxLength) {
4940
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength || /[\r\n]/.test(value)) {
4941
+ throw new Error(`MCP token store field "${field}" is invalid`);
4942
+ }
4943
+ return value;
4944
+ }
4945
+ function stringArray(value, field, maxItems) {
4946
+ if (!Array.isArray(value) || value.length > maxItems) {
4947
+ throw new Error(`MCP token store field "${field}" must be a bounded array`);
4948
+ }
4949
+ return [...new Set(value.map((entry) => boundedString(entry, field, 256)))];
4950
+ }
2515
4951
  export {
4952
+ DEFAULT_MCP_INSERTION_MAX_BYTES,
4953
+ DEFAULT_MCP_RESOURCE_SCHEMES,
4954
+ MCPAuthorizationManager,
2516
4955
  MCPClient,
4956
+ MCPRefreshingAuthorizationProvider,
2517
4957
  MCPRegistry,
2518
4958
  MCPServer,
4959
+ MCPVaultTokenStore,
2519
4960
  MCP_CONSTANTS,
4961
+ MCP_OPERATION_LIMITS,
2520
4962
  SSEReader,
2521
4963
  SSETransport,
2522
4964
  StreamableHTTPTransport,
2523
4965
  addMcp,
4966
+ authorizationHeaderForToken,
4967
+ authorizationServerMetadataUrls,
4968
+ canonicalMcpResource,
4969
+ createMcpAuthorizationRequest,
4970
+ createVaultBackedMcpAuthorizationProviderFactory,
2524
4971
  disableMcp,
2525
4972
  discoverMcp,
4973
+ discoverMcpAuthorization,
2526
4974
  enableMcp,
4975
+ exchangeMcpAuthorizationCode,
2527
4976
  listMcp,
2528
4977
  manifestConfigHash,
4978
+ parseAuthorizationServerMetadata,
4979
+ parseGetPromptResult,
4980
+ parseListPromptsResult,
4981
+ parseListResourceTemplatesResult,
4982
+ parseListResourcesResult,
4983
+ parseMcpAuthorizationCallback,
4984
+ parseMcpBearerChallenge,
4985
+ parseProtectedResourceMetadata,
4986
+ parseReadResourceResult,
4987
+ parseServerMetadata,
4988
+ preparePromptInsertion,
4989
+ prepareResourceInsertion,
4990
+ protectedResourceMetadataUrls,
4991
+ readCapabilityManifest,
2529
4992
  readManifest,
4993
+ refreshMcpAccessToken,
2530
4994
  removeMcp,
2531
4995
  restartMcp,
2532
4996
  serveHttp,
2533
4997
  serveStdio,
2534
4998
  toContentBlocks,
2535
4999
  updateMcp,
5000
+ validateMcpAuthorizationServerMetadata,
2536
5001
  wrapMCPTool,
5002
+ writeCapabilityManifest,
2537
5003
  writeManifest
2538
5004
  };
2539
5005
  //# sourceMappingURL=index.js.map