@ractive-ch/tab-mcp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -751,9 +751,11 @@ class TabAssertionVerifier {
751
751
  #seen = new Map;
752
752
  #perPerson = new Map;
753
753
  #sweptAt = Number.NEGATIVE_INFINITY;
754
- constructor(oidc, keys) {
754
+ #maxSeen;
755
+ constructor(oidc, keys, maxSeen = MAX_SEEN_JTI) {
755
756
  this.#oidc = oidc;
756
757
  this.#keys = keys ?? new JwksCache(oidc.assertionJwksUrl);
758
+ this.#maxSeen = maxSeen;
757
759
  }
758
760
  async verify(token, address, now = Date.now()) {
759
761
  const { payload } = await verifyJwt(token, this.#keys, {
@@ -799,15 +801,18 @@ ${jti}`;
799
801
  throw new JwtError("replayed", "the assertion was already used");
800
802
  }
801
803
  const mine = this.#perPerson.get(key) ?? 0;
802
- if (mine >= MAX_JTI_PER_PERSON || this.#seen.size >= MAX_SEEN_JTI) {
804
+ if (mine >= MAX_JTI_PER_PERSON) {
803
805
  throw new JwtError("busy", "too many assertions at once");
804
806
  }
807
+ if (this.#seen.size >= this.#maxSeen) {
808
+ throw new JwtError("relay_busy", "the relay is checking too many assertions; try again shortly");
809
+ }
805
810
  this.#seen.set(seenKey, exp + CLOCK_LEEWAY_MS);
806
811
  this.#perPerson.set(key, mine + 1);
807
812
  return key;
808
813
  }
809
814
  #sweep(now) {
810
- if (now - this.#sweptAt < 1000 && this.#seen.size < MAX_SEEN_JTI)
815
+ if (now - this.#sweptAt < 1000 && this.#seen.size < this.#maxSeen)
811
816
  return;
812
817
  this.#sweptAt = now;
813
818
  for (const [seenKey, until] of this.#seen) {
@@ -997,13 +1002,15 @@ function truncateIp(ip) {
997
1002
  }
998
1003
  return `${v6.slice(0, 3).map((g) => g.toString(16)).join(":")}::/48`;
999
1004
  }
1005
+ function errorName(error) {
1006
+ return error instanceof Error ? error.name : typeof error;
1007
+ }
1000
1008
  function installCrashLogging(logger, exit = (code) => process.exit(code)) {
1001
- const name = (error) => error instanceof Error ? error.name : typeof error;
1002
1009
  const onRejection = (error) => {
1003
- logger.error("process.unhandled_rejection", { error: name(error) });
1010
+ logger.error("process.unhandled_rejection", { error: errorName(error) });
1004
1011
  };
1005
1012
  const onException = (error) => {
1006
- logger.error("process.uncaught_exception", { error: name(error) });
1013
+ logger.error("process.uncaught_exception", { error: errorName(error) });
1007
1014
  exit(1);
1008
1015
  };
1009
1016
  process.on("unhandledRejection", onRejection);
@@ -1191,7 +1198,7 @@ function parseTabMessage(text) {
1191
1198
  }
1192
1199
  }
1193
1200
  // package.json
1194
- var version = "0.3.0";
1201
+ var version = "0.4.0";
1195
1202
 
1196
1203
  // src/exchange.ts
1197
1204
  var RelayErrorCode = {
@@ -1203,7 +1210,8 @@ var RelayErrorCode = {
1203
1210
  tooLarge: -31006,
1204
1211
  badResponse: -31007,
1205
1212
  storeUnavailable: -31008,
1206
- signedOut: -31009
1213
+ signedOut: -31009,
1214
+ restarting: -31010
1207
1215
  };
1208
1216
  var encoder = new TextEncoder;
1209
1217
  var NULL_BODY_STATUSES = new Set([204, 205, 304]);
@@ -1232,6 +1240,11 @@ function rpcErrorBody(rpcId, code, message, data) {
1232
1240
  error["data"] = data;
1233
1241
  return JSON.stringify({ jsonrpc: "2.0", id: rpcId ?? null, error });
1234
1242
  }
1243
+ var TAB_STARTED = new Set([
1244
+ "tab_stream",
1245
+ "relay_passed",
1246
+ "relay_wrapped"
1247
+ ]);
1235
1248
 
1236
1249
  class Exchange {
1237
1250
  id;
@@ -1243,9 +1256,6 @@ class Exchange {
1243
1256
  #options;
1244
1257
  #resolve;
1245
1258
  #state = "waiting";
1246
- #relayStarted = false;
1247
- #tabStarted = false;
1248
- #wrap = false;
1249
1259
  #wrapped = "";
1250
1260
  #controller;
1251
1261
  #atBoundary = true;
@@ -1271,7 +1281,7 @@ class Exchange {
1271
1281
  return this.#state === "done";
1272
1282
  }
1273
1283
  onResponse(msg) {
1274
- if (this.#tabStarted) {
1284
+ if (TAB_STARTED.has(this.#state)) {
1275
1285
  this.#badResponse("http.response after http.response.start");
1276
1286
  return;
1277
1287
  }
@@ -1294,7 +1304,7 @@ class Exchange {
1294
1304
  }
1295
1305
  this.#respond(response, msg.status);
1296
1306
  this.#finish("ok");
1297
- } else if (this.#state === "streaming" && this.#relayStarted) {
1307
+ } else if (this.#state === "relay_stream") {
1298
1308
  if (!this.#account(msg.body))
1299
1309
  return;
1300
1310
  if (msg.body !== "")
@@ -1303,11 +1313,17 @@ class Exchange {
1303
1313
  }
1304
1314
  }
1305
1315
  onStart(msg) {
1306
- if (this.#tabStarted) {
1316
+ if (this.#state === "done")
1317
+ return;
1318
+ if (TAB_STARTED.has(this.#state)) {
1307
1319
  this.#badResponse("http.response.start sent twice");
1308
1320
  return;
1309
1321
  }
1310
- this.#tabStarted = true;
1322
+ const eventStream = isEventStream(msg.headers);
1323
+ if (this.isListen && !eventStream) {
1324
+ this.#badResponse("a listen response must be an event stream");
1325
+ return;
1326
+ }
1311
1327
  if (this.#state === "waiting") {
1312
1328
  if (NULL_BODY_STATUSES.has(msg.status)) {
1313
1329
  this.#badResponse(`status ${msg.status} cannot be streamed`);
@@ -1320,25 +1336,29 @@ class Exchange {
1320
1336
  this.#badResponse("the response headers are invalid");
1321
1337
  return;
1322
1338
  }
1323
- this.#openStream(msg.status, headers);
1324
- } else if (this.#state === "streaming" && this.#relayStarted) {
1325
- this.#wrap = !isEventStream(msg.headers);
1339
+ this.#openStream(msg.status, headers, "tab_stream");
1340
+ } else {
1341
+ this.#state = eventStream ? "relay_passed" : "relay_wrapped";
1326
1342
  }
1327
1343
  }
1328
1344
  onChunk(data) {
1329
- if (this.#state !== "streaming")
1345
+ if (!this.#streaming)
1346
+ return;
1347
+ if (this.#state === "relay_stream") {
1348
+ this.#badResponse("http.response.chunk before http.response.start");
1330
1349
  return;
1350
+ }
1331
1351
  if (!this.#account(data))
1332
1352
  return;
1333
- if (this.#wrap)
1353
+ if (this.#state === "relay_wrapped")
1334
1354
  this.#wrapped += data;
1335
1355
  else
1336
1356
  this.#write(data);
1337
1357
  }
1338
1358
  onEnd() {
1339
- if (this.#state !== "streaming")
1359
+ if (!this.#streaming)
1340
1360
  return;
1341
- if (this.#wrap && this.#wrapped !== "")
1361
+ if (this.#state === "relay_wrapped" && this.#wrapped !== "")
1342
1362
  this.#write(sseEvent(this.#wrapped));
1343
1363
  this.#finish("ok");
1344
1364
  }
@@ -1360,6 +1380,11 @@ class Exchange {
1360
1380
  this.#finish("page_gone_retry");
1361
1381
  return;
1362
1382
  }
1383
+ if (reply !== undefined && "error" in reply) {
1384
+ const e = reply.error;
1385
+ this.fail(e.status, e.code, e.message, false, e.headers);
1386
+ return;
1387
+ }
1363
1388
  const body = reply?.retry === false ? reply.body : undefined;
1364
1389
  if (body === undefined) {
1365
1390
  this.fail(503, RelayErrorCode.tabGone, "The page closed its connection to the relay.", false);
@@ -1375,11 +1400,11 @@ class Exchange {
1375
1400
  }
1376
1401
  this.#finish("page_gone");
1377
1402
  }
1378
- fail(status, code, message, cancelTab) {
1403
+ fail(status, code, message, cancelTab, headers = {}) {
1379
1404
  if (this.#state === "done")
1380
1405
  return;
1381
1406
  if (this.#state === "waiting") {
1382
- this.#respond(this.#errorResponse(status, code, message), status);
1407
+ this.#respond(this.#errorResponse(status, code, message, headers), status);
1383
1408
  } else if (this.#atBoundary && this.#options.rpcId !== undefined) {
1384
1409
  this.#write(sseEvent(rpcErrorBody(this.#options.rpcId, code, message)));
1385
1410
  }
@@ -1393,7 +1418,8 @@ class Exchange {
1393
1418
  }
1394
1419
  #account(data) {
1395
1420
  this.#bodyBytes += Buffer.byteLength(data);
1396
- if (this.isListen || this.#bodyBytes <= this.#options.maxBodyBytes) {
1421
+ const unbounded = this.isListen && (this.#state === "tab_stream" || this.#state === "relay_passed");
1422
+ if (unbounded || this.#bodyBytes <= this.#options.maxBodyBytes) {
1397
1423
  return true;
1398
1424
  }
1399
1425
  this.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
@@ -1408,10 +1434,9 @@ class Exchange {
1408
1434
  #startRelayStream() {
1409
1435
  if (this.#state !== "waiting")
1410
1436
  return;
1411
- this.#relayStarted = true;
1412
- this.#openStream(200, this.#headers({ "content-type": "text/event-stream" }));
1437
+ this.#openStream(200, this.#headers({ "content-type": "text/event-stream" }), "relay_stream");
1413
1438
  }
1414
- #openStream(status, headers) {
1439
+ #openStream(status, headers, state) {
1415
1440
  const stream = new ReadableStream({
1416
1441
  start: (controller) => {
1417
1442
  this.#controller = controller;
@@ -1432,12 +1457,12 @@ class Exchange {
1432
1457
  this.#badResponse("the response could not be built");
1433
1458
  return;
1434
1459
  }
1435
- this.#state = "streaming";
1460
+ this.#state = state;
1436
1461
  this.#respond(response, status);
1437
1462
  this.#scheduleKeepAlive();
1438
1463
  }
1439
1464
  #write(text) {
1440
- if (this.#state !== "streaming" || this.#controller === undefined || text === "")
1465
+ if (!this.#streaming || this.#controller === undefined || text === "")
1441
1466
  return;
1442
1467
  try {
1443
1468
  this.#controller.enqueue(encoder.encode(text));
@@ -1454,16 +1479,19 @@ class Exchange {
1454
1479
  }
1455
1480
  this.#scheduleKeepAlive();
1456
1481
  }
1482
+ get #streaming() {
1483
+ return this.#state !== "waiting" && this.#state !== "done";
1484
+ }
1457
1485
  #scheduleKeepAlive() {
1458
1486
  clearTimeout(this.#keepAliveTimer);
1459
- if (this.#state !== "streaming" || this.#wrap) {
1460
- if (this.#state === "streaming") {
1461
- this.#keepAliveTimer = setTimeout(() => {
1462
- this.#write(`: keep-alive
1487
+ if (!this.#streaming)
1488
+ return;
1489
+ if (this.#state === "relay_wrapped") {
1490
+ this.#keepAliveTimer = setTimeout(() => {
1491
+ this.#write(`: keep-alive
1463
1492
 
1464
1493
  `);
1465
- }, this.#options.keepAliveMs);
1466
- }
1494
+ }, this.#options.keepAliveMs);
1467
1495
  return;
1468
1496
  }
1469
1497
  this.#keepAliveTimer = setTimeout(() => {
@@ -1483,8 +1511,10 @@ class Exchange {
1483
1511
  }
1484
1512
  return headers;
1485
1513
  }
1486
- #errorResponse(status, code, message) {
1514
+ #errorResponse(status, code, message, extra) {
1487
1515
  const headers = this.#headers({ "content-type": "application/json" });
1516
+ for (const [k, v] of Object.entries(extra))
1517
+ headers.set(k, v);
1488
1518
  return new Response(rpcErrorBody(this.#options.rpcId, code, message), {
1489
1519
  status,
1490
1520
  headers
@@ -1498,7 +1528,7 @@ class Exchange {
1498
1528
  #finish(outcome, closeStream = true) {
1499
1529
  if (this.#state === "done")
1500
1530
  return;
1501
- const wasStreaming = this.#state === "streaming";
1531
+ const wasStreaming = this.#streaming;
1502
1532
  this.#state = "done";
1503
1533
  this.outcome = outcome;
1504
1534
  clearTimeout(this.#startTimer);
@@ -1527,19 +1557,28 @@ function notOpenText(appName) {
1527
1557
  function signedOutText(appName) {
1528
1558
  return `The page is open, but nobody is signed in to it. Ask the user to sign in to ${pageName(appName)}, then try again.`;
1529
1559
  }
1560
+ function restartingText() {
1561
+ return "The relay is restarting. Try again in a few seconds.";
1562
+ }
1530
1563
  function phaseText(phase, appName) {
1564
+ if (phase === "restarting" || phase === "restarting_gone") {
1565
+ return restartingText();
1566
+ }
1531
1567
  return phase === "signed_out" ? signedOutText(appName) : notOpenText(appName);
1532
1568
  }
1533
1569
  function goneDuringCallText(appName) {
1534
1570
  return `The page closed while the tool was running, so it may or may not have finished. Ask the user to open ${pageName(appName)} and keep it in front (on a phone, in split screen), then check the result before trying again.`;
1535
1571
  }
1572
+ function restartedDuringCallText() {
1573
+ return "The relay restarted while the tool was running, so it may or may not have finished. Check the result before trying again in a few seconds.";
1574
+ }
1536
1575
  function notOpenAnswer(body, rpcId, appName, phase, relay) {
1537
1576
  if (rpcId === undefined)
1538
1577
  return;
1539
1578
  const id = rpcId;
1540
1579
  const method = body["method"];
1541
1580
  if (method === "tools/call") {
1542
- const text = phase === "gone" ? goneDuringCallText(appName) : phaseText(phase, appName);
1581
+ const text = phase === "gone" ? goneDuringCallText(appName) : phase === "restarting_gone" ? restartedDuringCallText() : phaseText(phase, appName);
1543
1582
  return JSON.stringify({
1544
1583
  jsonrpc: "2.0",
1545
1584
  id,
@@ -1574,7 +1613,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
1574
1613
  jsonrpc: "2.0",
1575
1614
  id,
1576
1615
  error: {
1577
- code: phase === "gone" ? RelayErrorCode.tabGone : RelayErrorCode.pageNotOpen,
1616
+ code: phase === "gone" ? RelayErrorCode.tabGone : phase === "restarting" || phase === "restarting_gone" ? RelayErrorCode.restarting : RelayErrorCode.pageNotOpen,
1578
1617
  message: phaseText(phase, appName)
1579
1618
  }
1580
1619
  });
@@ -1614,6 +1653,13 @@ class RateLimiter {
1614
1653
  bucket.tokens -= 1;
1615
1654
  return true;
1616
1655
  }
1656
+ available(key, now = Date.now()) {
1657
+ const bucket = this.#buckets.get(key);
1658
+ if (bucket === undefined)
1659
+ return this.#capacity >= 1;
1660
+ const elapsed = Math.max(0, now - bucket.updatedAt) / 1000;
1661
+ return Math.min(this.#capacity, bucket.tokens + elapsed * this.#refill) >= 1;
1662
+ }
1617
1663
  sweep(now = Date.now()) {
1618
1664
  for (const [key, b] of this.#buckets) {
1619
1665
  const elapsed = Math.max(0, now - b.updatedAt) / 1000;
@@ -1699,7 +1745,7 @@ async function defaultLookup(hostname) {
1699
1745
  const records = await Bun.dns.lookup(hostname, {});
1700
1746
  return records.map((r) => r.address);
1701
1747
  }
1702
- var INVISIBLE = /[\u034F\u115F\u1160\u17B4\u17B5\u2800\u3164\uFFA0]/gu;
1748
+ var INVISIBLE = /[\u034F\u115F\u1160\u17B4\u17B5\u2800\u3164\uFFA0]|\p{Variation_Selector}/gu;
1703
1749
  function displayName(raw) {
1704
1750
  let cleaned = raw.normalize("NFC").replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, " ").replace(/\p{Cf}/gu, "").replace(INVISIBLE, "");
1705
1751
  cleaned = cleaned.replace(/\s+/g, " ").trim();
@@ -1722,10 +1768,17 @@ function isIpLiteral(hostname) {
1722
1768
  const h = hostname.replace(/^\[|\]$/g, "");
1723
1769
  return parseIpv4(h) !== undefined || parseIpv6(h) !== undefined;
1724
1770
  }
1771
+ function limitKey(hostname) {
1772
+ const h = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
1773
+ if (isIpLiteral(h))
1774
+ return h;
1775
+ return h.split(".").slice(-2).join(".");
1776
+ }
1725
1777
 
1726
1778
  class ClientMetadataFetcher {
1727
1779
  #cache = new Map;
1728
1780
  #perHost = new RateLimiter({ capacity: 5, refillPerSecond: 0.2 });
1781
+ #perDomain = new RateLimiter({ capacity: 15, refillPerSecond: 0.6 });
1729
1782
  #global = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
1730
1783
  #allowedHosts;
1731
1784
  #logger;
@@ -1749,22 +1802,28 @@ class ClientMetadataFetcher {
1749
1802
  const url = this.#checkUrl(clientId);
1750
1803
  const now = this.#now();
1751
1804
  const cached = this.#cache.get(clientId);
1752
- if (cached !== undefined && cached.until > now)
1805
+ if (cached !== undefined && cached.until > now) {
1806
+ this.#cache.delete(clientId);
1807
+ this.#cache.set(clientId, cached);
1753
1808
  return cached.meta;
1754
- if (!this.#perHost.take(url.host, now)) {
1809
+ }
1810
+ const key = limitKey(url.hostname);
1811
+ if (!this.#perHost.available(url.host, now) || !this.#perDomain.available(key, now)) {
1755
1812
  throw new ClientMetadataError("rate_limited", "Too many requests for this AI app's details. Try again in a minute.");
1756
1813
  }
1757
1814
  if (!this.#global.take("all", now)) {
1758
1815
  throw new ClientMetadataError("rate_limited", "The relay is busy. Try again in a minute.");
1759
1816
  }
1817
+ this.#perHost.take(url.host, now);
1818
+ this.#perDomain.take(key, now);
1760
1819
  const address = await this.#vettedAddress(url);
1761
1820
  const { meta, ttlMs } = await this.#fetchDocument(url, clientId, address);
1821
+ this.#cache.delete(clientId);
1762
1822
  if (this.#cache.size >= MAX_CACHE_ENTRIES) {
1763
1823
  const oldest = this.#cache.keys().next().value;
1764
1824
  if (oldest !== undefined)
1765
1825
  this.#cache.delete(oldest);
1766
1826
  }
1767
- this.#cache.delete(clientId);
1768
1827
  this.#cache.set(clientId, { meta, until: now + ttlMs });
1769
1828
  return meta;
1770
1829
  }
@@ -1899,6 +1958,27 @@ class ClientMetadataFetcher {
1899
1958
  }
1900
1959
  }
1901
1960
  }
1961
+ var DOMAIN_SHAPED = /(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?\.)+\p{L}{2,63}(?![\p{L}\p{N}])/gu;
1962
+ function comparable(name) {
1963
+ return name.normalize("NFKC").replace(/\p{M}/gu, "").replace(/[\u3002\uFF0E\uFF61]/g, ".").toLowerCase();
1964
+ }
1965
+ function asciiHost(token) {
1966
+ try {
1967
+ return new URL(`https://${token}/`).hostname;
1968
+ } catch {
1969
+ return token;
1970
+ }
1971
+ }
1972
+ function foreignDomain(name, hostname) {
1973
+ const host = hostname.toLowerCase();
1974
+ for (const [match] of comparable(name).matchAll(DOMAIN_SHAPED)) {
1975
+ const token = asciiHost(match);
1976
+ const own = token === host || host.endsWith(`.${token}`) || token.endsWith(`.${host}`);
1977
+ if (!own)
1978
+ return true;
1979
+ }
1980
+ return false;
1981
+ }
1902
1982
  function validateDocument(doc, clientId, url) {
1903
1983
  const invalid = (message) => new ClientMetadataError("invalid_metadata", message);
1904
1984
  if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
@@ -1915,9 +1995,12 @@ function validateDocument(doc, clientId, url) {
1915
1995
  if (typeof name !== "string" || displayName(name) === "") {
1916
1996
  throw invalid("The AI app's details have no client_name.");
1917
1997
  }
1918
- if (/\(\s*[^()\s]*[a-z0-9-]\.[a-z]{2,}[^()]*\)/i.test(displayName(name))) {
1998
+ if (/\(\s*[^()\s]*[a-z0-9-]\.[a-z]{2,}[^()]*\)/i.test(comparable(displayName(name)))) {
1919
1999
  throw invalid("The AI app's name contains a web address in parentheses, which this relay does not show.");
1920
2000
  }
2001
+ if (foreignDomain(displayName(name), url.hostname)) {
2002
+ throw invalid("The AI app's name contains a web address other than its own, which this relay does not show.");
2003
+ }
1921
2004
  const uris = d["redirect_uris"];
1922
2005
  if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECT_URIS || !uris.every((u) => typeof u === "string" && isRedirectUri(u))) {
1923
2006
  throw invalid("The AI app's details have no valid redirect_uris.");
@@ -2132,14 +2215,17 @@ class ProviderDiscovery {
2132
2215
  class OidcPairing {
2133
2216
  kind = "oidc";
2134
2217
  #config;
2218
+ #maxFlows;
2135
2219
  #publicUrl;
2136
2220
  #logger;
2137
2221
  #discovery;
2138
2222
  #jwks;
2139
2223
  #flows = new Map;
2224
+ #flowOf = new Map;
2140
2225
  #mismatched = new Set;
2141
2226
  constructor(options) {
2142
2227
  this.#config = options.config;
2228
+ this.#maxFlows = options.maxFlows ?? MAX_FLOWS;
2143
2229
  this.#publicUrl = options.publicUrl;
2144
2230
  this.#logger = options.logger;
2145
2231
  this.#discovery = new ProviderDiscovery(options.config.issuer, options.development);
@@ -2176,7 +2262,7 @@ class OidcPairing {
2176
2262
  }
2177
2263
  return `<p>When you click Connect, sign in at <strong>${host}</strong> with the account you use in the page. No code is needed.</p>`;
2178
2264
  }
2179
- async check(target, _form, _admit, context) {
2265
+ async check(target, _form, admit, context) {
2180
2266
  if (!target.pairingOpen) {
2181
2267
  return {
2182
2268
  ok: false,
@@ -2201,16 +2287,25 @@ class OidcPairing {
2201
2287
  return this.#unavailable(error);
2202
2288
  }
2203
2289
  this.#sweep();
2204
- if (this.#flows.size >= MAX_FLOWS) {
2290
+ this.#dropFlow(context.requestId);
2291
+ if (this.#flows.size >= this.#maxFlows) {
2205
2292
  return {
2206
2293
  ok: false,
2207
2294
  message: "Too many sign-ins are open right now. Wait a few minutes and try again.",
2208
2295
  reason: "busy"
2209
2296
  };
2210
2297
  }
2298
+ if (!admit()) {
2299
+ return {
2300
+ ok: false,
2301
+ message: "Too many attempts for this page. Wait a few minutes and try again.",
2302
+ reason: "rate_limited"
2303
+ };
2304
+ }
2211
2305
  const state = random2(24);
2212
2306
  const nonce = random2(24);
2213
2307
  const verifier = random2(32);
2308
+ this.#flowOf.set(context.requestId, state);
2214
2309
  this.#flows.set(state, {
2215
2310
  requestId: context.requestId,
2216
2311
  browserId: context.browserId,
@@ -2250,6 +2345,7 @@ class OidcPairing {
2250
2345
  }
2251
2346
  this.#flows.delete(state);
2252
2347
  const requestId = flow.requestId;
2348
+ this.#flowOf.delete(requestId);
2253
2349
  const fail = (reason, message) => ({
2254
2350
  requestId,
2255
2351
  result: { ok: false, message, reason }
@@ -2376,12 +2472,25 @@ class OidcPairing {
2376
2472
  reason: "provider_unavailable"
2377
2473
  };
2378
2474
  }
2475
+ forget(requestId) {
2476
+ this.#dropFlow(requestId);
2477
+ this.#mismatched.delete(requestId);
2478
+ }
2479
+ #dropFlow(requestId) {
2480
+ const state = this.#flowOf.get(requestId);
2481
+ if (state !== undefined)
2482
+ this.#flows.delete(state);
2483
+ this.#flowOf.delete(requestId);
2484
+ }
2379
2485
  #sweep() {
2380
2486
  const now = Date.now();
2381
2487
  for (const [state, flow] of this.#flows) {
2382
2488
  if (flow.expiresAt > now)
2383
2489
  break;
2384
2490
  this.#flows.delete(state);
2491
+ if (this.#flowOf.get(flow.requestId) === state) {
2492
+ this.#flowOf.delete(flow.requestId);
2493
+ }
2385
2494
  }
2386
2495
  }
2387
2496
  }
@@ -2519,6 +2628,8 @@ var AUTH_CODE_TTL_MS = 60000;
2519
2628
  var MAX_PENDING = 1e4;
2520
2629
  var MAX_PENDING_PER_IP = 10;
2521
2630
  var MAX_PENDING_PER_ADDRESS = 20;
2631
+ var MAX_PENDING_PER_ADDRESS_IP = 5;
2632
+ var REFRESH_REUSE_WINDOW_MS = 24 * 60 * 60000;
2522
2633
  var MAX_CODES = 1e4;
2523
2634
  var MAX_FORM_BYTES = 8192;
2524
2635
  var MAX_STATE_LENGTH = 1024;
@@ -2599,6 +2710,10 @@ class AuthorizationServer {
2599
2710
  refillPerSecond: 1 / 30
2600
2711
  });
2601
2712
  #tokenPerIp = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
2713
+ #refreshesPerGrant = new RateLimiter({
2714
+ capacity: 20,
2715
+ refillPerSecond: 1 / 60
2716
+ });
2602
2717
  constructor(host, testMode) {
2603
2718
  this.#host = host;
2604
2719
  this.#authCodeTtlMs = testMode?.authCodeTtlMs ?? AUTH_CODE_TTL_MS;
@@ -2610,7 +2725,8 @@ class AuthorizationServer {
2610
2725
  config: oidc,
2611
2726
  publicUrl: () => host.publicUrl(),
2612
2727
  logger: host.logger,
2613
- development: host.config.development
2728
+ development: host.config.development,
2729
+ ...testMode?.oidcMaxFlows === undefined ? {} : { maxFlows: testMode.oidcMaxFlows }
2614
2730
  })
2615
2731
  },
2616
2732
  ...testMode?.pairingMethods
@@ -2776,7 +2892,7 @@ class AuthorizationServer {
2776
2892
  if (error instanceof ClientMetadataError) {
2777
2893
  this.#host.logger.info("oauth.client_refused", {
2778
2894
  reason: error.reason,
2779
- host: hostOf(clientId).slice(0, 100)
2895
+ host: URL.canParse(clientId) ? hostOf(clientId).slice(0, 100) : "(not a URL)"
2780
2896
  });
2781
2897
  return this.#errorPage(400, error.message);
2782
2898
  }
@@ -2822,10 +2938,11 @@ class AuthorizationServer {
2822
2938
  if (scopes === undefined) {
2823
2939
  return fail("invalid_scope", `the only scope is "${SCOPE}"`);
2824
2940
  }
2825
- if (this.#host.session(addressId) === undefined) {
2826
- return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"));
2941
+ const session = this.#host.session(addressId);
2942
+ if (session === undefined || !session.pairingOpen) {
2943
+ return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"), session !== undefined);
2827
2944
  }
2828
- const method = this.#methods[this.#host.session(addressId)?.site.pairing ?? "code"];
2945
+ const method = this.#methods[session.site.pairing];
2829
2946
  if (method?.prepare !== undefined) {
2830
2947
  try {
2831
2948
  await method.prepare();
@@ -2837,9 +2954,10 @@ class AuthorizationServer {
2837
2954
  }
2838
2955
  }
2839
2956
  this.#sweepPending();
2840
- if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP || (this.#pendingPerAddress.get(addressId) ?? 0) >= MAX_PENDING_PER_ADDRESS) {
2957
+ if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP) {
2841
2958
  return this.#errorPage(429, "Too many connection requests are open right now. Wait a few minutes and try again.");
2842
2959
  }
2960
+ this.#makeRoom(addressId, ip);
2843
2961
  const pending = {
2844
2962
  id: random3(24),
2845
2963
  expiresAt: Date.now() + PENDING_TTL_MS,
@@ -2861,6 +2979,36 @@ class AuthorizationServer {
2861
2979
  this.#pendingPerIp.set(p.ip, (this.#pendingPerIp.get(p.ip) ?? 0) + 1);
2862
2980
  this.#pendingPerAddress.set(p.addressId, (this.#pendingPerAddress.get(p.addressId) ?? 0) + 1);
2863
2981
  }
2982
+ #makeRoom(addressId, ip) {
2983
+ const count = this.#pendingPerAddress.get(addressId) ?? 0;
2984
+ if (count < MAX_PENDING_PER_ADDRESS_IP)
2985
+ return;
2986
+ const byIp = new Map;
2987
+ for (const p of this.#pending.values()) {
2988
+ if (p.addressId !== addressId)
2989
+ continue;
2990
+ const list = byIp.get(p.ip);
2991
+ if (list === undefined)
2992
+ byIp.set(p.ip, [p]);
2993
+ else
2994
+ list.push(p);
2995
+ }
2996
+ const own = byIp.get(ip) ?? [];
2997
+ if (own.length >= MAX_PENDING_PER_ADDRESS_IP) {
2998
+ if (own[0] !== undefined)
2999
+ this.#dropPending(own[0]);
3000
+ return;
3001
+ }
3002
+ if (count < MAX_PENDING_PER_ADDRESS)
3003
+ return;
3004
+ let most = [];
3005
+ for (const list of byIp.values()) {
3006
+ if (list.length >= most.length)
3007
+ most = list;
3008
+ }
3009
+ if (most[0] !== undefined)
3010
+ this.#dropPending(most[0]);
3011
+ }
2864
3012
  #dropPending(p) {
2865
3013
  if (!this.#pending.delete(p.id))
2866
3014
  return;
@@ -2873,18 +3021,22 @@ class AuthorizationServer {
2873
3021
  };
2874
3022
  dec(this.#pendingPerIp, p.ip);
2875
3023
  dec(this.#pendingPerAddress, p.addressId);
2876
- }
2877
- #offlinePage(client, retryUrl, cancelUrl, error) {
2878
- const errorHtml = error === undefined ? "" : `<p class="error" role="alert">${escapeHtml(error)}</p>`;
2879
- const body = `<h1>Open the page first</h1>
2880
- ${errorHtml}
2881
- <p><strong>${escapeHtml(client.name)}</strong> (${escapeHtml(client.host)}) wants to use the tools of a web page, but that page is not open right now.</p>
2882
- <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>
3024
+ for (const method of Object.values(this.#methods))
3025
+ method?.forget?.(p.id);
3026
+ }
3027
+ #offlinePage(client, retryUrl, cancelUrl, connected = false) {
3028
+ const who = `<strong>${escapeHtml(client.name)}</strong> (${escapeHtml(client.host)})`;
3029
+ const title = connected ? "Open \u201CConnect your AI app\u201D first" : "Open the page first";
3030
+ const intro = connected ? `<p>${who} wants to use the tools of a web page, but that page is not connecting right now.</p>
3031
+ <p>In the page, click \u201CConnect your AI app\u201D, then try again here.</p>` : `<p>${who} wants to use the tools of a web page, but that page is not open right now.</p>
3032
+ <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>`;
3033
+ const body = `<h1>${title}</h1>
3034
+ ${intro}
2883
3035
  <div class="actions">
2884
3036
  <a class="button primary" href="${escapeHtml(retryUrl)}">Try again</a>
2885
3037
  <a class="button" href="${escapeHtml(cancelUrl)}">Cancel</a>
2886
3038
  </div>`;
2887
- return this.#page(200, "Open the page first", body);
3039
+ return this.#page(200, title, body);
2888
3040
  }
2889
3041
  async authorizeSubmit(req, ip) {
2890
3042
  const origin = req.headers.get("origin");
@@ -2963,7 +3115,7 @@ ${errorHtml}
2963
3115
  client: pending.client.host,
2964
3116
  reason: result.reason
2965
3117
  });
2966
- return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" ? 429 : 400);
3118
+ return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" || result.reason === "busy" ? 429 : 400);
2967
3119
  }
2968
3120
  return this.#paired(pending, method.kind, result);
2969
3121
  }
@@ -3307,7 +3459,10 @@ ${cancel}
3307
3459
  throw new OAuthError(400, "invalid_scope", "scope exceeds the grant");
3308
3460
  }
3309
3461
  }
3310
- if (!await this.#host.store.markUsed(hash, now)) {
3462
+ if (!this.#refreshesPerGrant.take(record.grantId, now)) {
3463
+ throw new OAuthError(429, "temporarily_unavailable", "too many refreshes for this connection; try again in a minute");
3464
+ }
3465
+ if (!await this.#host.store.markUsed(hash, now, now + REFRESH_REUSE_WINDOW_MS)) {
3311
3466
  await this.#reuseDetected(record);
3312
3467
  throw invalid("the refresh token was already used; the connection was revoked");
3313
3468
  }
@@ -3416,6 +3571,7 @@ ${cancel}
3416
3571
  this.#guessesPerAddressIp.sweep(now);
3417
3572
  this.#guessesPerAddress.sweep(now);
3418
3573
  this.#tokenPerIp.sweep(now);
3574
+ this.#refreshesPerGrant.sweep(now);
3419
3575
  }
3420
3576
  }
3421
3577
 
@@ -3438,11 +3594,8 @@ function clientHost(clientId) {
3438
3594
  return "";
3439
3595
  }
3440
3596
  }
3441
- function messageId(parsed) {
3442
- if ("message" in parsed) {
3443
- return "id" in parsed.message ? parsed.message.id : undefined;
3444
- }
3445
- return parsed.id;
3597
+ function frameId(data) {
3598
+ return /"id"\s*:\s*"([^"\\]{1,64})"/.exec(data.slice(0, 256))?.[1];
3446
3599
  }
3447
3600
 
3448
3601
  class TabSession {
@@ -3455,7 +3608,9 @@ class TabSession {
3455
3608
  appName;
3456
3609
  codes;
3457
3610
  identity;
3611
+ identityUnchecked = false;
3458
3612
  pending = new Map;
3613
+ #listenGrants = new Map;
3459
3614
  #ws;
3460
3615
  #host;
3461
3616
  #state = "new";
@@ -3520,15 +3675,14 @@ class TabSession {
3520
3675
  this.#protocolError("bad_message", "binary frames are not allowed");
3521
3676
  return;
3522
3677
  }
3523
- const tooLarge = Buffer.byteLength(data) > this.limits.maxMessageBytes;
3524
- const parsed = parseTabMessage(data);
3525
- if (tooLarge) {
3526
- const id = messageId(parsed);
3678
+ if (Buffer.byteLength(data) > this.limits.maxMessageBytes) {
3679
+ const id = frameId(data);
3527
3680
  this.#sendError("too_large", "message exceeds maxMessageBytes", id);
3528
3681
  const exchange = id === undefined ? undefined : this.pending.get(id);
3529
3682
  exchange?.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
3530
3683
  return;
3531
3684
  }
3685
+ const parsed = parseTabMessage(data);
3532
3686
  if (!parsed.ok) {
3533
3687
  if (this.#state === "new") {
3534
3688
  this.#protocolError("bad_message", parsed.error);
@@ -3657,7 +3811,7 @@ class TabSession {
3657
3811
  } catch (error) {
3658
3812
  this.#host.logger.error("store.error", {
3659
3813
  op: "listGrants",
3660
- message: error instanceof Error ? error.message : "unknown"
3814
+ error: errorName(error)
3661
3815
  });
3662
3816
  }
3663
3817
  if (this.#state !== "hello")
@@ -3689,7 +3843,7 @@ class TabSession {
3689
3843
  library: `${library.name}@${library.version}`,
3690
3844
  replaced: replaced !== undefined
3691
3845
  });
3692
- this.#host.ready?.(this);
3846
+ this.#host.ready(this);
3693
3847
  }
3694
3848
  #serialIdentity(run) {
3695
3849
  const next = this.#identityChain.then(run);
@@ -3710,6 +3864,13 @@ class TabSession {
3710
3864
  this.#missedPongs++;
3711
3865
  this.send({ type: "ping", t: Date.now() });
3712
3866
  }
3867
+ listenStreams(grantId) {
3868
+ let n = 0;
3869
+ for (const g of this.#listenGrants.values())
3870
+ if (g === grantId)
3871
+ n++;
3872
+ return n;
3873
+ }
3713
3874
  forward(input) {
3714
3875
  this.#lastRequestAt = Date.now();
3715
3876
  const id = `r${++this.#nextRequest}`;
@@ -3731,16 +3892,19 @@ class TabSession {
3731
3892
  },
3732
3893
  onDone: (e) => {
3733
3894
  this.pending.delete(id);
3895
+ this.#listenGrants.delete(id);
3734
3896
  this.#lastRequestAt = Date.now();
3735
3897
  input.onDone(e);
3736
3898
  },
3737
3899
  ...input.pageGone === undefined ? {} : { pageGone: input.pageGone }
3738
3900
  });
3739
3901
  this.pending.set(id, exchange);
3902
+ if (input.isListen)
3903
+ this.#listenGrants.set(id, input.grant.grantId);
3740
3904
  const sent = this.send({
3741
3905
  type: "http.request",
3742
3906
  id,
3743
- method: input.method,
3907
+ method: "POST",
3744
3908
  path: "/mcp",
3745
3909
  headers: input.headers,
3746
3910
  body: input.body,
@@ -3842,11 +4006,12 @@ class MemoryTokenStore {
3842
4006
  const r = this.#records.get(hash);
3843
4007
  return r === undefined ? undefined : copy(r);
3844
4008
  }
3845
- async markUsed(hash, now) {
4009
+ async markUsed(hash, now, keepUntil) {
3846
4010
  const r = this.#records.get(hash);
3847
4011
  if (r === undefined || r.usedAt !== undefined)
3848
4012
  return false;
3849
4013
  r.usedAt = now;
4014
+ r.expiresAt = Math.min(r.expiresAt, keepUntil);
3850
4015
  return true;
3851
4016
  }
3852
4017
  async revoke(selector, options) {
@@ -3899,7 +4064,8 @@ class MemoryTokenStore {
3899
4064
 
3900
4065
  // src/store/sqlite.ts
3901
4066
  import { Database } from "bun:sqlite";
3902
- var SCHEMA_VERSION = 3;
4067
+ var SCHEMA_VERSION = 4;
4068
+ var REUSE_WINDOW_MS = 86400000;
3903
4069
  function toRecord(row) {
3904
4070
  const record = {
3905
4071
  hash: row.hash,
@@ -3962,6 +4128,8 @@ class SqliteTokenStore {
3962
4128
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
3963
4129
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
3964
4130
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
4131
+ this.#db.query(`UPDATE tokens SET expires_at = min(expires_at, used_at + $window)
4132
+ WHERE kind = 'refresh' AND used_at IS NOT NULL`).run({ window: REUSE_WINDOW_MS });
3965
4133
  this.#db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);
3966
4134
  })();
3967
4135
  }
@@ -4002,8 +4170,9 @@ class SqliteTokenStore {
4002
4170
  const row = this.#db.query("SELECT * FROM tokens WHERE hash = $hash").get({ hash });
4003
4171
  return row === null ? undefined : toRecord(row);
4004
4172
  }
4005
- async markUsed(hash, now) {
4006
- const result = this.#db.query("UPDATE tokens SET used_at = $now WHERE hash = $hash AND used_at IS NULL").run({ hash, now });
4173
+ async markUsed(hash, now, keepUntil) {
4174
+ const result = this.#db.query(`UPDATE tokens SET used_at = $now, expires_at = min(expires_at, $keep_until)
4175
+ WHERE hash = $hash AND used_at IS NULL`).run({ hash, now, keep_until: keepUntil });
4007
4176
  return result.changes === 1;
4008
4177
  }
4009
4178
  async revoke(selector, options) {
@@ -4042,6 +4211,16 @@ var VERSION = version;
4042
4211
  var RELAY_INFO = { name: "tab-mcp", version };
4043
4212
  var RETRY = Symbol("retry");
4044
4213
  var CLAUDE_MISMATCHED_HEADER = "2025-11-25";
4214
+ var MAX_LISTEN_STREAMS_PER_GRANT = 2;
4215
+ var FRAME_HEADROOM = 4;
4216
+ var TRANSIENT_REFUSALS = new Set([
4217
+ "jwks_unavailable",
4218
+ "store_unavailable",
4219
+ "relay_busy",
4220
+ "error"
4221
+ ]);
4222
+ var UNCHECKED_TEXT = "The relay cannot check the page's user right now. Try again shortly.";
4223
+ var RETRY_AFTER_S = "5";
4045
4224
  var FORWARDED_HEADERS = new Set([
4046
4225
  "content-type",
4047
4226
  "accept",
@@ -4103,10 +4282,10 @@ function startRelay(options, hooks) {
4103
4282
  const perIp = new Map;
4104
4283
  const waiters = new Map;
4105
4284
  let waitingCount = 0;
4106
- const appNames = new Map;
4285
+ const lastSeen = new Map;
4107
4286
  let claudeWorkarounds = 0;
4108
4287
  let publicUrl = config.publicUrl ?? "";
4109
- const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys);
4288
+ const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys, hooks?.maxSeenAssertions);
4110
4289
  const corsHeaders = config.cors ? {
4111
4290
  "access-control-allow-origin": "*",
4112
4291
  "access-control-expose-headers": "www-authenticate, mcp-protocol-version"
@@ -4168,9 +4347,15 @@ function startRelay(options, hooks) {
4168
4347
  }
4169
4348
  };
4170
4349
  const refuse = (reason, message) => {
4350
+ const transient = TRANSIENT_REFUSALS.has(reason);
4351
+ session.identityUnchecked = transient;
4171
4352
  if (session.identity !== undefined) {
4172
4353
  session.identity = undefined;
4173
- failPending(session, "The page's user is no longer signed in.");
4354
+ if (transient) {
4355
+ failPending(session, UNCHECKED_TEXT, true);
4356
+ } else {
4357
+ failPending(session, "The page's user is no longer signed in.");
4358
+ }
4174
4359
  }
4175
4360
  logger.info("identity.refused", {
4176
4361
  address: shortAddress(addressId),
@@ -4207,7 +4392,7 @@ function startRelay(options, hooks) {
4207
4392
  } catch (error) {
4208
4393
  logger.error("store.error", {
4209
4394
  op: "identity",
4210
- message: error instanceof Error ? error.message : "unknown"
4395
+ error: errorName(error)
4211
4396
  });
4212
4397
  refuse("store_unavailable", "the relay cannot check grants right now");
4213
4398
  return;
@@ -4215,6 +4400,7 @@ function startRelay(options, hooks) {
4215
4400
  if (!session.ready)
4216
4401
  return;
4217
4402
  session.identity = key;
4403
+ session.identityUnchecked = false;
4218
4404
  answer({ type: "identity.bound", ...echo });
4219
4405
  logger.info("identity.bound", {
4220
4406
  address: shortAddress(addressId),
@@ -4236,6 +4422,7 @@ function startRelay(options, hooks) {
4236
4422
  return;
4237
4423
  }
4238
4424
  session.identity = undefined;
4425
+ session.identityUnchecked = false;
4239
4426
  failPending(session, "The page's user signed out.");
4240
4427
  try {
4241
4428
  const grants = await store.listGrants(addressId, Date.now());
@@ -4247,10 +4434,7 @@ function startRelay(options, hooks) {
4247
4434
  for (const g of grants)
4248
4435
  grantRemoved(addressId, g.grantId, "revoked");
4249
4436
  } catch (error) {
4250
- logger.error("store.error", {
4251
- op: "signOut",
4252
- message: error instanceof Error ? error.message : "unknown"
4253
- });
4437
+ logger.error("store.error", { op: "signOut", error: errorName(error) });
4254
4438
  }
4255
4439
  },
4256
4440
  claimAddress(session) {
@@ -4272,14 +4456,16 @@ function startRelay(options, hooks) {
4272
4456
  const id = session.addressId;
4273
4457
  if (id === undefined)
4274
4458
  return;
4275
- if (session.appName !== undefined) {
4276
- appNames.delete(id);
4277
- appNames.set(id, session.appName);
4278
- if (appNames.size > config.maxSessions * 2) {
4279
- const oldest = appNames.keys().next().value;
4280
- if (oldest !== undefined)
4281
- appNames.delete(oldest);
4282
- }
4459
+ const appName = session.appName ?? lastSeen.get(id)?.appName;
4460
+ lastSeen.delete(id);
4461
+ lastSeen.set(id, {
4462
+ appName,
4463
+ maxConcurrentRequests: session.limits.maxConcurrentRequests
4464
+ });
4465
+ if (lastSeen.size > config.maxSessions * 2) {
4466
+ const oldest = lastSeen.keys().next().value;
4467
+ if (oldest !== undefined)
4468
+ lastSeen.delete(oldest);
4283
4469
  }
4284
4470
  const waiting = waiters.get(id);
4285
4471
  if (waiting !== undefined)
@@ -4287,9 +4473,11 @@ function startRelay(options, hooks) {
4287
4473
  wake(session);
4288
4474
  }
4289
4475
  };
4290
- function failPending(session, message) {
4476
+ function failPending(session, message, transient = false) {
4477
+ const addressId = session.addressId;
4478
+ const headers = transient ? { "retry-after": RETRY_AFTER_S } : addressId === undefined ? {} : { "www-authenticate": wwwAuthenticate(addressId, true) };
4291
4479
  for (const exchange of [...session.pending.values()]) {
4292
- exchange.fail(401, RelayErrorCode.signedOut, message, true);
4480
+ exchange.fail(transient ? 503 : 401, RelayErrorCode.signedOut, message, true, headers);
4293
4481
  }
4294
4482
  }
4295
4483
  function readySession(addressId, usable = () => true) {
@@ -4303,11 +4491,12 @@ function startRelay(options, hooks) {
4303
4491
  const now = readySession(addressId, usable);
4304
4492
  if (now !== undefined)
4305
4493
  return Promise.resolve(now);
4306
- if (waitMs === 0 || stopping !== undefined) {
4494
+ if (waitMs === 0 || closing) {
4307
4495
  return Promise.resolve(undefined);
4308
4496
  }
4309
4497
  const set = waiters.get(addressId) ?? new Set;
4310
- if (set.size >= config.maxConcurrentRequests || waitingCount >= config.maxSessions) {
4498
+ const limit = sessions.get(addressId)?.limits.maxConcurrentRequests ?? lastSeen.get(addressId)?.maxConcurrentRequests ?? config.maxConcurrentRequests;
4499
+ if (set.size >= limit || waitingCount >= config.maxSessions) {
4311
4500
  return Promise.resolve(undefined);
4312
4501
  }
4313
4502
  return new Promise((resolve) => {
@@ -4436,10 +4625,7 @@ function startRelay(options, hooks) {
4436
4625
  try {
4437
4626
  record = await store.get(hashToken(token));
4438
4627
  } catch (error) {
4439
- logger.error("store.error", {
4440
- op: "get",
4441
- message: error instanceof Error ? error.message : "unknown"
4442
- });
4628
+ logger.error("store.error", { op: "get", error: errorName(error) });
4443
4629
  return "unavailable";
4444
4630
  }
4445
4631
  if (record === undefined || record.kind !== "access" || record.addressId !== addressId || record.expiresAt <= Date.now() || !record.scopes.includes("tools")) {
@@ -4460,8 +4646,12 @@ function startRelay(options, hooks) {
4460
4646
  const deadline = new Promise((resolve) => {
4461
4647
  timer = setTimeout(() => resolve("timeout"), config.bodyTimeoutMs);
4462
4648
  });
4463
- const result = await Promise.race([reading, deadline]);
4464
- clearTimeout(timer);
4649
+ let result;
4650
+ try {
4651
+ result = await Promise.race([reading, deadline]);
4652
+ } finally {
4653
+ clearTimeout(timer);
4654
+ }
4465
4655
  if (result === "timeout") {
4466
4656
  return rpcError(408, undefined, -32600, "The request body did not arrive in time.", { ...corsHeaders, connection: "close" });
4467
4657
  }
@@ -4487,7 +4677,7 @@ function startRelay(options, hooks) {
4487
4677
  }
4488
4678
  function checkVersion(req, body, rpcId) {
4489
4679
  const header = req.headers.get("mcp-protocol-version");
4490
- if (body["method"] === "initialize") {
4680
+ if (body["method"] === "initialize" && !(header === MCP_PROTOCOL_VERSION && metaVersion(body) === MCP_PROTOCOL_VERSION)) {
4491
4681
  const params = body["params"];
4492
4682
  const requested = typeof params?.["protocolVersion"] === "string" ? params["protocolVersion"] : header ?? null;
4493
4683
  return rpcError(400, rpcId, -32022, `Unsupported protocol version: ${String(requested)}. This server supports only ${MCP_PROTOCOL_VERSION}.`, corsHeaders, { supported: [MCP_PROTOCOL_VERSION], requested });
@@ -4531,7 +4721,7 @@ function startRelay(options, hooks) {
4531
4721
  const auth = await authenticate(req, addressId);
4532
4722
  if (auth === "unavailable") {
4533
4723
  logDone(503, method, { reason: "store_unavailable" });
4534
- return rpcError(503, rpcId, RelayErrorCode.storeUnavailable, "The relay cannot check tokens right now. Try again shortly.", { ...corsHeaders, "retry-after": "5" });
4724
+ return rpcError(503, rpcId, RelayErrorCode.storeUnavailable, "The relay cannot check tokens right now. Try again shortly.", { ...corsHeaders, "retry-after": RETRY_AFTER_S });
4535
4725
  }
4536
4726
  if (typeof auth === "string") {
4537
4727
  logDone(401, method);
@@ -4563,12 +4753,9 @@ function startRelay(options, hooks) {
4563
4753
  logDone(499, method, { outcome: "client_closed" });
4564
4754
  return new Response(null, { status: 499 });
4565
4755
  };
4756
+ const idle = (on) => server.timeout(req, on ? config.httpIdleTimeoutS : 0);
4566
4757
  const usable = usableFor(auth.subject);
4567
- if (readySession(addressId, usable) === undefined && waitMs > 0) {
4568
- server.timeout(req, 0);
4569
- }
4570
- let retried = false;
4571
- for (;; ) {
4758
+ const admit = async () => {
4572
4759
  const found = await waitForPage(addressId, req.signal, waitMs, usable);
4573
4760
  if (found === "aborted" || req.signal.aborted)
4574
4761
  return closed();
@@ -4591,14 +4778,30 @@ function startRelay(options, hooks) {
4591
4778
  logDone(429, method);
4592
4779
  return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many requests at once for this page.", corsHeaders);
4593
4780
  }
4594
- server.timeout(req, 0);
4781
+ if (isListen && session.listenStreams(auth.grantId) >= MAX_LISTEN_STREAMS_PER_GRANT) {
4782
+ logDone(429, method, { reason: "listen_streams" });
4783
+ return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many listen streams at once for this AI app.", corsHeaders);
4784
+ }
4785
+ return session;
4786
+ };
4787
+ if (readySession(addressId, usable) === undefined && waitMs > 0) {
4788
+ idle(false);
4789
+ }
4790
+ let retried = false;
4791
+ for (;; ) {
4792
+ const admitted = await admit();
4793
+ if (admitted instanceof Response) {
4794
+ idle(true);
4795
+ return admitted;
4796
+ }
4797
+ const session = admitted;
4798
+ idle(false);
4595
4799
  let retry = () => {};
4596
4800
  const retrying = new Promise((resolve) => {
4597
4801
  retry = () => resolve(RETRY);
4598
4802
  });
4599
4803
  const mayRetry = !retried && waitMs > 0 && isRetryable(body.value["method"]);
4600
4804
  const exchange = session.forward({
4601
- method: "POST",
4602
4805
  headers,
4603
4806
  body: body.text,
4604
4807
  rpcId,
@@ -4612,6 +4815,7 @@ function startRelay(options, hooks) {
4612
4815
  onDone: (e) => {
4613
4816
  if (e.outcome === "page_gone_retry")
4614
4817
  return;
4818
+ idle(true);
4615
4819
  logDone(e.status, method, {
4616
4820
  session: session.sessionId,
4617
4821
  client: clientHostName,
@@ -4624,8 +4828,18 @@ function startRelay(options, hooks) {
4624
4828
  retry();
4625
4829
  return { retry: true };
4626
4830
  }
4627
- const answer = notOpenAnswer(body.value, rpcId, session.appName, "gone", RELAY_INFO);
4628
- return answer === undefined ? { retry: false } : { retry: false, body: answer };
4831
+ const answer = notOpenAnswer(body.value, rpcId, session.appName, closing ? "restarting_gone" : "gone", RELAY_INFO);
4832
+ if (answer !== undefined)
4833
+ return { retry: false, body: answer };
4834
+ return !closing ? { retry: false } : {
4835
+ retry: false,
4836
+ error: {
4837
+ status: 503,
4838
+ code: RelayErrorCode.restarting,
4839
+ message: "The relay is restarting.",
4840
+ headers: { "retry-after": RETRY_AFTER_S }
4841
+ }
4842
+ };
4629
4843
  }
4630
4844
  });
4631
4845
  const onAbort = () => exchange.clientClosed();
@@ -4639,13 +4853,21 @@ function startRelay(options, hooks) {
4639
4853
  }
4640
4854
  function pageNotOpen(value, rpcId, addressId, logDone, method) {
4641
4855
  const open = sessions.get(addressId);
4642
- const phase = open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
4643
- const answer = notOpenAnswer(value, rpcId, open?.appName ?? appNames.get(addressId), phase, RELAY_INFO);
4856
+ if (!closing && open?.ready === true && open.identity === undefined && open.identityUnchecked) {
4857
+ logDone(503, method, { reason: "identity_unchecked" });
4858
+ return rpcError(503, rpcId, RelayErrorCode.signedOut, UNCHECKED_TEXT, {
4859
+ ...corsHeaders,
4860
+ "retry-after": RETRY_AFTER_S
4861
+ });
4862
+ }
4863
+ const phase = closing ? "restarting" : open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
4864
+ const reason = phase === "restarting" ? "restarting" : "page_not_open";
4865
+ const answer = notOpenAnswer(value, rpcId, open?.appName ?? lastSeen.get(addressId)?.appName, phase, RELAY_INFO);
4644
4866
  if (answer === undefined) {
4645
- logDone(503, method, { reason: "page_not_open" });
4646
- return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
4867
+ logDone(503, method, { reason });
4868
+ return phase === "restarting" ? rpcError(503, rpcId, RelayErrorCode.restarting, "The relay is restarting.", { ...corsHeaders, "retry-after": RETRY_AFTER_S }) : rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
4647
4869
  }
4648
- logDone(200, method, { reason: "page_not_open" });
4870
+ logDone(200, method, { reason });
4649
4871
  return new Response(answer, {
4650
4872
  status: 200,
4651
4873
  headers: { ...corsHeaders, "content-type": "application/json" }
@@ -4740,7 +4962,7 @@ function startRelay(options, hooks) {
4740
4962
  fetch: () => new Response("Not found", { status: 404 }),
4741
4963
  websocket: {
4742
4964
  data: {},
4743
- maxPayloadLength: largest * 4,
4965
+ maxPayloadLength: largest * FRAME_HEADROOM,
4744
4966
  backpressureLimit: config.backpressureLimitBytes,
4745
4967
  closeOnBackpressureLimit: false,
4746
4968
  idleTimeout: 120,
@@ -4760,6 +4982,7 @@ function startRelay(options, hooks) {
4760
4982
  }
4761
4983
  });
4762
4984
  let stopping;
4985
+ let closing = false;
4763
4986
  const port = server.port ?? config.port;
4764
4987
  if (publicUrl === "")
4765
4988
  publicUrl = `http://localhost:${port}`;
@@ -4770,7 +4993,7 @@ function startRelay(options, hooks) {
4770
4993
  logger.debug("store.swept", { deleted: n });
4771
4994
  }).catch((error) => logger.error("store.error", {
4772
4995
  op: "deleteExpired",
4773
- message: error instanceof Error ? error.message : "unknown"
4996
+ error: errorName(error)
4774
4997
  }));
4775
4998
  }, config.sweepIntervalMs);
4776
4999
  const siteCount = config.allowedOrigins === "*" ? "*" : config.allowedOrigins.length + config.sites.length;
@@ -4799,6 +5022,7 @@ function startRelay(options, hooks) {
4799
5022
  return allSessions.size;
4800
5023
  },
4801
5024
  stop() {
5025
+ closing = true;
4802
5026
  stopping ??= (async () => {
4803
5027
  clearInterval(sweep);
4804
5028
  for (const set of [...waiters.values()]) {
@@ -4834,5 +5058,5 @@ export {
4834
5058
  resolveConfig
4835
5059
  };
4836
5060
 
4837
- //# debugId=D6651982449E84CF64756E2164756E21
5061
+ //# debugId=8481FBCB87B247C864756E2164756E21
4838
5062
  //# sourceMappingURL=index.js.map