@wrongstack/mcp 1.0.24 → 1.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // src/authorization.ts
2
2
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
+
4
+ // src/authorization-discovery.ts
3
5
  import * as dns from "node:dns/promises";
4
6
  import * as http from "node:http";
5
7
  import * as https from "node:https";
@@ -29,22 +31,6 @@ function canonicalMcpResource(rawUrl) {
29
31
  if (url.pathname === "/" && !url.search) return url.origin;
30
32
  return url.toString();
31
33
  }
32
- function authorizationHeaderForToken(token, expectedResource, now = Date.now()) {
33
- if (canonicalMcpResource(token.resource) !== expectedResource) {
34
- throw new Error("MCP access token resource does not match the target server");
35
- }
36
- if (token.expiresAt !== void 0 && token.expiresAt <= now) {
37
- throw new Error("MCP access token is expired");
38
- }
39
- const tokenType = token.tokenType ?? "Bearer";
40
- if (tokenType.toLowerCase() !== "bearer") {
41
- throw new Error(`Unsupported MCP OAuth token type "${tokenType}"`);
42
- }
43
- if (!token.accessToken || token.accessToken.length > 16384 || /[\r\n]/.test(token.accessToken)) {
44
- throw new Error("MCP access token is empty, oversized, or contains invalid characters");
45
- }
46
- return `Bearer ${token.accessToken}`;
47
- }
48
34
  function parseMcpBearerChallenge(header, resource) {
49
35
  const challenge = {
50
36
  status: 401,
@@ -154,36 +140,6 @@ function parseAuthorizationServerMetadata(value, expectedIssuer) {
154
140
  scopesSupported: optionalStringArray(metadata["scopes_supported"], "scopes_supported", 128)
155
141
  };
156
142
  }
157
- function validateMcpAuthorizationServerMetadata(value) {
158
- const metadata = record(value, "stored authorization server metadata");
159
- const registration = optionalString(metadata["registrationEndpoint"], "registrationEndpoint");
160
- const issuer = secureOAuthUrl(requiredString(metadata["issuer"], "issuer"), "issuer").toString();
161
- const authorizationEndpoint = secureOAuthUrl(
162
- requiredString(metadata["authorizationEndpoint"], "authorizationEndpoint"),
163
- "authorization endpoint"
164
- ).toString();
165
- const tokenEndpoint = secureOAuthUrl(
166
- requiredString(metadata["tokenEndpoint"], "tokenEndpoint"),
167
- "token endpoint"
168
- ).toString();
169
- const issuerParameterSupported = optionalBoolean(
170
- metadata["authorizationResponseIssuerParameterSupported"],
171
- "authorizationResponseIssuerParameterSupported"
172
- );
173
- if (issuerParameterSupported !== true && (new URL(authorizationEndpoint).origin !== new URL(issuer).origin || new URL(tokenEndpoint).origin !== new URL(issuer).origin)) {
174
- throw new Error(
175
- "MCP authorization server with cross-origin endpoints must support the authorization response issuer parameter"
176
- );
177
- }
178
- return {
179
- issuer,
180
- authorizationEndpoint,
181
- tokenEndpoint,
182
- registrationEndpoint: registration ? secureOAuthUrl(registration, "registration endpoint").toString() : void 0,
183
- authorizationResponseIssuerParameterSupported: issuerParameterSupported,
184
- scopesSupported: optionalStringArray(metadata["scopesSupported"], "scopesSupported", 128)
185
- };
186
- }
187
143
  async function discoverMcpAuthorization(resource, options = {}) {
188
144
  const canonicalResource = canonicalMcpResource(resource);
189
145
  const resourceUrl = new URL(canonicalResource);
@@ -219,124 +175,6 @@ async function discoverMcpAuthorization(resource, options = {}) {
219
175
  authorizationServer: authorizationDiscovery.value
220
176
  };
221
177
  }
222
- function createMcpAuthorizationRequest(options) {
223
- const resource = canonicalMcpResource(options.resource);
224
- const clientId = boundedCredential(options.clientId, "client id");
225
- const redirectUri = validateRedirectUri(options.redirectUri);
226
- const scopes = validateScopes(options.scopes ?? []);
227
- const codeVerifier = base64Url(randomBytes(32));
228
- const codeChallenge = base64Url(createHash("sha256").update(codeVerifier).digest());
229
- const state = base64Url(randomBytes(32));
230
- const authorizationUrl = secureOAuthUrl(
231
- options.authorizationServer.authorizationEndpoint,
232
- "authorization endpoint"
233
- );
234
- authorizationUrl.searchParams.set("response_type", "code");
235
- authorizationUrl.searchParams.set("client_id", clientId);
236
- authorizationUrl.searchParams.set("redirect_uri", redirectUri);
237
- authorizationUrl.searchParams.set("state", state);
238
- authorizationUrl.searchParams.set("code_challenge", codeChallenge);
239
- authorizationUrl.searchParams.set("code_challenge_method", "S256");
240
- authorizationUrl.searchParams.set("resource", resource);
241
- if (scopes.length > 0) authorizationUrl.searchParams.set("scope", scopes.join(" "));
242
- return {
243
- authorizationUrl: authorizationUrl.toString(),
244
- state,
245
- codeVerifier,
246
- redirectUri,
247
- clientId,
248
- resource,
249
- issuer: options.authorizationServer.issuer,
250
- requireIssuerParameter: options.authorizationServer.authorizationResponseIssuerParameterSupported === true
251
- };
252
- }
253
- function parseMcpAuthorizationCallback(callbackUrl, session) {
254
- let callback;
255
- try {
256
- callback = new URL(callbackUrl);
257
- } catch {
258
- throw new Error("MCP OAuth callback must be an absolute URL");
259
- }
260
- const expected = new URL(validateRedirectUri(session.redirectUri));
261
- if (callback.protocol !== expected.protocol || callback.hostname !== expected.hostname || callback.port !== expected.port || callback.pathname !== expected.pathname) {
262
- throw new Error("MCP OAuth callback redirect URI does not match the authorization session");
263
- }
264
- const returnedStates = callback.searchParams.getAll("state");
265
- if (returnedStates.length !== 1) {
266
- throw new Error("MCP OAuth callback must contain exactly one state parameter");
267
- }
268
- const returnedState = returnedStates[0];
269
- if (!constantTimeEqual(returnedState, session.state)) {
270
- throw new Error("MCP OAuth callback state mismatch");
271
- }
272
- const returnedIssuers = callback.searchParams.getAll("iss");
273
- if (session.requireIssuerParameter && returnedIssuers.length !== 1) {
274
- throw new Error("MCP OAuth callback must contain exactly one issuer parameter");
275
- }
276
- if (returnedIssuers.length > 0 && returnedIssuers.some((issuer) => issuer !== session.issuer)) {
277
- throw new Error("MCP OAuth callback issuer mismatch");
278
- }
279
- const oauthErrors = callback.searchParams.getAll("error");
280
- const codes = callback.searchParams.getAll("code");
281
- if (oauthErrors.length > 1 || codes.length > 1 || oauthErrors.length === 1 && codes.length > 0) {
282
- throw new Error("MCP OAuth callback contains ambiguous response parameters");
283
- }
284
- if (oauthErrors.length === 1) {
285
- throw new Error(`MCP OAuth authorization failed: ${boundedErrorCode(oauthErrors[0])}`);
286
- }
287
- if (codes.length !== 1) {
288
- throw new Error("MCP OAuth callback must contain exactly one authorization code");
289
- }
290
- return boundedCredential(codes[0], "authorization code");
291
- }
292
- async function exchangeMcpAuthorizationCode(options) {
293
- const resource = canonicalMcpResource(options.resource);
294
- const body = new URLSearchParams({
295
- grant_type: "authorization_code",
296
- code: boundedCredential(options.code, "authorization code"),
297
- client_id: boundedCredential(options.clientId, "client id"),
298
- redirect_uri: validateRedirectUri(options.redirectUri),
299
- code_verifier: validateCodeVerifier(options.codeVerifier),
300
- resource
301
- }).toString();
302
- const response = await requestPinnedJson(options.authorizationServer.tokenEndpoint, {
303
- method: "POST",
304
- body,
305
- headers: { "content-type": "application/x-www-form-urlencoded" },
306
- signal: options.signal,
307
- timeoutMs: options.timeoutMs,
308
- maxResponseBytes: options.maxResponseBytes,
309
- lookup: options.lookup,
310
- allowedLoopbackHostname: loopbackHostnameForResource(resource),
311
- label: "token endpoint"
312
- });
313
- if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
314
- return parseTokenResponse(response, resource);
315
- }
316
- async function refreshMcpAccessToken(options) {
317
- const resource = canonicalMcpResource(options.resource);
318
- const previousRefreshToken = boundedCredential(options.refreshToken, "refresh token");
319
- const body = new URLSearchParams({
320
- grant_type: "refresh_token",
321
- refresh_token: previousRefreshToken,
322
- client_id: boundedCredential(options.clientId, "client id"),
323
- resource
324
- }).toString();
325
- const response = await requestPinnedJson(options.authorizationServer.tokenEndpoint, {
326
- method: "POST",
327
- body,
328
- headers: { "content-type": "application/x-www-form-urlencoded" },
329
- signal: options.signal,
330
- timeoutMs: options.timeoutMs,
331
- maxResponseBytes: options.maxResponseBytes,
332
- lookup: options.lookup,
333
- allowedLoopbackHostname: loopbackHostnameForResource(resource),
334
- label: "token endpoint"
335
- });
336
- if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
337
- const parsed = parseTokenResponse(response, resource);
338
- return { ...parsed, refreshToken: parsed.refreshToken ?? previousRefreshToken };
339
- }
340
178
  function challengeParameter(parameters, name) {
341
179
  const pattern = new RegExp(
342
180
  `(?:^|,)\\s*${name}\\s*=\\s*(?:"((?:\\\\.|[^"\\\\])*)"|([^,\\s]+))`,
@@ -356,9 +194,9 @@ function validateMetadataUrl(value) {
356
194
  return void 0;
357
195
  }
358
196
  }
359
- function record(value, label) {
197
+ function record(value, label2) {
360
198
  if (!value || typeof value !== "object" || Array.isArray(value)) {
361
- throw new Error(`MCP ${label} must be an object`);
199
+ throw new Error(`MCP ${label2} must be an object`);
362
200
  }
363
201
  return value;
364
202
  }
@@ -387,23 +225,23 @@ function boundedStringArray(value, field, maxItems) {
387
225
  function optionalStringArray(value, field, maxItems) {
388
226
  return value === void 0 ? [] : boundedStringArray(value, field, maxItems);
389
227
  }
390
- function secureOAuthUrl(value, label) {
228
+ function secureOAuthUrl(value, label2) {
391
229
  let url;
392
230
  try {
393
231
  url = new URL(value);
394
232
  } catch {
395
- throw new Error(`MCP ${label} must be an absolute URL`);
233
+ throw new Error(`MCP ${label2} must be an absolute URL`);
396
234
  }
397
235
  if (url.protocol !== "https:" && !isLoopbackHttp(url)) {
398
- throw new Error(`MCP ${label} must use HTTPS (except loopback development)`);
236
+ throw new Error(`MCP ${label2} must use HTTPS (except loopback development)`);
399
237
  }
400
238
  if (url.username || url.password || url.search || url.hash) {
401
- throw new Error(`MCP ${label} must not contain credentials, query, or fragment components`);
239
+ throw new Error(`MCP ${label2} must not contain credentials, query, or fragment components`);
402
240
  }
403
241
  if (url.pathname === "/") return new URL(url.origin);
404
242
  return url;
405
243
  }
406
- async function discoverFirst(candidates, fetchJson, signal, parse, label) {
244
+ async function discoverFirst(candidates, fetchJson, signal, parse, label2) {
407
245
  const failures = [];
408
246
  for (const candidate of candidates) {
409
247
  signal?.throwIfAborted();
@@ -419,11 +257,11 @@ async function discoverFirst(candidates, fetchJson, signal, parse, label) {
419
257
  failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`);
420
258
  }
421
259
  }
422
- throw new Error(`MCP ${label} discovery failed (${failures.join("; ")})`);
260
+ throw new Error(`MCP ${label2} discovery failed (${failures.join("; ")})`);
423
261
  }
424
262
  async function requestPinnedJson(rawUrl, options) {
425
- const label = options.label ?? "discovery";
426
- const url = secureOAuthUrl(rawUrl, `${label} URL`);
263
+ const label2 = options.label ?? "discovery";
264
+ const url = secureOAuthUrl(rawUrl, `${label2} URL`);
427
265
  const target = await resolvePinnedAddress(url, options);
428
266
  const timeoutMs = options.timeoutMs ?? 1e4;
429
267
  const maxBytes = options.maxResponseBytes ?? 64 * 1024;
@@ -467,12 +305,12 @@ async function requestPinnedJson(rawUrl, options) {
467
305
  }
468
306
  if (status >= 300 && status < 400) {
469
307
  response.resume();
470
- finish(new Error(`MCP OAuth ${label} redirects are not allowed`));
308
+ finish(new Error(`MCP OAuth ${label2} redirects are not allowed`));
471
309
  return;
472
310
  }
473
311
  if (status < 200 || status >= 300) {
474
312
  response.resume();
475
- finish(new MCPOAuthHttpError(`MCP OAuth ${label} HTTP ${status}`, status));
313
+ finish(new MCPOAuthHttpError(`MCP OAuth ${label2} HTTP ${status}`, status));
476
314
  return;
477
315
  }
478
316
  const contentType = response.headers["content-type"] ?? "";
@@ -516,21 +354,21 @@ async function requestPinnedJson(rawUrl, options) {
516
354
  });
517
355
  }
518
356
  async function resolvePinnedAddress(url, options) {
519
- const hostname = unbracket(url.hostname).toLowerCase();
520
- const literalFamily = net.isIP(hostname);
357
+ const hostname2 = unbracket(url.hostname).toLowerCase();
358
+ const literalFamily = net.isIP(hostname2);
521
359
  if (literalFamily === 4 || literalFamily === 6) {
522
360
  assertDiscoveryAddressAllowed(
523
- hostname,
361
+ hostname2,
524
362
  literalFamily,
525
- hostname,
363
+ hostname2,
526
364
  options.allowedLoopbackHostname
527
365
  );
528
- return { address: hostname, family: literalFamily };
366
+ return { address: hostname2, family: literalFamily };
529
367
  }
530
368
  const lookup3 = options.lookup ?? ((host) => dns.lookup(host, { all: true }));
531
- const records = await lookup3(hostname);
369
+ const records = await lookup3(hostname2);
532
370
  if (records.length === 0)
533
- throw new Error(`MCP OAuth discovery DNS returned no addresses for ${hostname}`);
371
+ throw new Error(`MCP OAuth discovery DNS returned no addresses for ${hostname2}`);
534
372
  for (const record3 of records) {
535
373
  if (record3.family !== 4 && record3.family !== 6) {
536
374
  throw new Error("MCP OAuth discovery DNS returned an unsupported address family");
@@ -538,22 +376,194 @@ async function resolvePinnedAddress(url, options) {
538
376
  assertDiscoveryAddressAllowed(
539
377
  record3.address,
540
378
  record3.family,
541
- hostname,
379
+ hostname2,
542
380
  options.allowedLoopbackHostname
543
381
  );
544
382
  }
545
383
  const selected = records[0];
546
384
  return { address: selected.address, family: selected.family };
547
385
  }
548
- function assertDiscoveryAddressAllowed(address, family, hostname, allowedLoopbackHostname) {
386
+ function assertDiscoveryAddressAllowed(address, family, hostname2, allowedLoopbackHostname) {
549
387
  const isPrivate = family === 4 ? isPrivateIPv4(address) : isPrivateIPv6(address);
550
388
  if (!isPrivate) return;
551
389
  const loopback = family === 4 ? address.startsWith("127.") : address === "::1";
552
- if (loopback && hostname === allowedLoopbackHostname) return;
390
+ if (loopback && hostname2 === allowedLoopbackHostname) return;
553
391
  throw new Error(`MCP OAuth discovery blocked private address ${address}`);
554
392
  }
555
- function unbracket(hostname) {
556
- return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
393
+ function unbracket(hostname2) {
394
+ return hostname2.startsWith("[") && hostname2.endsWith("]") ? hostname2.slice(1, -1) : hostname2;
395
+ }
396
+ function isLoopbackHttp(url) {
397
+ if (url.protocol !== "http:") return false;
398
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
399
+ }
400
+
401
+ // src/authorization.ts
402
+ function authorizationHeaderForToken(token, expectedResource, now = Date.now()) {
403
+ if (canonicalMcpResource(token.resource) !== expectedResource) {
404
+ throw new Error("MCP access token resource does not match the target server");
405
+ }
406
+ if (token.expiresAt !== void 0 && token.expiresAt <= now) {
407
+ throw new Error("MCP access token is expired");
408
+ }
409
+ const tokenType = token.tokenType ?? "Bearer";
410
+ if (tokenType.toLowerCase() !== "bearer") {
411
+ throw new Error(`Unsupported MCP OAuth token type "${tokenType}"`);
412
+ }
413
+ if (!token.accessToken || token.accessToken.length > 16384 || /[\r\n]/.test(token.accessToken)) {
414
+ throw new Error("MCP access token is empty, oversized, or contains invalid characters");
415
+ }
416
+ return `Bearer ${token.accessToken}`;
417
+ }
418
+ function validateMcpAuthorizationServerMetadata(value) {
419
+ const metadata = record(value, "stored authorization server metadata");
420
+ const registration = optionalString(metadata["registrationEndpoint"], "registrationEndpoint");
421
+ const issuer = secureOAuthUrl(requiredString(metadata["issuer"], "issuer"), "issuer").toString();
422
+ const authorizationEndpoint = secureOAuthUrl(
423
+ requiredString(metadata["authorizationEndpoint"], "authorizationEndpoint"),
424
+ "authorization endpoint"
425
+ ).toString();
426
+ const tokenEndpoint = secureOAuthUrl(
427
+ requiredString(metadata["tokenEndpoint"], "tokenEndpoint"),
428
+ "token endpoint"
429
+ ).toString();
430
+ const issuerParameterSupported = optionalBoolean(
431
+ metadata["authorizationResponseIssuerParameterSupported"],
432
+ "authorizationResponseIssuerParameterSupported"
433
+ );
434
+ if (issuerParameterSupported !== true && (new URL(authorizationEndpoint).origin !== new URL(issuer).origin || new URL(tokenEndpoint).origin !== new URL(issuer).origin)) {
435
+ throw new Error(
436
+ "MCP authorization server with cross-origin endpoints must support the authorization response issuer parameter"
437
+ );
438
+ }
439
+ return {
440
+ issuer,
441
+ authorizationEndpoint,
442
+ tokenEndpoint,
443
+ registrationEndpoint: registration ? secureOAuthUrl(registration, "registration endpoint").toString() : void 0,
444
+ authorizationResponseIssuerParameterSupported: issuerParameterSupported,
445
+ scopesSupported: optionalStringArray(metadata["scopesSupported"], "scopesSupported", 128)
446
+ };
447
+ }
448
+ function createMcpAuthorizationRequest(options) {
449
+ const resource = canonicalMcpResource(options.resource);
450
+ const clientId = boundedCredential(options.clientId, "client id");
451
+ const redirectUri = validateRedirectUri(options.redirectUri);
452
+ const scopes = validateScopes(options.scopes ?? []);
453
+ const codeVerifier = base64Url(randomBytes(32));
454
+ const codeChallenge = base64Url(createHash("sha256").update(codeVerifier).digest());
455
+ const state = base64Url(randomBytes(32));
456
+ const authorizationUrl = secureOAuthUrl(
457
+ options.authorizationServer.authorizationEndpoint,
458
+ "authorization endpoint"
459
+ );
460
+ authorizationUrl.searchParams.set("response_type", "code");
461
+ authorizationUrl.searchParams.set("client_id", clientId);
462
+ authorizationUrl.searchParams.set("redirect_uri", redirectUri);
463
+ authorizationUrl.searchParams.set("state", state);
464
+ authorizationUrl.searchParams.set("code_challenge", codeChallenge);
465
+ authorizationUrl.searchParams.set("code_challenge_method", "S256");
466
+ authorizationUrl.searchParams.set("resource", resource);
467
+ if (scopes.length > 0) authorizationUrl.searchParams.set("scope", scopes.join(" "));
468
+ return {
469
+ authorizationUrl: authorizationUrl.toString(),
470
+ state,
471
+ codeVerifier,
472
+ redirectUri,
473
+ clientId,
474
+ resource,
475
+ issuer: options.authorizationServer.issuer,
476
+ requireIssuerParameter: options.authorizationServer.authorizationResponseIssuerParameterSupported === true
477
+ };
478
+ }
479
+ function parseMcpAuthorizationCallback(callbackUrl, session) {
480
+ let callback;
481
+ try {
482
+ callback = new URL(callbackUrl);
483
+ } catch {
484
+ throw new Error("MCP OAuth callback must be an absolute URL");
485
+ }
486
+ const expected = new URL(validateRedirectUri(session.redirectUri));
487
+ if (callback.protocol !== expected.protocol || callback.hostname !== expected.hostname || callback.port !== expected.port || callback.pathname !== expected.pathname) {
488
+ throw new Error("MCP OAuth callback redirect URI does not match the authorization session");
489
+ }
490
+ const returnedStates = callback.searchParams.getAll("state");
491
+ if (returnedStates.length !== 1) {
492
+ throw new Error("MCP OAuth callback must contain exactly one state parameter");
493
+ }
494
+ const returnedState = returnedStates[0];
495
+ if (!constantTimeEqual(returnedState, session.state)) {
496
+ throw new Error("MCP OAuth callback state mismatch");
497
+ }
498
+ const returnedIssuers = callback.searchParams.getAll("iss");
499
+ if (session.requireIssuerParameter && returnedIssuers.length !== 1) {
500
+ throw new Error("MCP OAuth callback must contain exactly one issuer parameter");
501
+ }
502
+ if (returnedIssuers.length > 0 && returnedIssuers.some((issuer) => issuer !== session.issuer)) {
503
+ throw new Error("MCP OAuth callback issuer mismatch");
504
+ }
505
+ const oauthErrors = callback.searchParams.getAll("error");
506
+ const codes = callback.searchParams.getAll("code");
507
+ if (oauthErrors.length > 1 || codes.length > 1 || oauthErrors.length === 1 && codes.length > 0) {
508
+ throw new Error("MCP OAuth callback contains ambiguous response parameters");
509
+ }
510
+ if (oauthErrors.length === 1) {
511
+ throw new Error(`MCP OAuth authorization failed: ${boundedErrorCode(oauthErrors[0])}`);
512
+ }
513
+ if (codes.length !== 1) {
514
+ throw new Error("MCP OAuth callback must contain exactly one authorization code");
515
+ }
516
+ return boundedCredential(codes[0], "authorization code");
517
+ }
518
+ async function exchangeMcpAuthorizationCode(options) {
519
+ const resource = canonicalMcpResource(options.resource);
520
+ const body = new URLSearchParams({
521
+ grant_type: "authorization_code",
522
+ code: boundedCredential(options.code, "authorization code"),
523
+ client_id: boundedCredential(options.clientId, "client id"),
524
+ redirect_uri: validateRedirectUri(options.redirectUri),
525
+ code_verifier: validateCodeVerifier(options.codeVerifier),
526
+ resource,
527
+ ...options.clientSecret ? { client_secret: boundedCredential(options.clientSecret, "client secret") } : {}
528
+ }).toString();
529
+ const response = await requestPinnedJson(options.authorizationServer.tokenEndpoint, {
530
+ method: "POST",
531
+ body,
532
+ headers: { "content-type": "application/x-www-form-urlencoded" },
533
+ signal: options.signal,
534
+ timeoutMs: options.timeoutMs,
535
+ maxResponseBytes: options.maxResponseBytes,
536
+ lookup: options.lookup,
537
+ allowedLoopbackHostname: loopbackHostnameForResource(resource),
538
+ label: "token endpoint"
539
+ });
540
+ if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
541
+ return parseTokenResponse(response, resource);
542
+ }
543
+ async function refreshMcpAccessToken(options) {
544
+ const resource = canonicalMcpResource(options.resource);
545
+ const previousRefreshToken = boundedCredential(options.refreshToken, "refresh token");
546
+ const body = new URLSearchParams({
547
+ grant_type: "refresh_token",
548
+ refresh_token: previousRefreshToken,
549
+ client_id: boundedCredential(options.clientId, "client id"),
550
+ resource,
551
+ ...options.clientSecret ? { client_secret: boundedCredential(options.clientSecret, "client secret") } : {}
552
+ }).toString();
553
+ const response = await requestPinnedJson(options.authorizationServer.tokenEndpoint, {
554
+ method: "POST",
555
+ body,
556
+ headers: { "content-type": "application/x-www-form-urlencoded" },
557
+ signal: options.signal,
558
+ timeoutMs: options.timeoutMs,
559
+ maxResponseBytes: options.maxResponseBytes,
560
+ lookup: options.lookup,
561
+ allowedLoopbackHostname: loopbackHostnameForResource(resource),
562
+ label: "token endpoint"
563
+ });
564
+ if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
565
+ const parsed = parseTokenResponse(response, resource);
566
+ return { ...parsed, refreshToken: parsed.refreshToken ?? previousRefreshToken };
557
567
  }
558
568
  function validateRedirectUri(value) {
559
569
  let url;
@@ -586,9 +596,9 @@ function validateCodeVerifier(value) {
586
596
  }
587
597
  return value;
588
598
  }
589
- function boundedCredential(value, label) {
599
+ function boundedCredential(value, label2) {
590
600
  if (!value || value.length > 16384 || /[\r\n]/.test(value)) {
591
- throw new Error(`MCP OAuth ${label} is empty, oversized, or invalid`);
601
+ throw new Error(`MCP OAuth ${label2} is empty, oversized, or invalid`);
592
602
  }
593
603
  return value;
594
604
  }
@@ -638,14 +648,213 @@ function loopbackHostnameForResource(resource) {
638
648
  const url = new URL(resource);
639
649
  return isLoopbackHttp(url) ? unbracket(url.hostname).toLowerCase() : void 0;
640
650
  }
641
- function isLoopbackHttp(url) {
642
- if (url.protocol !== "http:") return false;
643
- return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
651
+
652
+ // src/authorization-callback-server.ts
653
+ import * as http2 from "node:http";
654
+ var DEFAULT_PATH = "/callback";
655
+ var DEFAULT_TIMEOUT_MS = 5 * 6e4;
656
+ var MAX_REQUESTS = 16;
657
+ async function startMcpOAuthCallbackServer(options = {}) {
658
+ const path2 = normalizePath(options.path ?? DEFAULT_PATH);
659
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
660
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
661
+ throw new Error("MCP OAuth callback timeout must be a positive finite number");
662
+ }
663
+ options.signal?.throwIfAborted();
664
+ let settle;
665
+ let fail;
666
+ let finished = false;
667
+ let requests = 0;
668
+ const callback = new Promise((resolve, reject) => {
669
+ settle = resolve;
670
+ fail = reject;
671
+ });
672
+ callback.catch(() => void 0);
673
+ const server = http2.createServer((req, res) => {
674
+ if (finished || ++requests > MAX_REQUESTS) {
675
+ res.writeHead(429).end();
676
+ return;
677
+ }
678
+ const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
679
+ if (req.method !== "GET" || requestUrl.pathname !== path2) {
680
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
681
+ return;
682
+ }
683
+ finished = true;
684
+ res.writeHead(200, {
685
+ "content-type": "text/html; charset=utf-8",
686
+ // The URL carries a one-shot authorization code; keep it out of caches
687
+ // and out of the next site the user visits.
688
+ "cache-control": "no-store",
689
+ "referrer-policy": "no-referrer"
690
+ }).end(SUCCESS_PAGE);
691
+ const address2 = server.address();
692
+ const port = address2?.port ?? 0;
693
+ settle?.(`http://127.0.0.1:${port}${req.url ?? path2}`);
694
+ });
695
+ server.on("error", (error) => {
696
+ finished = true;
697
+ fail?.(error);
698
+ });
699
+ await new Promise((resolve, reject) => {
700
+ server.once("error", reject);
701
+ server.listen(options.port ?? 0, "127.0.0.1", () => {
702
+ server.removeListener("error", reject);
703
+ resolve();
704
+ });
705
+ });
706
+ const address = server.address();
707
+ if (!address) {
708
+ server.close();
709
+ throw new Error("MCP OAuth callback listener failed to bind a loopback port");
710
+ }
711
+ const redirectUri = `http://127.0.0.1:${address.port}${path2}`;
712
+ const timer = setTimeout(() => {
713
+ finished = true;
714
+ fail?.(new Error(`MCP OAuth callback was not received within ${timeoutMs}ms`));
715
+ close();
716
+ }, timeoutMs);
717
+ timer.unref?.();
718
+ const onAbort = () => {
719
+ finished = true;
720
+ fail?.(
721
+ options.signal?.reason instanceof Error ? options.signal.reason : new Error("MCP OAuth callback wait was aborted")
722
+ );
723
+ close();
724
+ };
725
+ options.signal?.addEventListener("abort", onAbort, { once: true });
726
+ function close() {
727
+ clearTimeout(timer);
728
+ options.signal?.removeEventListener("abort", onAbort);
729
+ server.closeAllConnections?.();
730
+ server.close(() => void 0);
731
+ }
732
+ return {
733
+ redirectUri,
734
+ waitForCallback: () => callback.finally(close),
735
+ close
736
+ };
737
+ }
738
+ function normalizePath(value) {
739
+ if (!value.startsWith("/") || value.length > 256 || /[?#\s]/.test(value)) {
740
+ throw new Error("MCP OAuth callback path must be a bounded absolute path without query");
741
+ }
742
+ return value;
743
+ }
744
+ var SUCCESS_PAGE = `<!doctype html>
745
+ <html lang="en"><head><meta charset="utf-8"><title>Authorized</title></head>
746
+ <body style="font-family:system-ui,sans-serif;padding:3rem;text-align:center">
747
+ <h1>Authorization complete</h1>
748
+ <p>You can close this tab and return to WrongStack.</p>
749
+ </body></html>
750
+ `;
751
+
752
+ // src/authorization-registration.ts
753
+ var DEFAULT_CLIENT_NAME = "WrongStack";
754
+ var MAX_REDIRECT_URIS = 8;
755
+ async function registerMcpOAuthClient(options) {
756
+ const endpoint = options.authorizationServer.registrationEndpoint;
757
+ if (!endpoint) {
758
+ throw new Error(
759
+ "MCP authorization server does not support dynamic client registration; pass an explicit client id"
760
+ );
761
+ }
762
+ const redirectUris = validateRedirectUris(options.redirectUris);
763
+ const scopes = options.scopes?.filter(Boolean) ?? [];
764
+ const body = JSON.stringify({
765
+ client_name: boundedClientName(options.clientName ?? DEFAULT_CLIENT_NAME),
766
+ redirect_uris: redirectUris,
767
+ grant_types: ["authorization_code", "refresh_token"],
768
+ response_types: ["code"],
769
+ // A CLI is a public client: it cannot hold a secret that the user could not
770
+ // also read. Asking for `none` keeps the PKCE-only exchange we implement.
771
+ token_endpoint_auth_method: "none",
772
+ application_type: "native",
773
+ ...options.clientUri ? { client_uri: options.clientUri } : {},
774
+ ...scopes.length > 0 ? { scope: scopes.join(" ") } : {}
775
+ });
776
+ const request3 = options.requestJson ?? requestPinnedJson;
777
+ const response = await request3(endpoint, {
778
+ method: "POST",
779
+ body,
780
+ headers: { "content-type": "application/json" },
781
+ signal: options.signal,
782
+ timeoutMs: options.timeoutMs,
783
+ maxResponseBytes: options.maxResponseBytes,
784
+ lookup: options.lookup,
785
+ allowedLoopbackHostname: loopbackHostnameForResource2(options.resource),
786
+ label: "client registration endpoint"
787
+ });
788
+ if (response === void 0) {
789
+ throw new Error("MCP OAuth client registration endpoint returned no response");
790
+ }
791
+ return parseRegistrationResponse(response, options.authorizationServer.issuer);
792
+ }
793
+ function parseRegistrationResponse(value, issuer) {
794
+ const metadata = record(value, "client registration response");
795
+ const clientId = requiredString(metadata["client_id"], "client_id");
796
+ if (/[\r\n]/.test(clientId)) {
797
+ throw new Error("MCP OAuth client registration returned an invalid client_id");
798
+ }
799
+ const clientSecret = optionalString(metadata["client_secret"], "client_secret");
800
+ if (clientSecret !== void 0 && /[\r\n]/.test(clientSecret)) {
801
+ throw new Error("MCP OAuth client registration returned an invalid client_secret");
802
+ }
803
+ const expiresAt = metadata["client_secret_expires_at"];
804
+ if (clientSecret !== void 0 && typeof expiresAt === "number" && expiresAt !== 0) {
805
+ if (!Number.isFinite(expiresAt) || expiresAt * 1e3 <= Date.now()) {
806
+ throw new Error("MCP OAuth client registration returned an already-expired client secret");
807
+ }
808
+ }
809
+ return {
810
+ clientId,
811
+ ...clientSecret !== void 0 ? { clientSecret } : {},
812
+ issuer,
813
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString()
814
+ };
815
+ }
816
+ function validateRedirectUris(values) {
817
+ if (values.length === 0 || values.length > MAX_REDIRECT_URIS) {
818
+ throw new Error(
819
+ `MCP OAuth client registration requires between 1 and ${MAX_REDIRECT_URIS} redirect URIs`
820
+ );
821
+ }
822
+ return values.map((value) => {
823
+ let url;
824
+ try {
825
+ url = new URL(value);
826
+ } catch {
827
+ throw new Error("MCP OAuth redirect URI must be an absolute URL");
828
+ }
829
+ if (url.protocol !== "https:" && !isLoopbackHttp(url)) {
830
+ throw new Error("MCP OAuth redirect URI must use HTTPS or loopback HTTP");
831
+ }
832
+ if (url.username || url.password || url.search || url.hash) {
833
+ throw new Error("MCP OAuth redirect URI must not contain credentials, query, or fragment");
834
+ }
835
+ return url.toString();
836
+ });
837
+ }
838
+ function boundedClientName(value) {
839
+ if (!value || value.length > 128 || /[\r\n]/.test(value)) {
840
+ throw new Error("MCP OAuth client name must be a bounded single-line string");
841
+ }
842
+ return value;
843
+ }
844
+ function loopbackHostnameForResource2(resource) {
845
+ if (!resource) return void 0;
846
+ try {
847
+ const url = new URL(resource);
848
+ return isLoopbackHttp(url) ? unbracket(url.hostname).toLowerCase() : void 0;
849
+ } catch {
850
+ return void 0;
851
+ }
644
852
  }
645
853
 
646
854
  // src/authorization-manager.ts
647
855
  var DEFAULT_PENDING_TTL_MS = 10 * 6e4;
648
856
  var MAX_PENDING_AUTHORIZATIONS = 32;
857
+ var MAX_CACHED_REGISTRATIONS = 32;
649
858
  var MCPAuthorizationManager = class {
650
859
  constructor(options) {
651
860
  this.options = options;
@@ -655,14 +864,18 @@ var MCPAuthorizationManager = class {
655
864
  }
656
865
  this.discover = options.discover ?? discoverMcpAuthorization;
657
866
  this.exchange = options.exchange ?? exchangeMcpAuthorizationCode;
867
+ this.register = options.register ?? registerMcpOAuthClient;
658
868
  this.now = options.now ?? Date.now;
659
869
  }
660
870
  options;
661
871
  pending = /* @__PURE__ */ new Map();
662
872
  starting = /* @__PURE__ */ new Map();
873
+ /** Registrations reused across servers that share one authorization server. */
874
+ registrations = /* @__PURE__ */ new Map();
663
875
  pendingTtlMs;
664
876
  discover;
665
877
  exchange;
878
+ register;
666
879
  now;
667
880
  async begin(input) {
668
881
  const resource = canonicalMcpResource(input.resource);
@@ -689,23 +902,42 @@ var MCPAuthorizationManager = class {
689
902
  resource
690
903
  ).scopes;
691
904
  const scopes = input.scopes ? [...input.scopes] : challengeScopes;
905
+ const identity = await this.resolveClientIdentity({
906
+ serverName: input.serverName,
907
+ resource,
908
+ discovery,
909
+ redirectUri: input.redirectUri,
910
+ explicitClientId: input.clientId,
911
+ scopes,
912
+ signal: input.signal
913
+ });
914
+ if (this.starting.get(key) !== attempt) {
915
+ throw new Error("MCP authorization discovery was cancelled");
916
+ }
692
917
  const session = createMcpAuthorizationRequest({
693
918
  authorizationServer: discovery.authorizationServer,
694
- clientId: input.clientId,
919
+ clientId: identity.clientId,
695
920
  redirectUri: input.redirectUri,
696
921
  resource,
697
922
  scopes
698
923
  });
699
924
  const normalizedScopes = new URL(session.authorizationUrl).searchParams.get("scope")?.split(" ").filter(Boolean) ?? [];
700
925
  const expiresAt = this.now() + this.pendingTtlMs;
701
- this.pending.set(key, { session, discovery, scopes: normalizedScopes, expiresAt });
926
+ this.pending.set(key, {
927
+ session,
928
+ discovery,
929
+ scopes: normalizedScopes,
930
+ expiresAt,
931
+ clientSecret: identity.clientSecret
932
+ });
702
933
  return {
703
934
  serverName: boundedServerName(input.serverName),
704
935
  resource,
705
936
  authorizationUrl: session.authorizationUrl,
706
937
  redirectUri: session.redirectUri,
707
938
  scopes: [...normalizedScopes],
708
- expiresAt
939
+ expiresAt,
940
+ clientIdSource: identity.source
709
941
  };
710
942
  } finally {
711
943
  if (this.starting.get(key) === attempt) this.starting.delete(key);
@@ -725,6 +957,7 @@ var MCPAuthorizationManager = class {
725
957
  const tokenSet = await this.exchange({
726
958
  authorizationServer: pending.discovery.authorizationServer,
727
959
  clientId: pending.session.clientId,
960
+ clientSecret: pending.clientSecret,
728
961
  redirectUri: pending.session.redirectUri,
729
962
  resource,
730
963
  code,
@@ -735,13 +968,95 @@ var MCPAuthorizationManager = class {
735
968
  serverName,
736
969
  resource,
737
970
  clientId: pending.session.clientId,
971
+ ...pending.clientSecret ? { clientSecret: pending.clientSecret } : {},
738
972
  authorizationServer: pending.discovery.authorizationServer,
739
973
  tokenSet,
740
974
  updatedAt: new Date(this.now()).toISOString()
741
975
  };
742
- await this.options.store.save(stored);
743
- this.emit("authorized", stored);
744
- return statusFromStored(stored, this.now());
976
+ await this.options.store.save(stored);
977
+ this.emit("authorized", stored);
978
+ return statusFromStored(stored, this.now());
979
+ }
980
+ /**
981
+ * Bind a loopback receiver and start the flow, returning as soon as the URL
982
+ * exists so a surface can show it without blocking. Completion is awaited
983
+ * separately — a slash command returns its message immediately and reports
984
+ * the outcome through `onStateChange`. The two-step `begin`/`complete` pair
985
+ * stays for surfaces that cannot host a listener.
986
+ */
987
+ async beginLogin(input) {
988
+ const callbackOptions = {
989
+ port: input.port,
990
+ timeoutMs: input.timeoutMs,
991
+ signal: input.signal
992
+ };
993
+ const listener = await startMcpOAuthCallbackServer(callbackOptions);
994
+ let started;
995
+ try {
996
+ started = await this.begin({
997
+ serverName: input.serverName,
998
+ resource: input.resource,
999
+ clientId: input.clientId,
1000
+ redirectUri: listener.redirectUri,
1001
+ scopes: input.scopes,
1002
+ challengeHeader: input.challengeHeader,
1003
+ signal: input.signal
1004
+ });
1005
+ } catch (error) {
1006
+ listener.close();
1007
+ throw error;
1008
+ }
1009
+ const completion = listener.waitForCallback().then(
1010
+ (callbackUrl) => this.complete({
1011
+ serverName: input.serverName,
1012
+ resource: input.resource,
1013
+ callbackUrl,
1014
+ signal: input.signal
1015
+ })
1016
+ ).finally(() => listener.close());
1017
+ return { started, completion, cancel: () => listener.close() };
1018
+ }
1019
+ /**
1020
+ * Identity precedence: an explicit id, then the one already stored for this
1021
+ * server (so re-authorizing does not orphan a registration), then the
1022
+ * per-issuer cache, then a fresh dynamic registration.
1023
+ */
1024
+ async resolveClientIdentity(input) {
1025
+ if (input.explicitClientId) {
1026
+ return { clientId: input.explicitClientId, source: "explicit" };
1027
+ }
1028
+ const issuer = normalizeIssuer(input.discovery.authorizationServer.issuer);
1029
+ const stored = await this.options.store.load(boundedServerName(input.serverName), input.resource).catch(() => void 0);
1030
+ if (stored && normalizeIssuer(stored.authorizationServer.issuer) === issuer) {
1031
+ return {
1032
+ clientId: stored.clientId,
1033
+ clientSecret: stored.clientSecret,
1034
+ source: "stored"
1035
+ };
1036
+ }
1037
+ const registrationKey = `${issuer}\0${input.redirectUri}`;
1038
+ const cached = this.registrations.get(registrationKey);
1039
+ if (cached) {
1040
+ return { clientId: cached.clientId, clientSecret: cached.clientSecret, source: "registered" };
1041
+ }
1042
+ const registration = await this.register({
1043
+ authorizationServer: input.discovery.authorizationServer,
1044
+ redirectUris: [input.redirectUri],
1045
+ clientName: this.options.clientName,
1046
+ scopes: input.scopes,
1047
+ resource: input.resource,
1048
+ signal: input.signal
1049
+ });
1050
+ if (this.registrations.size >= MAX_CACHED_REGISTRATIONS) {
1051
+ const oldest = this.registrations.keys().next();
1052
+ if (!oldest.done) this.registrations.delete(oldest.value);
1053
+ }
1054
+ this.registrations.set(registrationKey, registration);
1055
+ return {
1056
+ clientId: registration.clientId,
1057
+ clientSecret: registration.clientSecret,
1058
+ source: "registered"
1059
+ };
745
1060
  }
746
1061
  async status(serverName, resource) {
747
1062
  const normalizedName = boundedServerName(serverName);
@@ -816,6 +1131,9 @@ function statusFromStored(value, now) {
816
1131
  canRefresh: !!value.tokenSet.refreshToken
817
1132
  };
818
1133
  }
1134
+ function normalizeIssuer(value) {
1135
+ return secureOAuthUrl(value, "authorization server issuer").toString();
1136
+ }
819
1137
  function authorizationKey(serverName, resource) {
820
1138
  return `${boundedServerName(serverName)}\0${resource}`;
821
1139
  }
@@ -833,17 +1151,17 @@ import { buildChildEnv as buildChildEnv2, buildWin32CmdShimInvocation, toErrorMe
833
1151
 
834
1152
  // src/client-protocol-helpers.ts
835
1153
  var MAX_PROTOCOL_INPUT_CHARS = 8192;
836
- function validateProtocolString(value, label, allowEmpty = false) {
1154
+ function validateProtocolString(value, label2, allowEmpty = false) {
837
1155
  if (typeof value !== "string" || !allowEmpty && value.length === 0) {
838
- throw new Error(`MCP ${label} must be ${allowEmpty ? "a string" : "a non-empty string"}`);
1156
+ throw new Error(`MCP ${label2} must be ${allowEmpty ? "a string" : "a non-empty string"}`);
839
1157
  }
840
1158
  if (value.length > MAX_PROTOCOL_INPUT_CHARS) {
841
- throw new Error(`MCP ${label} exceeds ${MAX_PROTOCOL_INPUT_CHARS} characters`);
1159
+ throw new Error(`MCP ${label2} exceeds ${MAX_PROTOCOL_INPUT_CHARS} characters`);
842
1160
  }
843
1161
  }
844
- function pageParams(cursor, label) {
1162
+ function pageParams(cursor, label2) {
845
1163
  if (cursor === void 0) return {};
846
- validateProtocolString(cursor, label);
1164
+ validateProtocolString(cursor, label2);
847
1165
  return { cursor };
848
1166
  }
849
1167
  function parseEmptyResult(value) {
@@ -853,29 +1171,29 @@ function parseEmptyResult(value) {
853
1171
  }
854
1172
 
855
1173
  // src/protocol.ts
856
- function record2(value, label) {
1174
+ function record2(value, label2) {
857
1175
  if (!value || typeof value !== "object" || Array.isArray(value)) {
858
- throw new Error(`Malformed MCP ${label}: expected object`);
1176
+ throw new Error(`Malformed MCP ${label2}: expected object`);
859
1177
  }
860
1178
  return value;
861
1179
  }
862
- function requiredString2(value, label) {
1180
+ function requiredString2(value, label2) {
863
1181
  if (typeof value !== "string" || value.length === 0) {
864
- throw new Error(`Malformed MCP ${label}: expected non-empty string`);
1182
+ throw new Error(`Malformed MCP ${label2}: expected non-empty string`);
865
1183
  }
866
1184
  return value;
867
1185
  }
868
- function optionalString2(value, label) {
1186
+ function optionalString2(value, label2) {
869
1187
  if (value === void 0) return void 0;
870
- if (typeof value !== "string") throw new Error(`Malformed MCP ${label}: expected string`);
1188
+ if (typeof value !== "string") throw new Error(`Malformed MCP ${label2}: expected string`);
871
1189
  return value;
872
1190
  }
873
- function optionalRecord(value, label) {
1191
+ function optionalRecord(value, label2) {
874
1192
  if (value === void 0) return void 0;
875
- return record2(value, label);
1193
+ return record2(value, label2);
876
1194
  }
877
- function optionalCursor(value, label) {
878
- return optionalString2(value, `${label}.nextCursor`);
1195
+ function optionalCursor(value, label2) {
1196
+ return optionalString2(value, `${label2}.nextCursor`);
879
1197
  }
880
1198
  function parseServerMetadata(value) {
881
1199
  const input = record2(value, "initialize result");
@@ -1052,6 +1370,13 @@ function parseGetPromptResult(value) {
1052
1370
  })
1053
1371
  };
1054
1372
  }
1373
+ function resourceUpdatedUri(params) {
1374
+ if (!params || typeof params !== "object" || Array.isArray(params)) return void 0;
1375
+ const uri = params.uri;
1376
+ if (typeof uri !== "string" || uri.length === 0 || uri.length > 4096) return void 0;
1377
+ if (/[\r\n]/.test(uri)) return void 0;
1378
+ return uri;
1379
+ }
1055
1380
 
1056
1381
  // src/client-capabilities.ts
1057
1382
  var MCPCapabilityClient = class {
@@ -1188,116 +1513,6 @@ function forceKillTree(child) {
1188
1513
  }
1189
1514
  }
1190
1515
 
1191
- // src/constants.ts
1192
- var SUPPORTED_PROTOCOL_VERSIONS = Object.freeze(["2024-11-05"]);
1193
- function negotiateProtocolVersion(requested) {
1194
- if (typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) {
1195
- return requested;
1196
- }
1197
- return MCP_CONSTANTS.PROTOCOL_VERSION;
1198
- }
1199
- var MCP_CONSTANTS = Object.freeze({
1200
- /** MCP protocol version advertised during handshake: the newest we implement. */
1201
- PROTOCOL_VERSION: SUPPORTED_PROTOCOL_VERSIONS[0],
1202
- /** Identity announced to MCP servers during `initialize`. */
1203
- CLIENT_INFO: Object.freeze({
1204
- name: "wrongstack",
1205
- version: "0.1.10"
1206
- }),
1207
- /** Reconnection behaviour when a transport disconnects. */
1208
- RECONNECT: Object.freeze({
1209
- /** Max full reconnect cycles before the slot is marked `failed`. */
1210
- MAX_CYCLES: 5,
1211
- /** Base delay between cycles (exponential backoff applied on top). */
1212
- BASE_DELAY_MS: 1e3,
1213
- /** Jitter factor applied to the backoff (0 = no jitter, 1 = full). */
1214
- JITTER_FACTOR: 0.2,
1215
- /** Max connection attempts within a single cycle. */
1216
- MAX_ATTEMPTS: 3,
1217
- /** Base multiplier for the exponential backoff formula (`delay = BASE * multiplier^attempt`). */
1218
- BACKOFF_MULTIPLIER: 2
1219
- }),
1220
- /** Timing for graceful / forced disconnect. */
1221
- DISCONNECT: Object.freeze({
1222
- /** Ms to wait for in-flight requests to complete before force-closing. */
1223
- GRACEFUL_MS: 800,
1224
- /** Ms after which the force disconnect is triggered. */
1225
- FORCE_TIMEOUT_MS: 1200
1226
- }),
1227
- /** Lazy-connect idle lifecycle. */
1228
- IDLE: Object.freeze({
1229
- /** Default ms a lazy server stays connected with no tool calls before auto-sleep. */
1230
- DEFAULT_TIMEOUT_MS: 3e5,
1231
- /** How often the idle sweep runs (kept well below the timeout). */
1232
- SWEEP_INTERVAL_MS: 3e4
1233
- }),
1234
- /** JSON-RPC response timeout for outstanding requests. */
1235
- RESPONSE_TIMEOUT_MS: 500,
1236
- /** Max buffer size for the SSE reader. */
1237
- SSE_READER_MAX_BUFFER: 256 * 1024,
1238
- /** Max characters logged from a request body. */
1239
- REQUEST_LOG_CAP: 1024
1240
- });
1241
-
1242
- // src/tool-schema.ts
1243
- var MAX_TOOL_PAGES = 100;
1244
- var MAX_TOOLS = 1e4;
1245
- async function listAllTools(requestPage) {
1246
- const tools = [];
1247
- const seenCursors = /* @__PURE__ */ new Set();
1248
- let cursor;
1249
- for (let page = 0; page < MAX_TOOL_PAGES; page++) {
1250
- let response;
1251
- try {
1252
- response = await requestPage(cursor ? { cursor } : {});
1253
- } catch (err) {
1254
- if (page === 0) throw err;
1255
- break;
1256
- }
1257
- if (response.error) {
1258
- if (page === 0) return null;
1259
- break;
1260
- }
1261
- const result = response.result;
1262
- tools.push(...normalizeMCPTools(result?.tools));
1263
- const next = result?.nextCursor;
1264
- if (typeof next !== "string" || next.length === 0) break;
1265
- if (seenCursors.has(next) || tools.length >= MAX_TOOLS) break;
1266
- seenCursors.add(next);
1267
- cursor = next;
1268
- }
1269
- return tools.slice(0, MAX_TOOLS);
1270
- }
1271
- function normalizeMCPTools(value) {
1272
- if (!Array.isArray(value)) return [];
1273
- const tools = [];
1274
- for (const raw of value) {
1275
- if (!raw || typeof raw !== "object") continue;
1276
- const t = raw;
1277
- if (typeof t.name !== "string") continue;
1278
- const name = t.name.trim();
1279
- if (name.length === 0) continue;
1280
- const inputSchema = t.inputSchema && typeof t.inputSchema === "object" && !Array.isArray(t.inputSchema) ? t.inputSchema : { type: "object", properties: {} };
1281
- if (!t.inputSchema || typeof t.inputSchema !== "object" || Array.isArray(t.inputSchema)) {
1282
- console.warn(
1283
- JSON.stringify({
1284
- level: "warn",
1285
- event: "mcp.tool_schema_invalid",
1286
- tool: name,
1287
- message: "no/invalid inputSchema \u2014 defaulting to empty object",
1288
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1289
- })
1290
- );
1291
- }
1292
- tools.push({
1293
- name,
1294
- ...typeof t.description === "string" ? { description: t.description } : {},
1295
- inputSchema
1296
- });
1297
- }
1298
- return tools;
1299
- }
1300
-
1301
1516
  // src/sse-reader.ts
1302
1517
  import { ToolError } from "@wrongstack/core/types";
1303
1518
  var SSE_READER_MAX_BUFFER = 256 * 1024;
@@ -1360,78 +1575,451 @@ var SSEReader = class {
1360
1575
  }
1361
1576
  if (start > 0) this.buffer = this.buffer.slice(start);
1362
1577
  }
1363
- processLine(line) {
1364
- if (line === "") {
1365
- this.flush();
1366
- return;
1578
+ processLine(line) {
1579
+ if (line === "") {
1580
+ this.flush();
1581
+ return;
1582
+ }
1583
+ if (line.startsWith(":")) return;
1584
+ const colonIdx = line.indexOf(":");
1585
+ const field = colonIdx === -1 ? line : line.slice(0, colonIdx);
1586
+ let value = colonIdx === -1 ? "" : line.slice(colonIdx + 1);
1587
+ if (value.startsWith(" ")) value = value.slice(1);
1588
+ if (field === "event") {
1589
+ this.eventName = value;
1590
+ } else if (field === "data") {
1591
+ if (this.dataLines.length >= SSE_READER_MAX_DATA_LINES) {
1592
+ throw new ToolError({
1593
+ message: `SSE: exceeded ${SSE_READER_MAX_DATA_LINES} data lines per event \u2014 upstream is not sending blank-line delimiters`,
1594
+ code: "TOOL_EXECUTION_FAILED",
1595
+ toolName: "mcp_transport_sse_reader",
1596
+ context: {
1597
+ phase: "processLine",
1598
+ dataLineCount: this.dataLines.length,
1599
+ maxDataLines: SSE_READER_MAX_DATA_LINES
1600
+ }
1601
+ });
1602
+ }
1603
+ this.dataLines.push(value);
1604
+ }
1605
+ }
1606
+ flush() {
1607
+ const eventName = this.eventName;
1608
+ this.eventName = "";
1609
+ if (this.dataLines.length === 0) {
1610
+ return;
1611
+ }
1612
+ const data = this.dataLines.join("\n").trim();
1613
+ this.dataLines = [];
1614
+ if (!data) return;
1615
+ if (eventName === "endpoint") {
1616
+ for (const cb of this.endpointListeners) {
1617
+ try {
1618
+ cb(data);
1619
+ } catch {
1620
+ }
1621
+ }
1622
+ return;
1623
+ }
1624
+ try {
1625
+ const parsed = JSON.parse(data);
1626
+ this.dispatch(parsed);
1627
+ } catch {
1628
+ }
1629
+ }
1630
+ dispatch(msg) {
1631
+ for (const cb of this.listeners) {
1632
+ try {
1633
+ cb(msg);
1634
+ } catch {
1635
+ }
1636
+ }
1637
+ }
1638
+ reset() {
1639
+ this.buffer = "";
1640
+ this.dataLines = [];
1641
+ this.eventName = "";
1642
+ this.listeners = [];
1643
+ this.endpointListeners = [];
1644
+ }
1645
+ };
1646
+
1647
+ // src/transport-base.ts
1648
+ import * as https2 from "node:https";
1649
+ import { ConfigError as ConfigError2 } from "@wrongstack/core/types";
1650
+
1651
+ // src/elicitation.ts
1652
+ var ELICITATION_MAX_WAIT_MS = 10 * 6e4;
1653
+ var MAX_FIELDS = 32;
1654
+ var MAX_MESSAGE_CHARS = 4e3;
1655
+ var MAX_OPTIONS = 100;
1656
+ function isRecord(v) {
1657
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1658
+ }
1659
+ function optionalString3(v) {
1660
+ return typeof v === "string" && v.trim() ? v : void 0;
1661
+ }
1662
+ function optionalNumber(v) {
1663
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
1664
+ }
1665
+ function optionalCount(v) {
1666
+ return typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : void 0;
1667
+ }
1668
+ function parseOptions(schema) {
1669
+ const values = schema["enum"];
1670
+ if (Array.isArray(values)) {
1671
+ if (!values.every((v) => typeof v === "string")) return void 0;
1672
+ const names = Array.isArray(schema["enumNames"]) ? schema["enumNames"] : [];
1673
+ return values.map((value, i) => ({
1674
+ value,
1675
+ label: typeof names[i] === "string" && names[i] ? names[i] : value
1676
+ }));
1677
+ }
1678
+ const titled = Array.isArray(schema["oneOf"]) ? schema["oneOf"] : Array.isArray(schema["anyOf"]) ? schema["anyOf"] : void 0;
1679
+ if (!titled) return void 0;
1680
+ const options = [];
1681
+ for (const entry of titled) {
1682
+ if (!isRecord(entry) || typeof entry["const"] !== "string") return void 0;
1683
+ options.push({
1684
+ value: entry["const"],
1685
+ label: optionalString3(entry["title"]) ?? entry["const"]
1686
+ });
1687
+ }
1688
+ return options;
1689
+ }
1690
+ function parseField(name, schema, required) {
1691
+ if (!isRecord(schema)) return { ok: false, error: `property "${name}" is not a schema object` };
1692
+ const base = {
1693
+ name,
1694
+ title: optionalString3(schema["title"]),
1695
+ description: optionalString3(schema["description"]),
1696
+ required
1697
+ };
1698
+ const type = schema["type"];
1699
+ if (type === "array") {
1700
+ const items = schema["items"];
1701
+ const options = isRecord(items) ? parseOptions(items) : void 0;
1702
+ if (!options || options.length === 0 || options.length > MAX_OPTIONS) {
1703
+ return { ok: false, error: `property "${name}": arrays must be a list of enum values` };
1704
+ }
1705
+ const defaults = Array.isArray(schema["default"]) ? schema["default"].filter((v) => typeof v === "string") : void 0;
1706
+ return {
1707
+ ok: true,
1708
+ value: {
1709
+ ...base,
1710
+ kind: "multi-enum",
1711
+ options,
1712
+ minItems: optionalCount(schema["minItems"]),
1713
+ maxItems: optionalCount(schema["maxItems"]),
1714
+ default: defaults
1715
+ }
1716
+ };
1717
+ }
1718
+ if (type === "string") {
1719
+ const options = parseOptions(schema);
1720
+ if (options) {
1721
+ if (options.length === 0 || options.length > MAX_OPTIONS) {
1722
+ return { ok: false, error: `property "${name}": enum needs 1-${MAX_OPTIONS} values` };
1723
+ }
1724
+ return {
1725
+ ok: true,
1726
+ value: { ...base, kind: "enum", options, default: optionalString3(schema["default"]) }
1727
+ };
1728
+ }
1729
+ return {
1730
+ ok: true,
1731
+ value: {
1732
+ ...base,
1733
+ kind: "string",
1734
+ minLength: optionalCount(schema["minLength"]),
1735
+ maxLength: optionalCount(schema["maxLength"]),
1736
+ format: optionalString3(schema["format"]),
1737
+ default: typeof schema["default"] === "string" ? schema["default"] : void 0
1738
+ }
1739
+ };
1740
+ }
1741
+ if (type === "number" || type === "integer") {
1742
+ return {
1743
+ ok: true,
1744
+ value: {
1745
+ ...base,
1746
+ kind: type,
1747
+ minimum: optionalNumber(schema["minimum"]),
1748
+ maximum: optionalNumber(schema["maximum"]),
1749
+ default: optionalNumber(schema["default"])
1750
+ }
1751
+ };
1752
+ }
1753
+ if (type === "boolean") {
1754
+ return {
1755
+ ok: true,
1756
+ value: {
1757
+ ...base,
1758
+ kind: "boolean",
1759
+ default: typeof schema["default"] === "boolean" ? schema["default"] : void 0
1760
+ }
1761
+ };
1762
+ }
1763
+ return {
1764
+ ok: false,
1765
+ error: `property "${name}" has unsupported type ${JSON.stringify(type)} \u2014 only flat primitive fields can be elicited`
1766
+ };
1767
+ }
1768
+ function parseUrlElicitation(params) {
1769
+ if (!isRecord(params)) return { ok: false, error: "params must be an object" };
1770
+ const message = params["message"];
1771
+ if (typeof message !== "string" || !message.trim()) {
1772
+ return { ok: false, error: "message is required" };
1773
+ }
1774
+ const elicitationId = params["elicitationId"];
1775
+ if (typeof elicitationId !== "string" || !elicitationId) {
1776
+ return { ok: false, error: "elicitationId is required in URL mode" };
1777
+ }
1778
+ const raw = params["url"];
1779
+ let url;
1780
+ try {
1781
+ url = new URL(typeof raw === "string" ? raw : "");
1782
+ } catch {
1783
+ return { ok: false, error: "url must be a valid URL" };
1784
+ }
1785
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
1786
+ return { ok: false, error: `url must be http(s), not ${url.protocol}` };
1787
+ }
1788
+ return {
1789
+ ok: true,
1790
+ value: {
1791
+ mode: "url",
1792
+ message: message.slice(0, MAX_MESSAGE_CHARS),
1793
+ url: url.href,
1794
+ elicitationId
1795
+ }
1796
+ };
1797
+ }
1798
+ function parseElicitationParams(params) {
1799
+ if (!isRecord(params)) return { ok: false, error: "params must be an object" };
1800
+ const message = params["message"];
1801
+ if (typeof message !== "string" || !message.trim()) {
1802
+ return { ok: false, error: "message is required" };
1803
+ }
1804
+ if (params["mode"] === "url") return parseUrlElicitation(params);
1805
+ if (params["mode"] !== void 0 && params["mode"] !== "form") {
1806
+ return { ok: false, error: `unsupported mode ${JSON.stringify(params["mode"])}` };
1807
+ }
1808
+ const schema = params["requestedSchema"];
1809
+ if (!isRecord(schema) || schema["type"] !== "object" || !isRecord(schema["properties"])) {
1810
+ return { ok: false, error: "requestedSchema must be an object schema with properties" };
1811
+ }
1812
+ const required = new Set(
1813
+ Array.isArray(schema["required"]) ? schema["required"].filter((v) => typeof v === "string") : []
1814
+ );
1815
+ const entries = Object.entries(schema["properties"]);
1816
+ if (entries.length > MAX_FIELDS) {
1817
+ return { ok: false, error: `requestedSchema has more than ${MAX_FIELDS} properties` };
1818
+ }
1819
+ const fields = [];
1820
+ for (const [name, property] of entries) {
1821
+ const parsed = parseField(name, property, required.has(name));
1822
+ if (!parsed.ok) return parsed;
1823
+ fields.push(parsed.value);
1824
+ }
1825
+ return { ok: true, value: { message: message.slice(0, MAX_MESSAGE_CHARS), fields } };
1826
+ }
1827
+ function label(field) {
1828
+ return field.title ?? field.name;
1829
+ }
1830
+ function checkValue(field, raw) {
1831
+ const name = label(field);
1832
+ switch (field.kind) {
1833
+ case "string": {
1834
+ if (typeof raw !== "string") return { ok: false, error: `${name} must be text` };
1835
+ if (field.minLength !== void 0 && raw.length < field.minLength) {
1836
+ return { ok: false, error: `${name} needs at least ${field.minLength} characters` };
1837
+ }
1838
+ if (field.maxLength !== void 0 && raw.length > field.maxLength) {
1839
+ return { ok: false, error: `${name} allows at most ${field.maxLength} characters` };
1840
+ }
1841
+ return { ok: true, value: raw };
1842
+ }
1843
+ case "number":
1844
+ case "integer": {
1845
+ const n = typeof raw === "string" && raw.trim() ? Number(raw.trim()) : raw;
1846
+ if (typeof n !== "number" || !Number.isFinite(n)) {
1847
+ return { ok: false, error: `${name} must be a number` };
1848
+ }
1849
+ if (field.kind === "integer" && !Number.isInteger(n)) {
1850
+ return { ok: false, error: `${name} must be a whole number` };
1851
+ }
1852
+ if (field.minimum !== void 0 && n < field.minimum) {
1853
+ return { ok: false, error: `${name} must be at least ${field.minimum}` };
1854
+ }
1855
+ if (field.maximum !== void 0 && n > field.maximum) {
1856
+ return { ok: false, error: `${name} must be at most ${field.maximum}` };
1857
+ }
1858
+ return { ok: true, value: n };
1859
+ }
1860
+ case "boolean":
1861
+ return typeof raw === "boolean" ? { ok: true, value: raw } : { ok: false, error: `${name} must be yes or no` };
1862
+ case "enum":
1863
+ return typeof raw === "string" && field.options.some((o) => o.value === raw) ? { ok: true, value: raw } : { ok: false, error: `${name} must be one of the offered choices` };
1864
+ case "multi-enum": {
1865
+ if (!Array.isArray(raw) || !raw.every((v) => field.options.some((o) => o.value === v))) {
1866
+ return { ok: false, error: `${name} must be picked from the offered choices` };
1867
+ }
1868
+ const picked = [...new Set(raw)];
1869
+ if (field.minItems !== void 0 && picked.length < field.minItems) {
1870
+ return { ok: false, error: `${name} needs at least ${field.minItems} choices` };
1871
+ }
1872
+ if (field.maxItems !== void 0 && picked.length > field.maxItems) {
1873
+ return { ok: false, error: `${name} allows at most ${field.maxItems} choices` };
1874
+ }
1875
+ return { ok: true, value: picked };
1876
+ }
1877
+ }
1878
+ }
1879
+ function validateElicitationContent(fields, content) {
1880
+ const out = {};
1881
+ for (const field of fields) {
1882
+ const raw = content[field.name];
1883
+ if (Array.isArray(raw) && raw.length === 0 && field.kind === "multi-enum" && field.minItems === 0) {
1884
+ out[field.name] = [];
1885
+ continue;
1367
1886
  }
1368
- if (line.startsWith(":")) return;
1369
- const colonIdx = line.indexOf(":");
1370
- const field = colonIdx === -1 ? line : line.slice(0, colonIdx);
1371
- let value = colonIdx === -1 ? "" : line.slice(colonIdx + 1);
1372
- if (value.startsWith(" ")) value = value.slice(1);
1373
- if (field === "event") {
1374
- this.eventName = value;
1375
- } else if (field === "data") {
1376
- if (this.dataLines.length >= SSE_READER_MAX_DATA_LINES) {
1377
- throw new ToolError({
1378
- message: `SSE: exceeded ${SSE_READER_MAX_DATA_LINES} data lines per event \u2014 upstream is not sending blank-line delimiters`,
1379
- code: "TOOL_EXECUTION_FAILED",
1380
- toolName: "mcp_transport_sse_reader",
1381
- context: {
1382
- phase: "processLine",
1383
- dataLineCount: this.dataLines.length,
1384
- maxDataLines: SSE_READER_MAX_DATA_LINES
1385
- }
1386
- });
1387
- }
1388
- this.dataLines.push(value);
1887
+ if (raw === void 0 || raw === null || raw === "" || Array.isArray(raw) && raw.length === 0) {
1888
+ if (field.required) return { ok: false, error: `${label(field)} is required` };
1889
+ continue;
1389
1890
  }
1891
+ const checked = checkValue(field, raw);
1892
+ if (!checked.ok) return checked;
1893
+ out[field.name] = checked.value;
1390
1894
  }
1391
- flush() {
1392
- const eventName = this.eventName;
1393
- this.eventName = "";
1394
- if (this.dataLines.length === 0) {
1395
- return;
1895
+ return { ok: true, value: out };
1896
+ }
1897
+ var ServerRequestResponder = class {
1898
+ constructor(elicitation) {
1899
+ this.elicitation = elicitation;
1900
+ }
1901
+ elicitation;
1902
+ pending = /* @__PURE__ */ new Map();
1903
+ /** Client capabilities to declare in `initialize` — only what can be answered. */
1904
+ capabilities() {
1905
+ return this.elicitation ? { elicitation: { form: {}, url: {} } } : {};
1906
+ }
1907
+ /** True while an elicitation waits on the user; request timeouts hold meanwhile. */
1908
+ get awaitingUser() {
1909
+ return this.pending.size > 0;
1910
+ }
1911
+ async answer(request3) {
1912
+ const { id, method } = request3;
1913
+ const error = (code, message) => ({
1914
+ jsonrpc: "2.0",
1915
+ id,
1916
+ error: { code, message }
1917
+ });
1918
+ if (method === "ping") return { jsonrpc: "2.0", id, result: {} };
1919
+ if (method === "sampling/createMessage") {
1920
+ return error(-32601, "Client sampling is disabled by policy");
1396
1921
  }
1397
- const data = this.dataLines.join("\n").trim();
1398
- this.dataLines = [];
1399
- if (!data) return;
1400
- if (eventName === "endpoint") {
1401
- for (const cb of this.endpointListeners) {
1402
- try {
1403
- cb(data);
1404
- } catch {
1405
- }
1406
- }
1407
- return;
1922
+ if (method !== "elicitation/create" || !this.elicitation) {
1923
+ return error(-32601, `Method not found: ${method}`);
1408
1924
  }
1925
+ const form = parseElicitationParams(request3.params);
1926
+ if (!form.ok) return error(-32602, `Invalid elicitation request: ${form.error}`);
1927
+ if (this.pending.size > 0) {
1928
+ return error(-32603, "Another elicitation from this server is still waiting on the user");
1929
+ }
1930
+ const controller = new AbortController();
1931
+ this.pending.set(id, controller);
1932
+ const deadline = setTimeout(
1933
+ () => controller.abort(new Error("elicitation timed out")),
1934
+ ELICITATION_MAX_WAIT_MS
1935
+ );
1936
+ deadline.unref?.();
1409
1937
  try {
1410
- const parsed = JSON.parse(data);
1411
- this.dispatch(parsed);
1938
+ const prompt = form.value;
1939
+ const result = await this.elicitation({ ...prompt, signal: controller.signal });
1940
+ if (controller.signal.aborted) return { jsonrpc: "2.0", id, result: { action: "cancel" } };
1941
+ if (result.action !== "accept")
1942
+ return { jsonrpc: "2.0", id, result: { action: result.action } };
1943
+ if (prompt.mode === "url") return { jsonrpc: "2.0", id, result: { action: "accept" } };
1944
+ const content = validateElicitationContent(prompt.fields, result.content);
1945
+ if (!content.ok) return error(-32603, `Elicitation answer rejected: ${content.error}`);
1946
+ return { jsonrpc: "2.0", id, result: { action: "accept", content: content.value } };
1412
1947
  } catch {
1948
+ return { jsonrpc: "2.0", id, result: { action: "cancel" } };
1949
+ } finally {
1950
+ clearTimeout(deadline);
1951
+ this.pending.delete(id);
1413
1952
  }
1414
1953
  }
1415
- dispatch(msg) {
1416
- for (const cb of this.listeners) {
1417
- try {
1418
- cb(msg);
1419
- } catch {
1954
+ /**
1955
+ * Pages a tool call's `-32042` (URL elicitation required) error names, put
1956
+ * to the user one at a time. Returns what the user chose for each; nothing
1957
+ * is opened without their consent, and without a handler every one is
1958
+ * `cancel`.
1959
+ */
1960
+ async presentUrl(elicitations, signal) {
1961
+ const outcomes = [];
1962
+ for (const elicitation of elicitations) {
1963
+ let action = "cancel";
1964
+ if (this.elicitation && !signal.aborted) {
1965
+ try {
1966
+ action = (await this.elicitation({ ...elicitation, signal })).action;
1967
+ } catch {
1968
+ action = "cancel";
1969
+ }
1420
1970
  }
1971
+ outcomes.push({ elicitation, action });
1421
1972
  }
1973
+ return outcomes;
1422
1974
  }
1423
- reset() {
1424
- this.buffer = "";
1425
- this.dataLines = [];
1426
- this.eventName = "";
1427
- this.listeners = [];
1428
- this.endpointListeners = [];
1975
+ /** `notifications/cancelled` from the server: stop waiting for that answer. */
1976
+ cancel(params) {
1977
+ if (!isRecord(params)) return;
1978
+ const requestId = params["requestId"];
1979
+ if (typeof requestId !== "number" && typeof requestId !== "string") return;
1980
+ this.pending.get(requestId)?.abort(new Error("cancelled by server"));
1981
+ }
1982
+ /** The connection is gone: abandon every open form. */
1983
+ dispose() {
1984
+ for (const controller of this.pending.values())
1985
+ controller.abort(new Error("connection closed"));
1986
+ this.pending.clear();
1429
1987
  }
1430
1988
  };
1431
1989
 
1432
- // src/transport-base.ts
1433
- import * as https2 from "node:https";
1434
- import { ConfigError as ConfigError2 } from "@wrongstack/core/types";
1990
+ // src/read-body.ts
1991
+ import { ToolError as ToolError2 } from "@wrongstack/core/types";
1992
+ var MAX_MCP_HTTP_BODY_BYTES = 16 * 1024 * 1024;
1993
+ async function readBodyCapped(res, maxBytes = MAX_MCP_HTTP_BODY_BYTES) {
1994
+ const body = res.body;
1995
+ if (!body || typeof body.getReader !== "function") {
1996
+ return await res.text();
1997
+ }
1998
+ const reader = body.getReader();
1999
+ const chunks = [];
2000
+ let total = 0;
2001
+ try {
2002
+ for (; ; ) {
2003
+ const { done, value } = await reader.read();
2004
+ if (done) break;
2005
+ if (!value) continue;
2006
+ total += value.byteLength;
2007
+ if (total > maxBytes) {
2008
+ await reader.cancel().catch(() => void 0);
2009
+ throw new ToolError2({
2010
+ message: `MCP response body exceeded ${maxBytes} bytes \u2014 refusing to buffer`,
2011
+ code: "TOOL_EXECUTION_FAILED",
2012
+ toolName: "mcp_transport",
2013
+ context: { maxBytes, received: total }
2014
+ });
2015
+ }
2016
+ chunks.push(value);
2017
+ }
2018
+ } finally {
2019
+ reader.releaseLock?.();
2020
+ }
2021
+ return Buffer.concat(chunks).toString("utf8");
2022
+ }
1435
2023
 
1436
2024
  // src/transport-security.ts
1437
2025
  import * as dns2 from "node:dns/promises";
@@ -1459,16 +2047,16 @@ function validateTransportUrl(rawUrl) {
1459
2047
  context: { field: "url", rawUrl, protocol: url.protocol }
1460
2048
  });
1461
2049
  }
1462
- const hostname = url.hostname;
1463
- const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
2050
+ const hostname2 = url.hostname;
2051
+ const host = hostname2.startsWith("[") && hostname2.endsWith("]") ? hostname2.slice(1, -1) : hostname2;
1464
2052
  const ipVersion = net2.isIP(host);
1465
2053
  if (ipVersion !== 0) {
1466
2054
  const classification = classifyTransportAddress(host, ipVersion);
1467
2055
  if (classification === "blocked") {
1468
2056
  throw new ConfigError({
1469
- message: `MCP transport: blocked link-local/IMDS address "${hostname}" \u2014 likely not a valid MCP server`,
2057
+ message: `MCP transport: blocked link-local/IMDS address "${hostname2}" \u2014 likely not a valid MCP server`,
1470
2058
  code: "CONFIG_INVALID",
1471
- context: { field: "url", rawUrl, hostname }
2059
+ context: { field: "url", rawUrl, hostname: hostname2 }
1472
2060
  });
1473
2061
  }
1474
2062
  }
@@ -1476,9 +2064,9 @@ function validateTransportUrl(rawUrl) {
1476
2064
  const isLoopback = host === "localhost" || ipVersion !== 0 && classifyTransportAddress(host, ipVersion) === "loopback";
1477
2065
  if (!isLoopback) {
1478
2066
  throw new ConfigError({
1479
- message: `MCP transport: http:// is only allowed for loopback addresses; use https:// for "${hostname}"`,
2067
+ message: `MCP transport: http:// is only allowed for loopback addresses; use https:// for "${hostname2}"`,
1480
2068
  code: "CONFIG_INVALID",
1481
- context: { field: "url", rawUrl, hostname, protocol: url.protocol }
2069
+ context: { field: "url", rawUrl, hostname: hostname2, protocol: url.protocol }
1482
2070
  });
1483
2071
  }
1484
2072
  }
@@ -1489,6 +2077,11 @@ if (ALLOW_MCP_PRIVATE_NETWORKS && !process.env["CI"]) {
1489
2077
  "[WrongStack] WARNING: WRONGSTACK_MCP_ALLOW_PRIVATE=1 is active \u2014\n MCP HTTP transports may dial private/LAN addresses (10.x, 192.168.x,\n 172.16-31.x, ULA) when a server config sets allowPrivateNetworks. Link-local/IMDS targets stay blocked."
1490
2078
  );
1491
2079
  }
2080
+ function isImdsIpv6Literal(v6) {
2081
+ const groups = expandIPv6(v6);
2082
+ if (groups === null) return false;
2083
+ return groups[0] === 64768 && groups[1] === 3778 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 0 && groups[6] === 0 && groups[7] === 596;
2084
+ }
1492
2085
  function classifyTransportAddress(address, family) {
1493
2086
  if (family === 4) {
1494
2087
  const v4 = address.toLowerCase();
@@ -1499,7 +2092,7 @@ function classifyTransportAddress(address, family) {
1499
2092
  if (family === 6) {
1500
2093
  const v6 = address.toLowerCase();
1501
2094
  if (v6 === "::1") return "loopback";
1502
- if (/^fe[89ab]/.test(v6) || v6 === "fd00:ec2::254") return "blocked";
2095
+ if (/^fe[89ab]/.test(v6) || isImdsIpv6Literal(v6)) return "blocked";
1503
2096
  if (v6.startsWith("::ffff:")) {
1504
2097
  const tail = v6.slice("::ffff:".length);
1505
2098
  if (tail.includes(".")) return classifyTransportAddress(tail, 4);
@@ -1525,30 +2118,30 @@ function classifyTransportAddress(address, family) {
1525
2118
  }
1526
2119
  return "blocked";
1527
2120
  }
1528
- function assertTransportAddressAllowed(address, family, hostname, allowPrivateNetworks) {
2121
+ function assertTransportAddressAllowed(address, family, hostname2, allowPrivateNetworks) {
1529
2122
  const classification = classifyTransportAddress(address, family);
1530
2123
  if (classification === "blocked") {
1531
2124
  throw new ConfigError({
1532
- message: `MCP transport: resolved address "${address}" for "${hostname}" is link-local/IMDS \u2014 never a valid MCP target`,
2125
+ message: `MCP transport: resolved address "${address}" for "${hostname2}" is link-local/IMDS \u2014 never a valid MCP target`,
1533
2126
  code: "CONFIG_INVALID",
1534
- context: { hostname, address }
2127
+ context: { hostname: hostname2, address }
1535
2128
  });
1536
2129
  }
1537
2130
  if (classification === "private" && !allowPrivateNetworks) {
1538
2131
  throw new ConfigError({
1539
- message: `MCP transport: "${hostname}" resolved to private address ${address}. Private/LAN targets are blocked by default; if this server really runs on your private network, set allowPrivateNetworks: true on its config (or WRONGSTACK_MCP_ALLOW_PRIVATE=1 globally).`,
2132
+ message: `MCP transport: "${hostname2}" resolved to private address ${address}. Private/LAN targets are blocked by default; if this server really runs on your private network, set allowPrivateNetworks: true on its config (or WRONGSTACK_MCP_ALLOW_PRIVATE=1 globally).`,
1540
2133
  code: "CONFIG_INVALID",
1541
- context: { hostname, address }
2134
+ context: { hostname: hostname2, address }
1542
2135
  });
1543
2136
  }
1544
2137
  }
1545
2138
  function transportPinnedLookup(options) {
1546
2139
  const lookup3 = options.lookup ?? (async (host) => dns2.lookup(host, { all: true }));
1547
- return (hostname, connectOptions, callback) => {
1548
- lookup3(hostname).then((records) => {
2140
+ return (hostname2, connectOptions, callback) => {
2141
+ lookup3(hostname2).then((records) => {
1549
2142
  if (records.length === 0) {
1550
2143
  callback(
1551
- Object.assign(new Error(`MCP transport: no addresses for "${hostname}"`), {
2144
+ Object.assign(new Error(`MCP transport: no addresses for "${hostname2}"`), {
1552
2145
  code: "ENOTFOUND"
1553
2146
  })
1554
2147
  );
@@ -1558,7 +2151,7 @@ function transportPinnedLookup(options) {
1558
2151
  assertTransportAddressAllowed(
1559
2152
  record3.address,
1560
2153
  record3.family,
1561
- hostname,
2154
+ hostname2,
1562
2155
  options.allowPrivateNetworks
1563
2156
  );
1564
2157
  }
@@ -1606,7 +2199,7 @@ function makeAbortError(method) {
1606
2199
  function nextJsonRpcId(id) {
1607
2200
  return id >= Number.MAX_SAFE_INTEGER ? 1 : id + 1;
1608
2201
  }
1609
- function createTimeoutSignal(parent, timeoutMs) {
2202
+ function createTimeoutSignal(parent, timeoutMs, hold) {
1610
2203
  const ctrl = new AbortController();
1611
2204
  const onAbort = () => ctrl.abort(parent?.reason);
1612
2205
  if (parent?.aborted) {
@@ -1614,10 +2207,15 @@ function createTimeoutSignal(parent, timeoutMs) {
1614
2207
  } else {
1615
2208
  parent?.addEventListener("abort", onAbort, { once: true });
1616
2209
  }
1617
- const timer = setTimeout(
1618
- () => ctrl.abort(new Error(`MCP HTTP request timed out after ${timeoutMs}ms`)),
1619
- timeoutMs
1620
- );
2210
+ const onTimeout = () => {
2211
+ if (hold?.()) {
2212
+ timer = setTimeout(onTimeout, timeoutMs);
2213
+ timer.unref?.();
2214
+ return;
2215
+ }
2216
+ ctrl.abort(new Error(`MCP HTTP request timed out after ${timeoutMs}ms`));
2217
+ };
2218
+ let timer = setTimeout(onTimeout, timeoutMs);
1621
2219
  timer.unref?.();
1622
2220
  return {
1623
2221
  signal: ctrl.signal,
@@ -1648,9 +2246,12 @@ var BaseHTTPTransport = class {
1648
2246
  disconnectHandlers = [];
1649
2247
  toolsChangedListeners = /* @__PURE__ */ new Set();
1650
2248
  resourcesChangedListeners = /* @__PURE__ */ new Set();
2249
+ resourceUpdatedListeners = /* @__PURE__ */ new Set();
1651
2250
  promptsChangedListeners = /* @__PURE__ */ new Set();
1652
2251
  protocolVersion;
2252
+ serverRequests;
1653
2253
  constructor(opts, transportName) {
2254
+ this.serverRequests = opts.serverRequests ?? new ServerRequestResponder();
1654
2255
  validateTransportUrl(opts.url);
1655
2256
  this.name = opts.name;
1656
2257
  this.url = opts.url;
@@ -1774,6 +2375,10 @@ var BaseHTTPTransport = class {
1774
2375
  this.promptsChangedListeners.add(cb);
1775
2376
  return () => this.promptsChangedListeners.delete(cb);
1776
2377
  }
2378
+ onResourceUpdated(cb) {
2379
+ this.resourceUpdatedListeners.add(cb);
2380
+ return () => this.resourceUpdatedListeners.delete(cb);
2381
+ }
1777
2382
  /**
1778
2383
  * Fire all disconnect handlers. Subclasses call this when the connection
1779
2384
  * drops so the registry can schedule reconnects.
@@ -1802,6 +2407,14 @@ var BaseHTTPTransport = class {
1802
2407
  }
1803
2408
  }
1804
2409
  }
2410
+ notifyResourceUpdated(uri) {
2411
+ for (const cb of this.resourceUpdatedListeners) {
2412
+ try {
2413
+ cb(uri);
2414
+ } catch {
2415
+ }
2416
+ }
2417
+ }
1805
2418
  async dispatcherFetch() {
1806
2419
  if (globalThis.fetch !== nativeGlobalFetch) return globalThis.fetch;
1807
2420
  const undici = await ensureUndici();
@@ -1853,10 +2466,40 @@ var BaseHTTPTransport = class {
1853
2466
  fetchOpts.dispatcher = this.tlsAgent;
1854
2467
  }
1855
2468
  }
2469
+ /**
2470
+ * Request timeout for a call. It holds while the server waits on the user's
2471
+ * elicitation answer — that wait is the user typing, not the server stalling.
2472
+ */
2473
+ requestTimeoutSignal(parent, timeoutMs) {
2474
+ return createTimeoutSignal(parent, timeoutMs, () => this.serverRequests.awaitingUser);
2475
+ }
2476
+ /**
2477
+ * Answer a server→client request. Over HTTP the reply is POSTed back like
2478
+ * any client message; the server acknowledges it with 202. A failed reply is
2479
+ * not retried — the server times its request out on its side.
2480
+ */
2481
+ async replyToServer(request3, url, headers) {
2482
+ const response = await this.serverRequests.answer(request3);
2483
+ const timeoutSignal = createTimeoutSignal(this.abortController?.signal, this.requestTimeout);
2484
+ const init = {
2485
+ method: "POST",
2486
+ headers: { "Content-Type": "application/json", ...headers },
2487
+ body: JSON.stringify(response),
2488
+ signal: timeoutSignal.signal
2489
+ };
2490
+ this.applyTlsAgent(init);
2491
+ try {
2492
+ const res = await this.fetchWithAuthorization(url, init, timeoutSignal.signal);
2493
+ await readBodyCapped(res).catch(() => void 0);
2494
+ } catch {
2495
+ } finally {
2496
+ timeoutSignal.dispose();
2497
+ }
2498
+ }
1856
2499
  };
1857
2500
 
1858
2501
  // src/transport-jsonrpc.ts
1859
- import { ToolError as ToolError2 } from "@wrongstack/core/types";
2502
+ import { ToolError as ToolError3 } from "@wrongstack/core/types";
1860
2503
  function encodeJsonRpcMessage(id, method, params) {
1861
2504
  return JSON.stringify(
1862
2505
  method.startsWith("notifications/") ? { jsonrpc: "2.0", method, params } : { jsonrpc: "2.0", id, method, params }
@@ -1930,7 +2573,7 @@ function extractJsonRpcResults(text) {
1930
2573
  }
1931
2574
  function assertMatchingJsonRpcResult(data, expectedId, method) {
1932
2575
  if (!isJsonRpcResult(data)) {
1933
- throw new ToolError2({
2576
+ throw new ToolError3({
1934
2577
  message: "Invalid JSON-RPC response: not a JSON-RPC 2.0 envelope",
1935
2578
  code: "TOOL_EXECUTION_FAILED",
1936
2579
  toolName: "mcp_transport_jsonrpc",
@@ -1938,7 +2581,7 @@ function assertMatchingJsonRpcResult(data, expectedId, method) {
1938
2581
  });
1939
2582
  }
1940
2583
  if (data.id !== expectedId) {
1941
- throw new ToolError2({
2584
+ throw new ToolError3({
1942
2585
  message: `Invalid JSON-RPC response: id mismatch for ${method} (expected ${expectedId}, got ${data.id})`,
1943
2586
  code: "TOOL_EXECUTION_FAILED",
1944
2587
  toolName: "mcp_transport_jsonrpc",
@@ -1952,38 +2595,147 @@ function assertMatchingJsonRpcResult(data, expectedId, method) {
1952
2595
  import { randomBytes as randomBytes2 } from "node:crypto";
1953
2596
  import { ToolError as ToolError4 } from "@wrongstack/core/types";
1954
2597
 
1955
- // src/read-body.ts
1956
- import { ToolError as ToolError3 } from "@wrongstack/core/types";
1957
- var MAX_MCP_HTTP_BODY_BYTES = 16 * 1024 * 1024;
1958
- async function readBodyCapped(res, maxBytes = MAX_MCP_HTTP_BODY_BYTES) {
1959
- const body = res.body;
1960
- if (!body || typeof body.getReader !== "function") {
1961
- return await res.text();
2598
+ // src/constants.ts
2599
+ var SUPPORTED_PROTOCOL_VERSIONS = Object.freeze(["2024-11-05"]);
2600
+ function negotiateProtocolVersion(requested) {
2601
+ if (typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) {
2602
+ return requested;
1962
2603
  }
1963
- const reader = body.getReader();
1964
- const chunks = [];
1965
- let total = 0;
1966
- try {
1967
- for (; ; ) {
1968
- const { done, value } = await reader.read();
1969
- if (done) break;
1970
- if (!value) continue;
1971
- total += value.byteLength;
1972
- if (total > maxBytes) {
1973
- await reader.cancel().catch(() => void 0);
1974
- throw new ToolError3({
1975
- message: `MCP response body exceeded ${maxBytes} bytes \u2014 refusing to buffer`,
1976
- code: "TOOL_EXECUTION_FAILED",
1977
- toolName: "mcp_transport",
1978
- context: { maxBytes, received: total }
1979
- });
1980
- }
1981
- chunks.push(value);
2604
+ return MCP_CONSTANTS.PROTOCOL_VERSION;
2605
+ }
2606
+ var MCP_CONSTANTS = Object.freeze({
2607
+ /** MCP protocol version advertised during handshake: the newest we implement. */
2608
+ PROTOCOL_VERSION: SUPPORTED_PROTOCOL_VERSIONS[0],
2609
+ /** Identity announced to MCP servers during `initialize`. */
2610
+ CLIENT_INFO: Object.freeze({
2611
+ name: "wrongstack",
2612
+ version: "0.1.10"
2613
+ }),
2614
+ /** Reconnection behaviour when a transport disconnects. */
2615
+ RECONNECT: Object.freeze({
2616
+ /** Max full reconnect cycles before the slot is marked `failed`. */
2617
+ MAX_CYCLES: 5,
2618
+ /** Base delay between cycles (exponential backoff applied on top). */
2619
+ BASE_DELAY_MS: 1e3,
2620
+ /** Jitter factor applied to the backoff (0 = no jitter, 1 = full). */
2621
+ JITTER_FACTOR: 0.2,
2622
+ /** Max connection attempts within a single cycle. */
2623
+ MAX_ATTEMPTS: 3,
2624
+ /** Base multiplier for the exponential backoff formula (`delay = BASE * multiplier^attempt`). */
2625
+ BACKOFF_MULTIPLIER: 2
2626
+ }),
2627
+ /** Timing for graceful / forced disconnect. */
2628
+ DISCONNECT: Object.freeze({
2629
+ /** Ms to wait for in-flight requests to complete before force-closing. */
2630
+ GRACEFUL_MS: 800,
2631
+ /** Ms after which the force disconnect is triggered. */
2632
+ FORCE_TIMEOUT_MS: 1200
2633
+ }),
2634
+ /** Lazy-connect idle lifecycle. */
2635
+ IDLE: Object.freeze({
2636
+ /** Default ms a lazy server stays connected with no tool calls before auto-sleep. */
2637
+ DEFAULT_TIMEOUT_MS: 3e5,
2638
+ /** How often the idle sweep runs (kept well below the timeout). */
2639
+ SWEEP_INTERVAL_MS: 3e4
2640
+ }),
2641
+ /** JSON-RPC response timeout for outstanding requests. */
2642
+ RESPONSE_TIMEOUT_MS: 500,
2643
+ /** Max buffer size for the SSE reader. */
2644
+ SSE_READER_MAX_BUFFER: 256 * 1024,
2645
+ /** Max characters logged from a request body. */
2646
+ REQUEST_LOG_CAP: 1024
2647
+ });
2648
+
2649
+ // src/tool-schema.ts
2650
+ var URL_ELICITATION_REQUIRED = -32042;
2651
+ function isPlainObject(value) {
2652
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2653
+ }
2654
+ function toToolCallResult(response) {
2655
+ if (response.error) {
2656
+ const pages = urlElicitationsOf(response.error);
2657
+ return {
2658
+ content: response.error.message,
2659
+ isError: true,
2660
+ ...pages.length > 0 ? { urlElicitations: pages } : {}
2661
+ };
2662
+ }
2663
+ const result = isPlainObject(response.result) ? response.result : {};
2664
+ const structured = result["structuredContent"];
2665
+ return {
2666
+ content: result["content"] ?? "",
2667
+ isError: Boolean(result["isError"]),
2668
+ ...isPlainObject(structured) ? { structuredContent: structured } : {}
2669
+ };
2670
+ }
2671
+ function urlElicitationsOf(error) {
2672
+ if (error.code !== URL_ELICITATION_REQUIRED || !isPlainObject(error.data)) return [];
2673
+ const listed = error.data["elicitations"];
2674
+ if (!Array.isArray(listed)) return [];
2675
+ const pages = [];
2676
+ for (const entry of listed) {
2677
+ const parsed = parseUrlElicitation(entry);
2678
+ if (parsed.ok) pages.push(parsed.value);
2679
+ }
2680
+ return pages;
2681
+ }
2682
+ var MAX_TOOL_PAGES = 100;
2683
+ var MAX_TOOLS = 1e4;
2684
+ async function listAllTools(requestPage) {
2685
+ const tools = [];
2686
+ const seenCursors = /* @__PURE__ */ new Set();
2687
+ let cursor;
2688
+ for (let page = 0; page < MAX_TOOL_PAGES; page++) {
2689
+ let response;
2690
+ try {
2691
+ response = await requestPage(cursor ? { cursor } : {});
2692
+ } catch (err) {
2693
+ if (page === 0) throw err;
2694
+ break;
2695
+ }
2696
+ if (response.error) {
2697
+ if (page === 0) return null;
2698
+ break;
2699
+ }
2700
+ const result = response.result;
2701
+ tools.push(...normalizeMCPTools(result?.tools));
2702
+ const next = result?.nextCursor;
2703
+ if (typeof next !== "string" || next.length === 0) break;
2704
+ if (seenCursors.has(next) || tools.length >= MAX_TOOLS) break;
2705
+ seenCursors.add(next);
2706
+ cursor = next;
2707
+ }
2708
+ return tools.slice(0, MAX_TOOLS);
2709
+ }
2710
+ function normalizeMCPTools(value) {
2711
+ if (!Array.isArray(value)) return [];
2712
+ const tools = [];
2713
+ for (const raw of value) {
2714
+ if (!raw || typeof raw !== "object") continue;
2715
+ const t = raw;
2716
+ if (typeof t.name !== "string") continue;
2717
+ const name = t.name.trim();
2718
+ if (name.length === 0) continue;
2719
+ const inputSchema = t.inputSchema && typeof t.inputSchema === "object" && !Array.isArray(t.inputSchema) ? t.inputSchema : { type: "object", properties: {} };
2720
+ if (!t.inputSchema || typeof t.inputSchema !== "object" || Array.isArray(t.inputSchema)) {
2721
+ console.warn(
2722
+ JSON.stringify({
2723
+ level: "warn",
2724
+ event: "mcp.tool_schema_invalid",
2725
+ tool: name,
2726
+ message: "no/invalid inputSchema \u2014 defaulting to empty object",
2727
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2728
+ })
2729
+ );
1982
2730
  }
1983
- } finally {
1984
- reader.releaseLock?.();
2731
+ tools.push({
2732
+ name,
2733
+ ...typeof t.description === "string" ? { description: t.description } : {},
2734
+ inputSchema,
2735
+ ...isPlainObject(t.outputSchema) ? { outputSchema: t.outputSchema } : {}
2736
+ });
1985
2737
  }
1986
- return Buffer.concat(chunks).toString("utf8");
2738
+ return tools;
1987
2739
  }
1988
2740
 
1989
2741
  // src/transport-sse.ts
@@ -2069,13 +2821,27 @@ var SSETransport = class extends BaseHTTPTransport {
2069
2821
  });
2070
2822
  sseReader.onMessage((msg) => {
2071
2823
  this.streamSignal?.();
2072
- if (msg.method && !msg.id) {
2073
- if (msg.method === "notifications/tools/list_changed") {
2824
+ const id = msg.id;
2825
+ if (msg.method && (typeof id === "number" || typeof id === "string")) {
2826
+ void this.replyToServer(
2827
+ { id, method: msg.method, params: msg.params },
2828
+ this.endpointUrl ?? this.url,
2829
+ this.headers
2830
+ );
2831
+ return;
2832
+ }
2833
+ if (msg.method) {
2834
+ if (msg.method === "notifications/cancelled") {
2835
+ this.serverRequests.cancel(msg.params);
2836
+ } else if (msg.method === "notifications/tools/list_changed") {
2074
2837
  void this.handleToolsListChanged();
2075
2838
  } else if (msg.method === "notifications/resources/list_changed") {
2076
2839
  this.notifyResourcesChanged();
2077
2840
  } else if (msg.method === "notifications/prompts/list_changed") {
2078
2841
  this.notifyPromptsChanged();
2842
+ } else if (msg.method === "notifications/resources/updated") {
2843
+ const uri = resourceUpdatedUri(msg.params);
2844
+ if (uri) this.notifyResourceUpdated(uri);
2079
2845
  }
2080
2846
  return;
2081
2847
  }
@@ -2096,9 +2862,9 @@ var SSETransport = class extends BaseHTTPTransport {
2096
2862
  await this.waitForStream(streamReady, signal);
2097
2863
  const initRes = await this.httpPost("initialize", {
2098
2864
  protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
2099
- // Client capabilities (roots/sampling/elicitation) — none are offered.
2865
+ // Client capabilities: elicitation when the host can ask its user.
2100
2866
  // `tools` is a SERVER capability and never belonged here.
2101
- capabilities: {},
2867
+ capabilities: this.serverRequests.capabilities(),
2102
2868
  clientInfo: MCP_CONSTANTS.CLIENT_INFO
2103
2869
  });
2104
2870
  if (initRes.error) {
@@ -2226,7 +2992,7 @@ var SSETransport = class extends BaseHTTPTransport {
2226
2992
  const body = encodeJsonRpcMessage(id, method, params);
2227
2993
  const external = opts?.signal;
2228
2994
  const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
2229
- const timeoutSignal = createTimeoutSignal(parent, timeoutMs);
2995
+ const timeoutSignal = this.requestTimeoutSignal(parent, timeoutMs);
2230
2996
  const streamed = isNotification ? void 0 : this.awaitStreamResponse(id, timeoutSignal.signal);
2231
2997
  const fetchOpts = {
2232
2998
  method: "POST",
@@ -2315,14 +3081,7 @@ var SSETransport = class extends BaseHTTPTransport {
2315
3081
  });
2316
3082
  }
2317
3083
  const res = await this.httpPost("tools/call", { name, arguments: input }, opts);
2318
- if (res.error) {
2319
- return { content: res.error.message, isError: true };
2320
- }
2321
- const result = res.result;
2322
- return {
2323
- content: result?.content ?? "",
2324
- isError: Boolean(result?.isError)
2325
- };
3084
+ return toToolCallResult(res);
2326
3085
  }
2327
3086
  /** Generic JSON-RPC request — used by MCPClient.request() for SSE transports. */
2328
3087
  async request(method, params, timeoutMs, opts) {
@@ -2379,23 +3138,89 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2379
3138
  this._nextId = nextJsonRpcId(id);
2380
3139
  return id;
2381
3140
  }
2382
- consumeResponseText(text, requestId) {
2383
- const envelopes = extractJsonRpcEnvelopes(text);
2384
- for (const envelope of envelopes) {
2385
- if ("method" in envelope && envelope.id === void 0) {
2386
- this.handleNotification(envelope.method);
3141
+ /**
3142
+ * Read a POST's reply. A `text/event-stream` reply is consumed event by
3143
+ * event: the server may put notifications and its OWN requests (elicitation)
3144
+ * ahead of our response and then wait for our answer to them, so buffering
3145
+ * the whole body first would deadlock the call.
3146
+ */
3147
+ async readResponse(res, requestId) {
3148
+ const streamed = (res.headers?.get("content-type") ?? "").includes("text/event-stream");
3149
+ const body = res.body;
3150
+ if (!streamed || !body || typeof body.getReader !== "function") {
3151
+ return this.takeEnvelopes(await readBodyCapped(res), requestId);
3152
+ }
3153
+ const reader = body.getReader();
3154
+ const decoder = new TextDecoder();
3155
+ const others = [];
3156
+ let pending = "";
3157
+ let total = 0;
3158
+ try {
3159
+ for (; ; ) {
3160
+ const { done, value } = await reader.read();
3161
+ if (done) break;
3162
+ total += value.byteLength;
3163
+ if (total > MAX_MCP_HTTP_BODY_BYTES) {
3164
+ throw new Error(`MCP response stream exceeded ${MAX_MCP_HTTP_BODY_BYTES} bytes`);
3165
+ }
3166
+ pending += decoder.decode(value, { stream: true });
3167
+ const cut = completeEventsEnd(pending);
3168
+ if (cut === 0) continue;
3169
+ const match = this.takeEnvelopes(pending.slice(0, cut), requestId, others);
3170
+ pending = pending.slice(cut);
3171
+ if (match) {
3172
+ await reader.cancel().catch(() => void 0);
3173
+ return match;
3174
+ }
3175
+ }
3176
+ pending += decoder.decode();
3177
+ return this.takeEnvelopes(pending, requestId, others) ?? others[0];
3178
+ } finally {
3179
+ reader.releaseLock?.();
3180
+ }
3181
+ }
3182
+ /**
3183
+ * Dispatch the notifications and server requests in `text`, and return the
3184
+ * response to `requestId` — falling back to the first other response, which
3185
+ * the caller's id check then rejects.
3186
+ */
3187
+ takeEnvelopes(text, requestId, others = []) {
3188
+ let match;
3189
+ for (const envelope of extractJsonRpcEnvelopes(text)) {
3190
+ if (isJsonRpcResult(envelope)) {
3191
+ if (envelope.id === requestId) match ??= envelope;
3192
+ else others.push(envelope);
3193
+ } else if (envelope.id !== void 0) {
3194
+ void this.replyToServer(
3195
+ { id: envelope.id, method: envelope.method, params: envelope.params },
3196
+ this.url,
3197
+ this.sessionHeaders()
3198
+ );
3199
+ } else {
3200
+ this.handleNotification(envelope.method, envelope.params);
2387
3201
  }
2388
3202
  }
2389
- const responses = envelopes.filter(isJsonRpcResult);
2390
- return responses.find((envelope) => envelope.id === requestId) ?? responses[0];
3203
+ return match ?? others[0];
3204
+ }
3205
+ sessionHeaders() {
3206
+ return {
3207
+ Accept: "application/json, text/event-stream",
3208
+ ...this.sessionId ? { "Mcp-Session-Id": this.sessionId } : {},
3209
+ ...this.headers
3210
+ };
2391
3211
  }
2392
- handleNotification(method) {
2393
- if (method === "notifications/resources/list_changed") {
3212
+ handleNotification(method, params) {
3213
+ if (method === "notifications/cancelled") {
3214
+ this.serverRequests.cancel(params);
3215
+ } else if (method === "notifications/resources/list_changed") {
2394
3216
  this.notifyResourcesChanged();
2395
3217
  } else if (method === "notifications/prompts/list_changed") {
2396
3218
  this.notifyPromptsChanged();
2397
3219
  } else if (method === "notifications/tools/list_changed") {
2398
3220
  void this.refreshTools();
3221
+ } else if (method === "notifications/resources/updated") {
3222
+ const uri = resourceUpdatedUri(params);
3223
+ if (uri) this.notifyResourceUpdated(uri);
2399
3224
  }
2400
3225
  }
2401
3226
  async refreshTools() {
@@ -2434,8 +3259,9 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2434
3259
  method: "initialize",
2435
3260
  params: {
2436
3261
  protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
2437
- // Client capabilities — none offered (`tools` is a server capability).
2438
- capabilities: {},
3262
+ // Client capabilities: elicitation when the host can ask its user
3263
+ // (`tools` is a server capability and never belongs here).
3264
+ capabilities: this.serverRequests.capabilities(),
2439
3265
  clientInfo: MCP_CONSTANTS.CLIENT_INFO
2440
3266
  }
2441
3267
  }),
@@ -2481,7 +3307,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2481
3307
  const body = encodeJsonRpcMessage(id, method, params);
2482
3308
  const external = opts?.signal;
2483
3309
  const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
2484
- const timeoutSignal = createTimeoutSignal(parent, this.requestTimeout);
3310
+ const timeoutSignal = this.requestTimeoutSignal(parent, this.requestTimeout);
2485
3311
  const fetchOpts = {
2486
3312
  method: "POST",
2487
3313
  headers: {
@@ -2506,7 +3332,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2506
3332
  await readBodyCapped(res).catch(() => void 0);
2507
3333
  return { jsonrpc: "2.0", id };
2508
3334
  }
2509
- const match = this.consumeResponseText(await readBodyCapped(res), id);
3335
+ const match = await this.readResponse(res, id);
2510
3336
  if (match) {
2511
3337
  return assertMatchingJsonRpcResult(match, id, method);
2512
3338
  }
@@ -2531,7 +3357,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2531
3357
  const body = encodeJsonRpcMessage(id, method, params);
2532
3358
  const external = opts?.signal;
2533
3359
  const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
2534
- const timeoutSignal = createTimeoutSignal(parent, timeoutMs ?? this.requestTimeout);
3360
+ const timeoutSignal = this.requestTimeoutSignal(parent, timeoutMs ?? this.requestTimeout);
2535
3361
  const fetchOpts = {
2536
3362
  method: "POST",
2537
3363
  headers: {
@@ -2556,7 +3382,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2556
3382
  await readBodyCapped(res).catch(() => void 0);
2557
3383
  return { jsonrpc: "2.0", id };
2558
3384
  }
2559
- const parsed = this.consumeResponseText(await readBodyCapped(res), id);
3385
+ const parsed = await this.readResponse(res, id);
2560
3386
  if (parsed) {
2561
3387
  const matched = assertMatchingJsonRpcResult(parsed, id, method);
2562
3388
  return {
@@ -2586,14 +3412,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2586
3412
  throw new Error(`streamable-http transport not connected (state=${this.state})`);
2587
3413
  }
2588
3414
  const res = await this.postRaw("tools/call", { name, arguments: input }, opts);
2589
- if (res.error) {
2590
- return { content: res.error.message, isError: true };
2591
- }
2592
- const result = res.result;
2593
- return {
2594
- content: result?.content ?? "",
2595
- isError: Boolean(result?.isError)
2596
- };
3415
+ return toToolCallResult(res);
2597
3416
  }
2598
3417
  async close() {
2599
3418
  this.releasePinnedDispatcher();
@@ -2619,11 +3438,125 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2619
3438
  }
2620
3439
  }
2621
3440
  };
3441
+ function completeEventsEnd(text) {
3442
+ const lf = text.lastIndexOf("\n\n");
3443
+ const crlf = text.lastIndexOf("\r\n\r\n");
3444
+ return Math.max(lf === -1 ? 0 : lf + 2, crlf === -1 ? 0 : crlf + 4);
3445
+ }
3446
+
3447
+ // src/client-http-connection.ts
3448
+ async function connectSSE(host) {
3449
+ if (!host.opts.url) {
3450
+ host.state = "failed";
3451
+ throw new Error('MCP SSE transport requires "url"');
3452
+ }
3453
+ const httpOpts = {
3454
+ name: host.opts.name,
3455
+ url: host.opts.url,
3456
+ headers: resolveHttpBearerHeaders(host.opts),
3457
+ startupTimeoutMs: host.opts.startupTimeoutMs,
3458
+ requestTimeoutMs: host.opts.requestTimeoutMs,
3459
+ authorizationProvider: host.opts.authorizationProvider,
3460
+ allowPrivateNetworks: host.opts.allowPrivateNetworks,
3461
+ serverRequests: host.serverRequests
3462
+ };
3463
+ host.sseTransport = new SSETransport(httpOpts);
3464
+ host.sseTransport.onDisconnect(() => {
3465
+ host.state = "disconnected";
3466
+ for (const cb of host.disconnectListeners) {
3467
+ try {
3468
+ cb();
3469
+ } catch {
3470
+ }
3471
+ }
3472
+ });
3473
+ host.sseTransport.onToolsChanged((tools) => {
3474
+ host._tools = tools;
3475
+ host._toolsCache = tools;
3476
+ for (const cb of host.toolsChangedListeners) {
3477
+ try {
3478
+ cb(host.opts.name, tools);
3479
+ } catch {
3480
+ }
3481
+ }
3482
+ });
3483
+ host.sseTransport.onResourcesChanged(() => host.emitCapabilityChanged("resources"));
3484
+ host.sseTransport.onResourceUpdated((uri) => host.emitResourceUpdated(uri));
3485
+ host.sseTransport.onPromptsChanged(() => host.emitCapabilityChanged("prompts"));
3486
+ try {
3487
+ await host.sseTransport.connect();
3488
+ } catch (err) {
3489
+ const t = host.sseTransport;
3490
+ host.sseTransport = void 0;
3491
+ await t.close().catch(() => {
3492
+ });
3493
+ host.state = "failed";
3494
+ throw err;
3495
+ }
3496
+ host._tools = host.sseTransport.listTools();
3497
+ host._toolsCache = host._tools;
3498
+ host._serverMetadata = host.sseTransport.getServerMetadata();
3499
+ host.state = "connected";
3500
+ }
3501
+ async function connectStreamableHTTP(host) {
3502
+ if (!host.opts.url) {
3503
+ host.state = "failed";
3504
+ throw new Error('MCP streamable-http transport requires "url"');
3505
+ }
3506
+ const httpOpts = {
3507
+ name: host.opts.name,
3508
+ url: host.opts.url,
3509
+ headers: resolveHttpBearerHeaders(host.opts),
3510
+ startupTimeoutMs: host.opts.startupTimeoutMs,
3511
+ requestTimeoutMs: host.opts.requestTimeoutMs,
3512
+ authorizationProvider: host.opts.authorizationProvider,
3513
+ allowPrivateNetworks: host.opts.allowPrivateNetworks,
3514
+ serverRequests: host.serverRequests
3515
+ };
3516
+ host.httpTransport = new StreamableHTTPTransport(httpOpts);
3517
+ host.httpTransport.onDisconnect(() => {
3518
+ host.state = "disconnected";
3519
+ for (const cb of host.disconnectListeners) {
3520
+ try {
3521
+ cb();
3522
+ } catch {
3523
+ }
3524
+ }
3525
+ });
3526
+ host.httpTransport.onToolsChanged((tools) => {
3527
+ host._tools = tools;
3528
+ host._toolsCache = tools;
3529
+ for (const cb of host.toolsChangedListeners) {
3530
+ try {
3531
+ cb(host.opts.name, tools);
3532
+ } catch {
3533
+ }
3534
+ }
3535
+ });
3536
+ host.httpTransport.onResourcesChanged(() => host.emitCapabilityChanged("resources"));
3537
+ host.httpTransport.onResourceUpdated((uri) => host.emitResourceUpdated(uri));
3538
+ host.httpTransport.onPromptsChanged(() => host.emitCapabilityChanged("prompts"));
3539
+ try {
3540
+ await host.httpTransport.connect();
3541
+ } catch (err) {
3542
+ const t = host.httpTransport;
3543
+ host.httpTransport = void 0;
3544
+ await t.close().catch(() => {
3545
+ });
3546
+ host.state = "failed";
3547
+ throw err;
3548
+ }
3549
+ host._tools = host.httpTransport.listTools();
3550
+ host._toolsCache = host._tools;
3551
+ host._serverMetadata = host.httpTransport.getServerMetadata();
3552
+ host.state = "connected";
3553
+ }
2622
3554
 
2623
3555
  // src/client.ts
2624
3556
  var MCPClient = class _MCPClient {
2625
3557
  constructor(opts) {
2626
3558
  this.opts = opts;
3559
+ this.serverRequests = new ServerRequestResponder(opts.elicitation);
2627
3560
  }
2628
3561
  opts;
2629
3562
  capabilityClient = new MCPCapabilityClient(
@@ -2676,8 +3609,11 @@ var MCPClient = class _MCPClient {
2676
3609
  toolsChangedListeners = /* @__PURE__ */ new Set();
2677
3610
  resourcesChangedListeners = /* @__PURE__ */ new Set();
2678
3611
  promptsChangedListeners = /* @__PURE__ */ new Set();
3612
+ resourceUpdatedListeners = /* @__PURE__ */ new Set();
2679
3613
  /** Notified when an HTTP transport (SSE or streamable-http) disconnects. */
2680
3614
  disconnectListeners = /* @__PURE__ */ new Set();
3615
+ /** Answers server→client requests (ping, elicitation) on every transport. */
3616
+ serverRequests;
2681
3617
  getState() {
2682
3618
  return this.state;
2683
3619
  }
@@ -2819,9 +3755,9 @@ var MCPClient = class _MCPClient {
2819
3755
  "initialize",
2820
3756
  {
2821
3757
  protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
2822
- // Client capabilities (roots/sampling/elicitation) — none offered.
3758
+ // Client capabilities: elicitation when the host can ask its user.
2823
3759
  // `tools` is a SERVER capability and never belonged here.
2824
- capabilities: {},
3760
+ capabilities: this.serverRequests.capabilities(),
2825
3761
  clientInfo: MCP_CONSTANTS.CLIENT_INFO
2826
3762
  },
2827
3763
  this.opts.startupTimeoutMs ?? 1e4
@@ -2848,106 +3784,10 @@ var MCPClient = class _MCPClient {
2848
3784
  this.state = "connected";
2849
3785
  }
2850
3786
  async connectSSE() {
2851
- if (!this.opts.url) {
2852
- this.state = "failed";
2853
- throw new Error('MCP SSE transport requires "url"');
2854
- }
2855
- const httpOpts = {
2856
- name: this.opts.name,
2857
- url: this.opts.url,
2858
- headers: resolveHttpBearerHeaders(this.opts),
2859
- startupTimeoutMs: this.opts.startupTimeoutMs,
2860
- requestTimeoutMs: this.opts.requestTimeoutMs,
2861
- authorizationProvider: this.opts.authorizationProvider,
2862
- allowPrivateNetworks: this.opts.allowPrivateNetworks
2863
- };
2864
- this.sseTransport = new SSETransport(httpOpts);
2865
- this.sseTransport.onDisconnect(() => {
2866
- this.state = "disconnected";
2867
- for (const cb of this.disconnectListeners) {
2868
- try {
2869
- cb();
2870
- } catch {
2871
- }
2872
- }
2873
- });
2874
- this.sseTransport.onToolsChanged((tools) => {
2875
- this._tools = tools;
2876
- this._toolsCache = tools;
2877
- for (const cb of this.toolsChangedListeners) {
2878
- try {
2879
- cb(this.opts.name, tools);
2880
- } catch {
2881
- }
2882
- }
2883
- });
2884
- this.sseTransport.onResourcesChanged(() => this.emitCapabilityChanged("resources"));
2885
- this.sseTransport.onPromptsChanged(() => this.emitCapabilityChanged("prompts"));
2886
- try {
2887
- await this.sseTransport.connect();
2888
- } catch (err) {
2889
- const t = this.sseTransport;
2890
- this.sseTransport = void 0;
2891
- await t.close().catch(() => {
2892
- });
2893
- this.state = "failed";
2894
- throw err;
2895
- }
2896
- this._tools = this.sseTransport.listTools();
2897
- this._toolsCache = this._tools;
2898
- this._serverMetadata = this.sseTransport.getServerMetadata();
2899
- this.state = "connected";
3787
+ return connectSSE(this.clientHttpConnectionHost());
2900
3788
  }
2901
3789
  async connectStreamableHTTP() {
2902
- if (!this.opts.url) {
2903
- this.state = "failed";
2904
- throw new Error('MCP streamable-http transport requires "url"');
2905
- }
2906
- const httpOpts = {
2907
- name: this.opts.name,
2908
- url: this.opts.url,
2909
- headers: resolveHttpBearerHeaders(this.opts),
2910
- startupTimeoutMs: this.opts.startupTimeoutMs,
2911
- requestTimeoutMs: this.opts.requestTimeoutMs,
2912
- authorizationProvider: this.opts.authorizationProvider,
2913
- allowPrivateNetworks: this.opts.allowPrivateNetworks
2914
- };
2915
- this.httpTransport = new StreamableHTTPTransport(httpOpts);
2916
- this.httpTransport.onDisconnect(() => {
2917
- this.state = "disconnected";
2918
- for (const cb of this.disconnectListeners) {
2919
- try {
2920
- cb();
2921
- } catch {
2922
- }
2923
- }
2924
- });
2925
- this.httpTransport.onToolsChanged((tools) => {
2926
- this._tools = tools;
2927
- this._toolsCache = tools;
2928
- for (const cb of this.toolsChangedListeners) {
2929
- try {
2930
- cb(this.opts.name, tools);
2931
- } catch {
2932
- }
2933
- }
2934
- });
2935
- this.httpTransport.onResourcesChanged(() => this.emitCapabilityChanged("resources"));
2936
- this.httpTransport.onPromptsChanged(() => this.emitCapabilityChanged("prompts"));
2937
- try {
2938
- await this.httpTransport.connect();
2939
- } catch (err) {
2940
- const t = this.httpTransport;
2941
- this.httpTransport = void 0;
2942
- await t.close().catch(() => {
2943
- });
2944
- this.state = "failed";
2945
- throw err;
2946
- }
2947
- this._tools = this.httpTransport.listTools();
2948
- this._toolsCache = this._tools;
2949
- this._serverMetadata = this.httpTransport.getServerMetadata();
2950
- this.state = "connected";
3790
+ return connectStreamableHTTP(this.clientHttpConnectionHost());
2951
3791
  }
2952
3792
  async callTool(name, input, opts) {
2953
3793
  if (this.state !== "connected") {
@@ -2960,14 +3800,14 @@ var MCPClient = class _MCPClient {
2960
3800
  return this.httpTransport.callTool(name, input, opts);
2961
3801
  }
2962
3802
  const res = await this.request("tools/call", { name, arguments: input }, void 0, opts);
2963
- if (res.error) {
2964
- return { content: res.error.message, isError: true };
2965
- }
2966
- const result = res.result;
2967
- return {
2968
- content: result?.content ?? "",
2969
- isError: Boolean(result?.isError)
2970
- };
3803
+ return toToolCallResult(res);
3804
+ }
3805
+ /**
3806
+ * Put the pages of a `-32042` tool-call error to the user (consent first,
3807
+ * nothing opened without it), on the run whose call got the error.
3808
+ */
3809
+ presentUrlElicitations(elicitations, signal) {
3810
+ return this.serverRequests.presentUrl(elicitations, signal);
2971
3811
  }
2972
3812
  async listResources(opts = {}) {
2973
3813
  return this.capabilityClient.listResources(opts);
@@ -3041,6 +3881,7 @@ var MCPClient = class _MCPClient {
3041
3881
  this.child = void 0;
3042
3882
  }
3043
3883
  this.failPending(`MCP "${this.opts.name}" closed`);
3884
+ this.serverRequests.dispose();
3044
3885
  await Promise.allSettled([this.sseTransport?.close(), this.httpTransport?.close()]);
3045
3886
  this.state = "disconnected";
3046
3887
  }
@@ -3074,26 +3915,31 @@ var MCPClient = class _MCPClient {
3074
3915
  const detach = () => {
3075
3916
  if (signal && onAbort) signal.removeEventListener("abort", onAbort);
3076
3917
  };
3077
- const timer = setTimeout(() => {
3918
+ const onTimeout = () => {
3919
+ if (this.serverRequests.awaitingUser) {
3920
+ entry.timer = setTimeout(onTimeout, timeoutMs);
3921
+ return;
3922
+ }
3078
3923
  this.pending.delete(id);
3079
3924
  detach();
3080
3925
  reject(
3081
3926
  new Error(`MCP "${this.opts.name}" request "${method}" timed out after ${timeoutMs}ms`)
3082
3927
  );
3083
- }, timeoutMs);
3084
- this.pending.set(id, {
3928
+ };
3929
+ const entry = {
3085
3930
  resolve: (res) => {
3086
- clearTimeout(timer);
3931
+ clearTimeout(entry.timer);
3087
3932
  detach();
3088
3933
  resolve(res);
3089
3934
  },
3090
3935
  reject: (err) => {
3091
- clearTimeout(timer);
3936
+ clearTimeout(entry.timer);
3092
3937
  detach();
3093
3938
  reject(err);
3094
3939
  },
3095
- timer
3096
- });
3940
+ timer: setTimeout(onTimeout, timeoutMs)
3941
+ };
3942
+ this.pending.set(id, entry);
3097
3943
  const stdin = this.child?.stdin;
3098
3944
  if (!stdin || stdin.destroyed) {
3099
3945
  const pending = this.pending.get(id);
@@ -3255,7 +4101,7 @@ var MCPClient = class _MCPClient {
3255
4101
  if (typeof envelope["method"] === "string") {
3256
4102
  const id = envelope["id"];
3257
4103
  if (typeof id === "number" || typeof id === "string") {
3258
- this.handleServerRequest({
4104
+ void this.handleServerRequest({
3259
4105
  jsonrpc: "2.0",
3260
4106
  id,
3261
4107
  method: envelope["method"],
@@ -3264,12 +4110,17 @@ var MCPClient = class _MCPClient {
3264
4110
  return;
3265
4111
  }
3266
4112
  if (Object.hasOwn(envelope, "id")) return;
3267
- if (envelope["method"] === "notifications/tools/list_changed") {
4113
+ if (envelope["method"] === "notifications/cancelled") {
4114
+ this.serverRequests.cancel(envelope["params"]);
4115
+ } else if (envelope["method"] === "notifications/tools/list_changed") {
3268
4116
  void this.handleToolsListChanged();
3269
4117
  } else if (envelope["method"] === "notifications/resources/list_changed") {
3270
4118
  this.emitCapabilityChanged("resources");
3271
4119
  } else if (envelope["method"] === "notifications/prompts/list_changed") {
3272
4120
  this.emitCapabilityChanged("prompts");
4121
+ } else if (envelope["method"] === "notifications/resources/updated") {
4122
+ const uri = resourceUpdatedUri(envelope["params"]);
4123
+ if (uri) this.emitResourceUpdated(uri);
3273
4124
  }
3274
4125
  return;
3275
4126
  }
@@ -3281,15 +4132,8 @@ var MCPClient = class _MCPClient {
3281
4132
  entry?.resolve(response);
3282
4133
  }
3283
4134
  }
3284
- handleServerRequest(request3) {
3285
- const response = request3.method === "ping" ? { jsonrpc: "2.0", id: request3.id, result: {} } : {
3286
- jsonrpc: "2.0",
3287
- id: request3.id,
3288
- error: {
3289
- code: -32601,
3290
- message: request3.method === "sampling/createMessage" ? "Client sampling is disabled by policy" : `Method not found: ${request3.method}`
3291
- }
3292
- };
4135
+ async handleServerRequest(request3) {
4136
+ const response = await this.serverRequests.answer(request3);
3293
4137
  try {
3294
4138
  this.child?.stdin?.write(`${JSON.stringify(response)}
3295
4139
  `);
@@ -3329,12 +4173,26 @@ var MCPClient = class _MCPClient {
3329
4173
  removeResourcesChangedListener(listener) {
3330
4174
  this.resourcesChangedListeners.delete(listener);
3331
4175
  }
4176
+ addResourceUpdatedListener(listener) {
4177
+ this.resourceUpdatedListeners.add(listener);
4178
+ }
4179
+ removeResourceUpdatedListener(listener) {
4180
+ this.resourceUpdatedListeners.delete(listener);
4181
+ }
3332
4182
  addPromptsChangedListener(listener) {
3333
4183
  this.promptsChangedListeners.add(listener);
3334
4184
  }
3335
4185
  removePromptsChangedListener(listener) {
3336
4186
  this.promptsChangedListeners.delete(listener);
3337
4187
  }
4188
+ emitResourceUpdated(uri) {
4189
+ for (const listener of this.resourceUpdatedListeners) {
4190
+ try {
4191
+ listener(this.opts.name, uri);
4192
+ } catch {
4193
+ }
4194
+ }
4195
+ }
3338
4196
  emitCapabilityChanged(capability) {
3339
4197
  const listeners = capability === "resources" ? this.resourcesChangedListeners : this.promptsChangedListeners;
3340
4198
  for (const listener of listeners) {
@@ -3344,8 +4202,99 @@ var MCPClient = class _MCPClient {
3344
4202
  }
3345
4203
  }
3346
4204
  }
4205
+ clientHttpConnectionHost() {
4206
+ const self = this;
4207
+ return {
4208
+ get opts() {
4209
+ return self.opts;
4210
+ },
4211
+ get state() {
4212
+ return self.state;
4213
+ },
4214
+ set state(value) {
4215
+ self.state = value;
4216
+ },
4217
+ get sseTransport() {
4218
+ return self.sseTransport;
4219
+ },
4220
+ set sseTransport(value) {
4221
+ self.sseTransport = value;
4222
+ },
4223
+ disconnectListeners: this.disconnectListeners,
4224
+ get _tools() {
4225
+ return self._tools;
4226
+ },
4227
+ set _tools(value) {
4228
+ self._tools = value;
4229
+ },
4230
+ get _toolsCache() {
4231
+ return self._toolsCache;
4232
+ },
4233
+ set _toolsCache(value) {
4234
+ self._toolsCache = value;
4235
+ },
4236
+ toolsChangedListeners: this.toolsChangedListeners,
4237
+ emitCapabilityChanged: (...args) => this.emitCapabilityChanged(...args),
4238
+ emitResourceUpdated: (uri) => this.emitResourceUpdated(uri),
4239
+ get _serverMetadata() {
4240
+ return self._serverMetadata;
4241
+ },
4242
+ set _serverMetadata(value) {
4243
+ self._serverMetadata = value;
4244
+ },
4245
+ get httpTransport() {
4246
+ return self.httpTransport;
4247
+ },
4248
+ set httpTransport(value) {
4249
+ self.httpTransport = value;
4250
+ },
4251
+ serverRequests: this.serverRequests
4252
+ };
4253
+ }
3347
4254
  };
3348
4255
 
4256
+ // src/config-env.ts
4257
+ var PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
4258
+ function mcpEnvPlaceholders(cfg) {
4259
+ const names = /* @__PURE__ */ new Set();
4260
+ const scan = (value) => {
4261
+ if (!value) return;
4262
+ for (const match of value.matchAll(PLACEHOLDER)) if (match[1]) names.add(match[1]);
4263
+ };
4264
+ scan(cfg.command);
4265
+ scan(cfg.url);
4266
+ for (const arg of cfg.args ?? []) scan(arg);
4267
+ for (const value of Object.values(cfg.env ?? {})) scan(value);
4268
+ for (const value of Object.values(cfg.headers ?? {})) scan(value);
4269
+ return [...names].sort();
4270
+ }
4271
+ function expandMcpEnvPlaceholders(cfg, env = process.env) {
4272
+ const missing = /* @__PURE__ */ new Set();
4273
+ const expand = (value) => value.replace(PLACEHOLDER, (_match, name, fallback) => {
4274
+ const resolved = env[name];
4275
+ if (resolved !== void 0 && resolved !== "") return resolved;
4276
+ if (fallback !== void 0) return fallback;
4277
+ missing.add(name);
4278
+ return "";
4279
+ });
4280
+ const mapValues = (record3) => record3 ? Object.fromEntries(Object.entries(record3).map(([key, value]) => [key, expand(value)])) : void 0;
4281
+ const expanded = {
4282
+ command: cfg.command === void 0 ? void 0 : expand(cfg.command),
4283
+ args: cfg.args?.map(expand),
4284
+ env: mapValues(cfg.env),
4285
+ url: cfg.url === void 0 ? void 0 : expand(cfg.url),
4286
+ headers: mapValues(cfg.headers)
4287
+ };
4288
+ if (missing.size > 0) {
4289
+ throw new Error(
4290
+ `MCP server "${cfg.name}" needs environment variable${missing.size === 1 ? "" : "s"} ${[
4291
+ ...missing
4292
+ ].join(", ")} (referenced as \${\u2026} in its config)`
4293
+ );
4294
+ }
4295
+ return expanded;
4296
+ }
4297
+
3349
4298
  // src/content-selection.ts
3350
4299
  var DEFAULT_MCP_INSERTION_MAX_BYTES = 256 * 1024;
3351
4300
  var DEFAULT_MCP_RESOURCE_SCHEMES = [
@@ -3466,11 +4415,223 @@ function base64DecodedBytes(blob) {
3466
4415
  function utf8Bytes(value) {
3467
4416
  return new TextEncoder().encode(value).byteLength;
3468
4417
  }
3469
- function requireIdentity(value, label) {
4418
+ function requireIdentity(value, label2) {
3470
4419
  if (value.length === 0 || value.length > 256) {
3471
- throw new Error(`MCP insertion ${label} must contain 1\u2013256 characters`);
4420
+ throw new Error(`MCP insertion ${label2} must contain 1\u2013256 characters`);
4421
+ }
4422
+ }
4423
+
4424
+ // src/elicitation-form.ts
4425
+ import { randomUUID } from "node:crypto";
4426
+ import { hostname } from "node:os";
4427
+ var MAX_ATTEMPTS = 3;
4428
+ var CONFIRM_QUESTION = "confirm";
4429
+ var URL_QUESTION = "url";
4430
+ function questionId(index) {
4431
+ return `f${index}`;
4432
+ }
4433
+ function optionId(index) {
4434
+ return `o${index}`;
4435
+ }
4436
+ function bounds(min, max, unit = "") {
4437
+ if (min !== void 0 && max !== void 0) return `Between ${min} and ${max}${unit}.`;
4438
+ if (min !== void 0) return `At least ${min}${unit}.`;
4439
+ if (max !== void 0) return `At most ${max}${unit}.`;
4440
+ return void 0;
4441
+ }
4442
+ function describe(field) {
4443
+ const notes = [field.description];
4444
+ if (field.kind === "string") {
4445
+ notes.push(field.format ? `Format: ${field.format}.` : void 0);
4446
+ notes.push(bounds(field.minLength, field.maxLength, " characters"));
4447
+ } else if (field.kind === "number" || field.kind === "integer") {
4448
+ notes.push(field.kind === "integer" ? "A whole number." : "A number.");
4449
+ notes.push(bounds(field.minimum, field.maximum));
4450
+ } else if (field.kind === "multi-enum") {
4451
+ notes.push(bounds(field.minItems, field.maxItems, " choices"));
4452
+ }
4453
+ const text = notes.filter(Boolean).join(" ");
4454
+ return text || void 0;
4455
+ }
4456
+ function toQuestion(field, index) {
4457
+ const base = {
4458
+ id: questionId(index),
4459
+ prompt: field.title ?? field.name,
4460
+ description: describe(field),
4461
+ required: field.required
4462
+ };
4463
+ switch (field.kind) {
4464
+ case "string":
4465
+ return { ...base, kind: "text", recommendedText: field.default, placeholder: field.format };
4466
+ case "number":
4467
+ case "integer":
4468
+ return {
4469
+ ...base,
4470
+ kind: "text",
4471
+ recommendedText: field.default === void 0 ? void 0 : String(field.default)
4472
+ };
4473
+ case "boolean":
4474
+ return {
4475
+ ...base,
4476
+ kind: "single_select",
4477
+ options: [
4478
+ { id: "yes", label: "Yes" },
4479
+ { id: "no", label: "No" }
4480
+ ],
4481
+ recommendedOptionIds: field.default === void 0 ? void 0 : [field.default ? "yes" : "no"]
4482
+ };
4483
+ case "enum":
4484
+ case "multi-enum": {
4485
+ const defaults = field.kind === "enum" ? [field.default] : field.default ?? [];
4486
+ const recommended = field.options.map((option, i) => defaults.includes(option.value) ? optionId(i) : void 0).filter((id) => id !== void 0);
4487
+ return {
4488
+ ...base,
4489
+ kind: field.kind === "enum" ? "single_select" : "multi_select",
4490
+ options: field.options.map((option, i) => ({ id: optionId(i), label: option.label })),
4491
+ recommendedOptionIds: recommended.length > 0 ? recommended : void 0
4492
+ };
4493
+ }
3472
4494
  }
3473
4495
  }
4496
+ function pageWarnings(url) {
4497
+ const warnings = [];
4498
+ if (url.hostname.split(".").some((label2) => label2.startsWith("xn--"))) {
4499
+ warnings.push(
4500
+ "The address uses international characters (punycode); it may imitate another site."
4501
+ );
4502
+ }
4503
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
4504
+ if (url.protocol === "http:" && !local) {
4505
+ warnings.push("The page is not served over HTTPS.");
4506
+ }
4507
+ if (url.username || url.password) warnings.push("The address carries a user name or password.");
4508
+ return warnings;
4509
+ }
4510
+ function toPageRequest(request3, canOpen) {
4511
+ const url = new URL(request3.url);
4512
+ const description = [
4513
+ request3.message,
4514
+ `Page: ${request3.url}`,
4515
+ `Site: ${url.host}`,
4516
+ ...pageWarnings(url).map((warning) => `Warning: ${warning}`),
4517
+ "What you do on that page goes to the server directly, not through WrongStack."
4518
+ ].join("\n\n");
4519
+ return {
4520
+ id: `mcp-elicitation-${randomUUID()}`,
4521
+ title: `MCP server "${request3.server}" asks you to open ${url.host}`,
4522
+ description,
4523
+ submitLabel: "Continue",
4524
+ tabs: [
4525
+ {
4526
+ id: "page",
4527
+ label: request3.server,
4528
+ questions: [
4529
+ {
4530
+ id: URL_QUESTION,
4531
+ prompt: `Open ${url.host}?`,
4532
+ kind: "single_select",
4533
+ required: true,
4534
+ options: [
4535
+ // Named: with `wstack remote` this process runs on another machine.
4536
+ ...canOpen ? [{ id: "open", label: `Open it in the browser on ${hostname()}` }] : [],
4537
+ { id: "self", label: "I will open it myself" },
4538
+ { id: "decline", label: "Decline" }
4539
+ ]
4540
+ }
4541
+ ]
4542
+ }
4543
+ ]
4544
+ };
4545
+ }
4546
+ function toUserInputRequest(request3, problem) {
4547
+ const questions = request3.fields.length > 0 ? request3.fields.map(toQuestion) : [
4548
+ {
4549
+ id: CONFIRM_QUESTION,
4550
+ prompt: "Go ahead?",
4551
+ kind: "single_select",
4552
+ required: true,
4553
+ options: [
4554
+ { id: "yes", label: "Yes" },
4555
+ { id: "no", label: "No" }
4556
+ ]
4557
+ }
4558
+ ];
4559
+ const description = [
4560
+ request3.message,
4561
+ `Only share what you are comfortable sending to "${request3.server}".`,
4562
+ problem ? `The last answer was not accepted: ${problem}.` : void 0
4563
+ ].filter(Boolean).join("\n\n");
4564
+ return {
4565
+ id: `mcp-elicitation-${randomUUID()}`,
4566
+ title: `MCP server "${request3.server}" is asking for input`,
4567
+ description,
4568
+ submitLabel: `Send to ${request3.server}`,
4569
+ tabs: [{ id: "form", label: request3.server, questions }]
4570
+ };
4571
+ }
4572
+ function answerValue(field, answer) {
4573
+ if (!answer || answer.delegated) return void 0;
4574
+ switch (field.kind) {
4575
+ case "string":
4576
+ return answer.text?.trim() ? answer.text : void 0;
4577
+ case "number":
4578
+ case "integer":
4579
+ return answer.text?.trim() || void 0;
4580
+ case "boolean": {
4581
+ const picked = answer.selectedOptionIds[0];
4582
+ return picked === "yes" ? true : picked === "no" ? false : void 0;
4583
+ }
4584
+ case "enum":
4585
+ case "multi-enum": {
4586
+ const values = answer.selectedOptionIds.map((id) => field.options[Number(id.slice(1))]?.value).filter((v) => v !== void 0);
4587
+ if (field.kind === "enum") return values[0];
4588
+ return values;
4589
+ }
4590
+ }
4591
+ }
4592
+ async function elicitViaUserInput(request3, fallback, options = {}) {
4593
+ const target = request3.requester ?? fallback;
4594
+ if (!target) return { action: "cancel" };
4595
+ if (request3.mode === "url") {
4596
+ if (request3.signal.aborted) return { action: "cancel" };
4597
+ const response = await target.requestUserInput(
4598
+ toPageRequest(request3, options.openUrl !== void 0),
4599
+ request3.signal
4600
+ );
4601
+ if (!response || request3.signal.aborted) return { action: "cancel" };
4602
+ if (response.status !== "submitted") return { action: "decline" };
4603
+ const choice = response.answers.find((a) => a.questionId === URL_QUESTION)?.selectedOptionIds[0];
4604
+ if (choice === "open" && options.openUrl) {
4605
+ options.openUrl(request3.url);
4606
+ return { action: "accept", content: {} };
4607
+ }
4608
+ if (choice === "self") return { action: "accept", content: {} };
4609
+ return { action: "decline" };
4610
+ }
4611
+ const form = request3;
4612
+ let problem;
4613
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
4614
+ if (request3.signal.aborted) return { action: "cancel" };
4615
+ const response = await target.requestUserInput(
4616
+ toUserInputRequest(form, problem),
4617
+ request3.signal
4618
+ );
4619
+ if (!response || request3.signal.aborted) return { action: "cancel" };
4620
+ if (response.status !== "submitted") return { action: "decline" };
4621
+ const byId = new Map(response.answers.map((answer) => [answer.questionId, answer]));
4622
+ if (form.fields.length === 0) {
4623
+ return byId.get(CONFIRM_QUESTION)?.selectedOptionIds[0] === "yes" ? { action: "accept", content: {} } : { action: "decline" };
4624
+ }
4625
+ const content = {};
4626
+ form.fields.forEach((field, i) => {
4627
+ content[field.name] = answerValue(field, byId.get(questionId(i)));
4628
+ });
4629
+ const checked = validateElicitationContent(form.fields, content);
4630
+ if (checked.ok) return { action: "accept", content: checked.value };
4631
+ problem = checked.error;
4632
+ }
4633
+ return { action: "cancel" };
4634
+ }
3474
4635
 
3475
4636
  // src/manage.ts
3476
4637
  import { randomBytes as randomBytes3 } from "node:crypto";
@@ -3708,11 +4869,11 @@ async function disableMcp(name, deps) {
3708
4869
  }
3709
4870
  const cfg = servers[name];
3710
4871
  if (!cfg) return { ok: false, message: `Server "${name}" is not in config.` };
3711
- await safeStop(name, deps);
3712
4872
  cfg.enabled = false;
3713
- trackDisabled(deps.registry, { ...cfg, name });
3714
4873
  servers[name] = cfg;
3715
4874
  await persist(deps.configPath, full, servers);
4875
+ trackDisabled(deps.registry, { ...cfg, name });
4876
+ await safeStop(name, deps);
3716
4877
  return {
3717
4878
  ok: true,
3718
4879
  message: `Server "${name}" disabled`,
@@ -4023,6 +5184,13 @@ function requireHttpServerConfig(servers, disabledServers, name) {
4023
5184
  }
4024
5185
  return cfg;
4025
5186
  }
5187
+ async function loginRegistryAuthorization(manager, cfg, name, input) {
5188
+ return requireAuthorizationManager(manager).beginLogin({
5189
+ serverName: name,
5190
+ resource: cfg.url,
5191
+ ...input
5192
+ });
5193
+ }
4026
5194
  async function beginRegistryAuthorization(manager, cfg, name, input) {
4027
5195
  return requireAuthorizationManager(manager).begin({
4028
5196
  serverName: name,
@@ -4080,6 +5248,56 @@ import { expectDefined } from "@wrongstack/core/utils";
4080
5248
  // src/wrap-tool.ts
4081
5249
  import { ToolCapabilities } from "@wrongstack/core/security";
4082
5250
  import { mcpQualifiedToolName } from "@wrongstack/core/utils";
5251
+
5252
+ // src/structured-result.ts
5253
+ import { validateAgainstSchema } from "@wrongstack/core/utils";
5254
+ function canonical(value) {
5255
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
5256
+ if (value && typeof value === "object") {
5257
+ const entries = Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`);
5258
+ return `{${entries.join(",")}}`;
5259
+ }
5260
+ return JSON.stringify(value) ?? "null";
5261
+ }
5262
+ function textBlocks(content) {
5263
+ if (typeof content === "string") return [content];
5264
+ if (!Array.isArray(content)) return [];
5265
+ return content.flatMap(
5266
+ (block) => block && typeof block === "object" && block.type === "text" ? [String(block.text ?? "")] : []
5267
+ );
5268
+ }
5269
+ function alreadyInText(content, structured) {
5270
+ const wanted = canonical(structured);
5271
+ return textBlocks(content).some((text) => {
5272
+ const trimmed = text.trim();
5273
+ if (!trimmed.startsWith("{")) return false;
5274
+ try {
5275
+ return canonical(JSON.parse(trimmed)) === wanted;
5276
+ } catch {
5277
+ return false;
5278
+ }
5279
+ });
5280
+ }
5281
+ function renderStructuredResult(rendered, content, structured, outputSchema) {
5282
+ if (!structured) return rendered;
5283
+ const parts = [rendered];
5284
+ if (!alreadyInText(content, structured)) {
5285
+ parts.push(`Structured result:
5286
+ ${JSON.stringify(structured, null, 2)}`);
5287
+ }
5288
+ if (outputSchema) {
5289
+ const check = validateAgainstSchema(structured, outputSchema);
5290
+ if (!check.ok) {
5291
+ const problems = check.errors.slice(0, 3).map((e) => `${e.path || "(root)"}: ${e.message}`).join("; ");
5292
+ parts.push(
5293
+ `[The structured result does not match the tool's declared output schema \u2014 ${problems}]`
5294
+ );
5295
+ }
5296
+ }
5297
+ return parts.filter((part) => part.length > 0).join("\n\n");
5298
+ }
5299
+
5300
+ // src/wrap-tool.ts
4083
5301
  var MUTATING_RE = /create|update|delete|write|send|set|put|post|patch|remove|rename|move/i;
4084
5302
  function isMutatingTool(mcpTool) {
4085
5303
  if (MUTATING_RE.test(mcpTool.name)) return true;
@@ -4106,24 +5324,49 @@ function wrapMCPTool(serverName, mcpTool, client, permission = "confirm", observ
4106
5324
  inputSchema: mcpTool.inputSchema ?? { type: "object", properties: {} },
4107
5325
  async execute(input, ctx, opts) {
4108
5326
  const startedAt = Date.now();
4109
- observer?.onStart();
5327
+ observer?.onStart(ctx);
4110
5328
  let ok = false;
4111
5329
  try {
4112
5330
  const live = typeof client === "function" ? await client() : client;
4113
5331
  const signal = opts?.signal ?? ctx?.signal;
4114
5332
  const res = await live.callTool(mcpTool.name, input, signal ? { signal } : void 0);
5333
+ if (res.urlElicitations && res.urlElicitations.length > 0) {
5334
+ const outcomes = await live.presentUrlElicitations(
5335
+ res.urlElicitations,
5336
+ signal ?? new AbortController().signal
5337
+ );
5338
+ throw new Error(describeUrlOutcomes(serverName, qualifiedName, outcomes));
5339
+ }
4115
5340
  if (res.isError) {
4116
5341
  const errText = stringify(res.content);
4117
5342
  throw new Error(errText || `MCP tool "${qualifiedName}" failed`);
4118
5343
  }
4119
5344
  ok = true;
4120
- return stringify(res.content);
5345
+ return renderStructuredResult(
5346
+ stringify(res.content),
5347
+ res.content,
5348
+ res.structuredContent,
5349
+ mcpTool.outputSchema
5350
+ );
4121
5351
  } finally {
4122
- observer?.onFinish({ durationMs: Date.now() - startedAt, ok });
5352
+ observer?.onFinish({ durationMs: Date.now() - startedAt, ok }, ctx);
4123
5353
  }
4124
5354
  }
4125
5355
  };
4126
5356
  }
5357
+ function describeUrlOutcomes(serverName, toolName, outcomes) {
5358
+ const lines = outcomes.map(({ elicitation, action }) => {
5359
+ const site = new URL(elicitation.url).host;
5360
+ const choice = action === "accept" ? "the user agreed to open it" : action === "decline" ? "the user declined" : "no answer";
5361
+ return `- ${site}: ${elicitation.message} (${choice})`;
5362
+ });
5363
+ const agreed = outcomes.length > 0 && outcomes.every((o) => o.action === "accept");
5364
+ return [
5365
+ `MCP server "${serverName}" needs the user to finish a step in the browser before "${toolName}" can run:`,
5366
+ ...lines,
5367
+ agreed ? "Ask the user to tell you when they have finished, then call the tool again." : "The user did not agree to every page, so do not call the tool again unless they ask."
5368
+ ].join("\n");
5369
+ }
4127
5370
  function renderContentBlock(item) {
4128
5371
  const type = item["type"];
4129
5372
  if (type === "text") return typeof item["text"] === "string" ? item["text"] : "";
@@ -4181,7 +5424,8 @@ function applySlotTools(ctx, slot, tools, client) {
4181
5424
  const clientArg = slot.lazy ? () => ctx.ensureConnected(slot.cfg.name) : expectDefined(client);
4182
5425
  const wrapped = filtered.map(
4183
5426
  (t) => wrapMCPTool(slot.cfg.name, t, clientArg, slot.cfg.permission ?? "confirm", {
4184
- onStart: () => {
5427
+ onStart: (caller) => {
5428
+ if (caller) slot.activeCallers = [...slot.activeCallers ?? [], caller];
4185
5429
  slot.operations.inFlightCalls++;
4186
5430
  slot.operations.peakInFlightCalls = Math.max(
4187
5431
  slot.operations.peakInFlightCalls,
@@ -4189,7 +5433,9 @@ function applySlotTools(ctx, slot, tools, client) {
4189
5433
  );
4190
5434
  ctx.recordOperation(slot, "call", "started", void 0, void 0, false);
4191
5435
  },
4192
- onFinish: ({ durationMs, ok }) => {
5436
+ onFinish: ({ durationMs, ok }, caller) => {
5437
+ const at = caller ? slot.activeCallers?.lastIndexOf(caller) ?? -1 : -1;
5438
+ if (at >= 0) slot.activeCallers?.splice(at, 1);
4193
5439
  slot.operations.inFlightCalls = Math.max(0, slot.operations.inFlightCalls - 1);
4194
5440
  slot.lastUsed = Date.now();
4195
5441
  pushBounded(slot.operations.callSamples, durationMs, MCP_OPERATION_LIMITS.LATENCY_SAMPLES);
@@ -4289,9 +5535,9 @@ async function persistSlotCapabilityManifest(cacheDir, slot) {
4289
5535
  if (slot.manifestWrite === pending) slot.manifestWrite = void 0;
4290
5536
  }
4291
5537
  async function attemptConnectSlot(ctx, slot) {
4292
- const MAX_ATTEMPTS = MCP_CONSTANTS.RECONNECT.MAX_ATTEMPTS;
5538
+ const MAX_ATTEMPTS2 = MCP_CONSTANTS.RECONNECT.MAX_ATTEMPTS;
4293
5539
  let attempt = 0;
4294
- while (attempt < MAX_ATTEMPTS) {
5540
+ while (attempt < MAX_ATTEMPTS2) {
4295
5541
  if (ctx.servers.get(slot.cfg.name) !== slot) {
4296
5542
  return;
4297
5543
  }
@@ -4305,18 +5551,17 @@ async function attemptConnectSlot(ctx, slot) {
4305
5551
  client = new MCPClient({
4306
5552
  name: slot.cfg.name,
4307
5553
  transport: slot.cfg.transport,
4308
- command: slot.cfg.command,
4309
- args: slot.cfg.args,
4310
- env: slot.cfg.env,
4311
- url: slot.cfg.url,
4312
- headers: slot.cfg.headers,
5554
+ // `${VAR}` placeholders resolve here, per connect — never into the
5555
+ // stored config (see config-env.ts).
5556
+ ...expandMcpEnvPlaceholders(slot.cfg),
4313
5557
  bearerTokenEnv: slot.cfg.bearerTokenEnv,
4314
5558
  startupTimeoutMs: slot.cfg.startupTimeoutMs,
4315
5559
  requestTimeoutMs: slot.cfg.requestTimeoutMs,
4316
5560
  cwd: ctx.cwd,
4317
5561
  allowPrivateNetworks: slot.cfg.allowPrivateNetworks,
4318
5562
  passthroughEnv: slot.cfg.passthroughEnv,
4319
- authorizationProvider: ctx.authorizationProviderFactory?.(slot.cfg)
5563
+ authorizationProvider: ctx.authorizationProviderFactory?.(slot.cfg),
5564
+ elicitation: slotElicitation(ctx, slot)
4320
5565
  });
4321
5566
  if (slot.cfg.transport === "stdio") {
4322
5567
  client.addExitListener(ctx.onChildExit);
@@ -4389,9 +5634,9 @@ async function attemptConnectSlot(ctx, slot) {
4389
5634
  await client.close().catch(() => {
4390
5635
  });
4391
5636
  }
4392
- if (attempt >= MAX_ATTEMPTS) {
5637
+ if (attempt >= MAX_ATTEMPTS2) {
4393
5638
  ctx.log.error(
4394
- `MCP server "${slot.cfg.name}" connect exhausted after ${MAX_ATTEMPTS} attempts`,
5639
+ `MCP server "${slot.cfg.name}" connect exhausted after ${MAX_ATTEMPTS2} attempts`,
4395
5640
  err
4396
5641
  );
4397
5642
  slot.state = "failed";
@@ -4415,6 +5660,11 @@ async function attemptConnectSlot(ctx, slot) {
4415
5660
  }
4416
5661
  }
4417
5662
  }
5663
+ function slotElicitation(ctx, slot) {
5664
+ const handler = ctx.elicitationHandler;
5665
+ if (!handler) return void 0;
5666
+ return (form) => handler({ ...form, server: slot.cfg.name, requester: slot.activeCallers?.at(-1) });
5667
+ }
4418
5668
 
4419
5669
  // src/registry-disconnect.ts
4420
5670
  function resetDisconnectedSlotTools(slot, toolRegistry) {
@@ -4536,6 +5786,7 @@ async function sleepIdleSlot(ctx, slot) {
4536
5786
  ctx.log.warn(`MCP server "${slot.cfg.name}" error during idle sleep close`, err);
4537
5787
  }
4538
5788
  }
5789
+ if (slot.client || slot.state !== "dormant") return;
4539
5790
  slot.onDisconnect = void 0;
4540
5791
  slot.operations.sleepCount++;
4541
5792
  ctx.recordOperation(slot, "sleep", "idle-timeout");
@@ -4651,7 +5902,10 @@ var MCPRegistry = class _MCPRegistry {
4651
5902
  idleTimeoutMs;
4652
5903
  authorizationProviderFactory;
4653
5904
  authorizationManager;
5905
+ elicitationHandler;
4654
5906
  operationListeners = /* @__PURE__ */ new Set();
5907
+ /** Starts still in flight, for {@link whenStarted}. */
5908
+ startups = /* @__PURE__ */ new Set();
4655
5909
  /** Single shared idle sweep timer (started lazily; unref'd; cleared on stopAll). */
4656
5910
  idleTimer;
4657
5911
  constructor(opts) {
@@ -4664,12 +5918,17 @@ var MCPRegistry = class _MCPRegistry {
4664
5918
  this.idleTimeoutMs = opts.idleTimeoutMs ?? MCP_CONSTANTS.IDLE.DEFAULT_TIMEOUT_MS;
4665
5919
  this.authorizationProviderFactory = opts.authorizationProviderFactory;
4666
5920
  this.authorizationManager = opts.authorizationManager;
5921
+ this.elicitationHandler = opts.elicitationHandler;
4667
5922
  }
4668
5923
  requireSlot(name) {
4669
5924
  const slot = this.servers.get(name);
4670
5925
  if (!slot) throw new Error(`MCP server "${name}" not registered`);
4671
5926
  return slot;
4672
5927
  }
5928
+ async loginAuthorization(name, input) {
5929
+ const cfg = this.requireHttpServerConfig(name);
5930
+ return loginRegistryAuthorization(this.authorizationManager, cfg, name, input);
5931
+ }
4673
5932
  async beginAuthorization(name, input) {
4674
5933
  const cfg = this.requireHttpServerConfig(name);
4675
5934
  return beginRegistryAuthorization(this.authorizationManager, cfg, name, input);
@@ -4720,11 +5979,24 @@ var MCPRegistry = class _MCPRegistry {
4720
5979
  operations: createMCPServerOperationState()
4721
5980
  };
4722
5981
  this.servers.set(cfg.name, slot);
4723
- if (lazy) {
4724
- await this.startLazy(slot);
4725
- } else {
4726
- await this.singleFlightConnect(slot);
4727
- }
5982
+ const startup = lazy ? this.startLazy(slot) : this.singleFlightConnect(slot);
5983
+ const settled = startup.then(
5984
+ () => {
5985
+ },
5986
+ () => {
5987
+ }
5988
+ );
5989
+ this.startups.add(settled);
5990
+ void settled.finally(() => this.startups.delete(settled));
5991
+ await startup;
5992
+ }
5993
+ /**
5994
+ * Resolves once every start already in flight has connected or failed. A
5995
+ * host that starts its servers in the background and then runs a single
5996
+ * turn waits here, so the turn's tool list includes theirs.
5997
+ */
5998
+ async whenStarted() {
5999
+ while (this.startups.size > 0) await Promise.all([...this.startups]);
4728
6000
  }
4729
6001
  /** Record an intentionally disabled configuration without opening a transport. */
4730
6002
  markDisabled(cfg) {
@@ -4876,10 +6148,13 @@ var MCPRegistry = class _MCPRegistry {
4876
6148
  slot.reconnectTimer = void 0;
4877
6149
  }
4878
6150
  slot.state = "disconnected";
4879
- if (slot.client) {
6151
+ while (slot.client) {
4880
6152
  const client = slot.client;
6153
+ slot.client = void 0;
6154
+ const handler = slot.onDisconnect;
6155
+ slot.onDisconnect = void 0;
4881
6156
  client.removeExitListener?.(this.onChildExit);
4882
- if (slot.onDisconnect) client.removeDisconnectListener?.(slot.onDisconnect);
6157
+ if (handler) client.removeDisconnectListener?.(handler);
4883
6158
  client.removeToolsChangedListener?.(this.onToolsChanged);
4884
6159
  this.removeCatalogListeners(client);
4885
6160
  try {
@@ -4887,9 +6162,8 @@ var MCPRegistry = class _MCPRegistry {
4887
6162
  } catch (err) {
4888
6163
  this.log.warn(`MCP server "${name}" error during stop close`, err);
4889
6164
  }
4890
- slot.client = void 0;
6165
+ slot.state = "disconnected";
4891
6166
  }
4892
- slot.onDisconnect = void 0;
4893
6167
  slot.connecting = void 0;
4894
6168
  resetDisconnectedSlotTools(slot, this.toolRegistry);
4895
6169
  slot.registeredLazy = false;
@@ -5065,6 +6339,7 @@ var MCPRegistry = class _MCPRegistry {
5065
6339
  cacheDir: this.cacheDir,
5066
6340
  cwd: this.cwd,
5067
6341
  authorizationProviderFactory: this.authorizationProviderFactory,
6342
+ elicitationHandler: this.elicitationHandler,
5068
6343
  operationListeners: this.operationListeners,
5069
6344
  ensureConnected: (name) => this.ensureConnected(name),
5070
6345
  recordOperation: (slot, kind, reason, failureKind, durationMs, retain) => this.recordOperation(slot, kind, reason, failureKind, durationMs, retain),
@@ -5190,6 +6465,10 @@ var MCPRegistry = class _MCPRegistry {
5190
6465
  void this.persistCapabilityManifest(slot);
5191
6466
  this.log.info(`MCP server "${name}" resource catalog invalidated`);
5192
6467
  };
6468
+ onResourceUpdated = (name, uri) => {
6469
+ if (!this.servers.has(name)) return;
6470
+ this.events.emit("mcp.resource.updated", { name, uri });
6471
+ };
5193
6472
  onPromptsChanged = (name) => {
5194
6473
  const slot = this.servers.get(name);
5195
6474
  if (!slot) return;
@@ -5200,10 +6479,12 @@ var MCPRegistry = class _MCPRegistry {
5200
6479
  addCatalogListeners(client) {
5201
6480
  client.addResourcesChangedListener(this.onResourcesChanged);
5202
6481
  client.addPromptsChangedListener(this.onPromptsChanged);
6482
+ client.addResourceUpdatedListener?.(this.onResourceUpdated);
5203
6483
  }
5204
6484
  removeCatalogListeners(client) {
5205
6485
  client.removeResourcesChangedListener?.(this.onResourcesChanged);
5206
6486
  client.removePromptsChangedListener?.(this.onPromptsChanged);
6487
+ client.removeResourceUpdatedListener?.(this.onResourceUpdated);
5207
6488
  }
5208
6489
  onChildExit = (name, code, _signal) => {
5209
6490
  const slot = this.servers.get(name);
@@ -5288,8 +6569,8 @@ var MCPRegistry = class _MCPRegistry {
5288
6569
 
5289
6570
  // src/server.ts
5290
6571
  import { timingSafeEqual as timingSafeEqual2 } from "node:crypto";
5291
- import { createServer } from "node:http";
5292
- import { expectDefined as expectDefined3, toErrorMessage as toErrorMessage2, validateAgainstSchema } from "@wrongstack/core/utils";
6572
+ import { createServer as createServer2 } from "node:http";
6573
+ import { expectDefined as expectDefined3, toErrorMessage as toErrorMessage2, validateAgainstSchema as validateAgainstSchema2 } from "@wrongstack/core/utils";
5293
6574
  var PARSE_ERROR = -32700;
5294
6575
  var INVALID_REQUEST = -32600;
5295
6576
  var METHOD_NOT_FOUND = -32601;
@@ -5482,7 +6763,7 @@ var MCPServer = class {
5482
6763
  }
5483
6764
  const schema = tools.find((tool) => tool.name === name)?.inputSchema;
5484
6765
  if (!schema || typeof schema !== "object") return;
5485
- const result = validateAgainstSchema(
6766
+ const result = validateAgainstSchema2(
5486
6767
  args,
5487
6768
  schema
5488
6769
  );
@@ -5527,14 +6808,14 @@ function requiredParamString(params, field, method) {
5527
6808
  }
5528
6809
  return value;
5529
6810
  }
5530
- function stringRecord(value, label) {
6811
+ function stringRecord(value, label2) {
5531
6812
  if (value === void 0) return {};
5532
6813
  if (!value || typeof value !== "object" || Array.isArray(value)) {
5533
- throw new Error(`${label} must be an object`);
6814
+ throw new Error(`${label2} must be an object`);
5534
6815
  }
5535
6816
  const result = {};
5536
6817
  for (const [key, item] of Object.entries(value)) {
5537
- if (typeof item !== "string") throw new Error(`${label}.${key} must be a string`);
6818
+ if (typeof item !== "string") throw new Error(`${label2}.${key} must be a string`);
5538
6819
  result[key] = item;
5539
6820
  }
5540
6821
  return result;
@@ -5716,7 +6997,7 @@ function serveHttp(server, opts = {}) {
5716
6997
  )
5717
6998
  );
5718
6999
  }
5719
- const httpServer = createServer((req, res) => {
7000
+ const httpServer = createServer2((req, res) => {
5720
7001
  void handleHttpRequest(server, req, res, token, log, host);
5721
7002
  });
5722
7003
  return new Promise((resolve, reject) => {
@@ -5792,8 +7073,8 @@ async function handleHttpRequest(server, req, res, token, log, boundHost) {
5792
7073
  });
5793
7074
  });
5794
7075
  }
5795
- function isLoopbackHostname(hostname) {
5796
- const bare = hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
7076
+ function isLoopbackHostname(hostname2) {
7077
+ const bare = hostname2.toLowerCase().replace(/^\[(.*)\]$/, "$1");
5797
7078
  return bare === "localhost" || bare === "::1" || bare === "127.0.0.1" || bare.startsWith("127.");
5798
7079
  }
5799
7080
  function originIsAcceptable(req, boundHost) {
@@ -5920,13 +7201,15 @@ var MCPVaultTokenStore = class {
5920
7201
  encryptEntry(value) {
5921
7202
  const accessToken = this.vault.encrypt(value.tokenSet.accessToken);
5922
7203
  const refreshToken = value.tokenSet.refreshToken ? this.vault.encrypt(value.tokenSet.refreshToken) : void 0;
5923
- if (!this.vault.isEncrypted(accessToken) || refreshToken && !this.vault.isEncrypted(refreshToken)) {
7204
+ const clientSecret = value.clientSecret ? this.vault.encrypt(value.clientSecret) : void 0;
7205
+ if (!this.vault.isEncrypted(accessToken) || refreshToken && !this.vault.isEncrypted(refreshToken) || clientSecret && !this.vault.isEncrypted(clientSecret)) {
5924
7206
  throw new Error("MCP token store requires an encrypting SecretVault");
5925
7207
  }
5926
7208
  return {
5927
7209
  serverName: value.serverName,
5928
7210
  resource: value.resource,
5929
7211
  clientId: value.clientId,
7212
+ clientSecret,
5930
7213
  authorizationServer: value.authorizationServer,
5931
7214
  accessToken,
5932
7215
  refreshToken,
@@ -5937,13 +7220,14 @@ var MCPVaultTokenStore = class {
5937
7220
  };
5938
7221
  }
5939
7222
  decryptEntry(entry) {
5940
- if (!this.vault.isEncrypted(entry.accessToken) || entry.refreshToken !== void 0 && !this.vault.isEncrypted(entry.refreshToken)) {
7223
+ if (!this.vault.isEncrypted(entry.accessToken) || entry.refreshToken !== void 0 && !this.vault.isEncrypted(entry.refreshToken) || entry.clientSecret !== void 0 && !this.vault.isEncrypted(entry.clientSecret)) {
5941
7224
  throw new Error("MCP token store contains an unencrypted token");
5942
7225
  }
5943
7226
  const value = {
5944
7227
  serverName: entry.serverName,
5945
7228
  resource: entry.resource,
5946
7229
  clientId: entry.clientId,
7230
+ ...entry.clientSecret !== void 0 ? { clientSecret: this.vault.decrypt(entry.clientSecret) } : {},
5947
7231
  authorizationServer: entry.authorizationServer,
5948
7232
  tokenSet: {
5949
7233
  accessToken: this.vault.decrypt(entry.accessToken),
@@ -6014,6 +7298,7 @@ var MCPRefreshingAuthorizationProvider = class {
6014
7298
  tokenSet = await refreshMcpAccessToken({
6015
7299
  authorizationServer: state.authorizationServer,
6016
7300
  clientId: state.clientId,
7301
+ clientSecret: state.clientSecret,
6017
7302
  resource: state.resource,
6018
7303
  refreshToken
6019
7304
  });
@@ -6087,7 +7372,7 @@ function emptyFile() {
6087
7372
  return { version: TOKEN_STORE_VERSION, updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(), entries: [] };
6088
7373
  }
6089
7374
  function validateStoreFile(value) {
6090
- if (!isRecord(value) || value["version"] !== TOKEN_STORE_VERSION || !Array.isArray(value["entries"])) {
7375
+ if (!isRecord2(value) || value["version"] !== TOKEN_STORE_VERSION || !Array.isArray(value["entries"])) {
6091
7376
  throw new Error("MCP token store has an unsupported or malformed structure");
6092
7377
  }
6093
7378
  if (value["entries"].length > MAX_ENTRIES)
@@ -6099,7 +7384,7 @@ function validateStoreFile(value) {
6099
7384
  };
6100
7385
  }
6101
7386
  function validateEncryptedEntry(value) {
6102
- if (!isRecord(value)) throw new Error("MCP token store entry must be an object");
7387
+ if (!isRecord2(value)) throw new Error("MCP token store entry must be an object");
6103
7388
  const resource = canonicalMcpResource(boundedString(value["resource"], "resource", 4096));
6104
7389
  const authorizationServer = validateMcpAuthorizationServerMetadata(value["authorizationServer"]);
6105
7390
  const scopes = stringArray(value["scopes"], "scopes", 128);
@@ -6111,6 +7396,7 @@ function validateEncryptedEntry(value) {
6111
7396
  serverName: boundedString(value["serverName"], "serverName", 256),
6112
7397
  resource,
6113
7398
  clientId: boundedString(value["clientId"], "clientId", 4096),
7399
+ clientSecret: value["clientSecret"] === void 0 ? void 0 : boundedString(value["clientSecret"], "clientSecret", 32768),
6114
7400
  authorizationServer,
6115
7401
  accessToken: boundedString(value["accessToken"], "accessToken", 32768),
6116
7402
  refreshToken: value["refreshToken"] === void 0 ? void 0 : boundedString(value["refreshToken"], "refreshToken", 32768),
@@ -6137,12 +7423,13 @@ function normalizeStoredAuthorization(value) {
6137
7423
  serverName,
6138
7424
  resource,
6139
7425
  clientId: boundedString(value.clientId, "clientId", 4096),
7426
+ ...value.clientSecret ? { clientSecret: boundedString(value.clientSecret, "clientSecret", 4096) } : {},
6140
7427
  authorizationServer,
6141
7428
  tokenSet,
6142
7429
  updatedAt: boundedString(value.updatedAt, "updatedAt", 128)
6143
7430
  };
6144
7431
  }
6145
- function isRecord(value) {
7432
+ function isRecord2(value) {
6146
7433
  return typeof value === "object" && value !== null && !Array.isArray(value);
6147
7434
  }
6148
7435
  function boundedString(value, field, maxLength) {
@@ -6182,10 +7469,12 @@ export {
6182
7469
  disableMcp,
6183
7470
  discoverMcp,
6184
7471
  discoverMcpAuthorization,
7472
+ elicitViaUserInput,
6185
7473
  enableMcp,
6186
7474
  exchangeMcpAuthorizationCode,
6187
7475
  listMcp,
6188
7476
  manifestConfigHash,
7477
+ mcpEnvPlaceholders,
6189
7478
  negotiateProtocolVersion,
6190
7479
  parseAuthorizationServerMetadata,
6191
7480
  parseGetPromptResult,
@@ -6203,10 +7492,12 @@ export {
6203
7492
  readCapabilityManifest,
6204
7493
  readManifest,
6205
7494
  refreshMcpAccessToken,
7495
+ registerMcpOAuthClient,
6206
7496
  removeMcp,
6207
7497
  restartMcp,
6208
7498
  serveHttp,
6209
7499
  serveStdio,
7500
+ startMcpOAuthCallbackServer,
6210
7501
  toContentBlocks,
6211
7502
  updateMcp,
6212
7503
  validateMcpAuthorizationServerMetadata,