@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/main.js CHANGED
@@ -584,13 +584,15 @@ function truncateIp(ip) {
584
584
  }
585
585
  return `${v6.slice(0, 3).map((g) => g.toString(16)).join(":")}::/48`;
586
586
  }
587
+ function errorName(error) {
588
+ return error instanceof Error ? error.name : typeof error;
589
+ }
587
590
  function installCrashLogging(logger, exit = (code) => process.exit(code)) {
588
- const name = (error) => error instanceof Error ? error.name : typeof error;
589
591
  const onRejection = (error) => {
590
- logger.error("process.unhandled_rejection", { error: name(error) });
592
+ logger.error("process.unhandled_rejection", { error: errorName(error) });
591
593
  };
592
594
  const onException = (error) => {
593
- logger.error("process.uncaught_exception", { error: name(error) });
595
+ logger.error("process.uncaught_exception", { error: errorName(error) });
594
596
  exit(1);
595
597
  };
596
598
  process.on("unhandledRejection", onRejection);
@@ -601,7 +603,7 @@ function installCrashLogging(logger, exit = (code) => process.exit(code)) {
601
603
  };
602
604
  }
603
605
  // package.json
604
- var version = "0.3.0";
606
+ var version = "0.4.0";
605
607
 
606
608
  // src/address.ts
607
609
  var BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
@@ -651,7 +653,8 @@ var RelayErrorCode = {
651
653
  tooLarge: -31006,
652
654
  badResponse: -31007,
653
655
  storeUnavailable: -31008,
654
- signedOut: -31009
656
+ signedOut: -31009,
657
+ restarting: -31010
655
658
  };
656
659
  var encoder = new TextEncoder;
657
660
  var NULL_BODY_STATUSES = new Set([204, 205, 304]);
@@ -680,6 +683,11 @@ function rpcErrorBody(rpcId, code, message, data) {
680
683
  error["data"] = data;
681
684
  return JSON.stringify({ jsonrpc: "2.0", id: rpcId ?? null, error });
682
685
  }
686
+ var TAB_STARTED = new Set([
687
+ "tab_stream",
688
+ "relay_passed",
689
+ "relay_wrapped"
690
+ ]);
683
691
 
684
692
  class Exchange {
685
693
  id;
@@ -691,9 +699,6 @@ class Exchange {
691
699
  #options;
692
700
  #resolve;
693
701
  #state = "waiting";
694
- #relayStarted = false;
695
- #tabStarted = false;
696
- #wrap = false;
697
702
  #wrapped = "";
698
703
  #controller;
699
704
  #atBoundary = true;
@@ -719,7 +724,7 @@ class Exchange {
719
724
  return this.#state === "done";
720
725
  }
721
726
  onResponse(msg) {
722
- if (this.#tabStarted) {
727
+ if (TAB_STARTED.has(this.#state)) {
723
728
  this.#badResponse("http.response after http.response.start");
724
729
  return;
725
730
  }
@@ -742,7 +747,7 @@ class Exchange {
742
747
  }
743
748
  this.#respond(response, msg.status);
744
749
  this.#finish("ok");
745
- } else if (this.#state === "streaming" && this.#relayStarted) {
750
+ } else if (this.#state === "relay_stream") {
746
751
  if (!this.#account(msg.body))
747
752
  return;
748
753
  if (msg.body !== "")
@@ -751,11 +756,17 @@ class Exchange {
751
756
  }
752
757
  }
753
758
  onStart(msg) {
754
- if (this.#tabStarted) {
759
+ if (this.#state === "done")
760
+ return;
761
+ if (TAB_STARTED.has(this.#state)) {
755
762
  this.#badResponse("http.response.start sent twice");
756
763
  return;
757
764
  }
758
- this.#tabStarted = true;
765
+ const eventStream = isEventStream(msg.headers);
766
+ if (this.isListen && !eventStream) {
767
+ this.#badResponse("a listen response must be an event stream");
768
+ return;
769
+ }
759
770
  if (this.#state === "waiting") {
760
771
  if (NULL_BODY_STATUSES.has(msg.status)) {
761
772
  this.#badResponse(`status ${msg.status} cannot be streamed`);
@@ -768,25 +779,29 @@ class Exchange {
768
779
  this.#badResponse("the response headers are invalid");
769
780
  return;
770
781
  }
771
- this.#openStream(msg.status, headers);
772
- } else if (this.#state === "streaming" && this.#relayStarted) {
773
- this.#wrap = !isEventStream(msg.headers);
782
+ this.#openStream(msg.status, headers, "tab_stream");
783
+ } else {
784
+ this.#state = eventStream ? "relay_passed" : "relay_wrapped";
774
785
  }
775
786
  }
776
787
  onChunk(data) {
777
- if (this.#state !== "streaming")
788
+ if (!this.#streaming)
789
+ return;
790
+ if (this.#state === "relay_stream") {
791
+ this.#badResponse("http.response.chunk before http.response.start");
778
792
  return;
793
+ }
779
794
  if (!this.#account(data))
780
795
  return;
781
- if (this.#wrap)
796
+ if (this.#state === "relay_wrapped")
782
797
  this.#wrapped += data;
783
798
  else
784
799
  this.#write(data);
785
800
  }
786
801
  onEnd() {
787
- if (this.#state !== "streaming")
802
+ if (!this.#streaming)
788
803
  return;
789
- if (this.#wrap && this.#wrapped !== "")
804
+ if (this.#state === "relay_wrapped" && this.#wrapped !== "")
790
805
  this.#write(sseEvent(this.#wrapped));
791
806
  this.#finish("ok");
792
807
  }
@@ -808,6 +823,11 @@ class Exchange {
808
823
  this.#finish("page_gone_retry");
809
824
  return;
810
825
  }
826
+ if (reply !== undefined && "error" in reply) {
827
+ const e = reply.error;
828
+ this.fail(e.status, e.code, e.message, false, e.headers);
829
+ return;
830
+ }
811
831
  const body = reply?.retry === false ? reply.body : undefined;
812
832
  if (body === undefined) {
813
833
  this.fail(503, RelayErrorCode.tabGone, "The page closed its connection to the relay.", false);
@@ -823,11 +843,11 @@ class Exchange {
823
843
  }
824
844
  this.#finish("page_gone");
825
845
  }
826
- fail(status, code, message, cancelTab) {
846
+ fail(status, code, message, cancelTab, headers = {}) {
827
847
  if (this.#state === "done")
828
848
  return;
829
849
  if (this.#state === "waiting") {
830
- this.#respond(this.#errorResponse(status, code, message), status);
850
+ this.#respond(this.#errorResponse(status, code, message, headers), status);
831
851
  } else if (this.#atBoundary && this.#options.rpcId !== undefined) {
832
852
  this.#write(sseEvent(rpcErrorBody(this.#options.rpcId, code, message)));
833
853
  }
@@ -841,7 +861,8 @@ class Exchange {
841
861
  }
842
862
  #account(data) {
843
863
  this.#bodyBytes += Buffer.byteLength(data);
844
- if (this.isListen || this.#bodyBytes <= this.#options.maxBodyBytes) {
864
+ const unbounded = this.isListen && (this.#state === "tab_stream" || this.#state === "relay_passed");
865
+ if (unbounded || this.#bodyBytes <= this.#options.maxBodyBytes) {
845
866
  return true;
846
867
  }
847
868
  this.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
@@ -856,10 +877,9 @@ class Exchange {
856
877
  #startRelayStream() {
857
878
  if (this.#state !== "waiting")
858
879
  return;
859
- this.#relayStarted = true;
860
- this.#openStream(200, this.#headers({ "content-type": "text/event-stream" }));
880
+ this.#openStream(200, this.#headers({ "content-type": "text/event-stream" }), "relay_stream");
861
881
  }
862
- #openStream(status, headers) {
882
+ #openStream(status, headers, state) {
863
883
  const stream = new ReadableStream({
864
884
  start: (controller) => {
865
885
  this.#controller = controller;
@@ -880,12 +900,12 @@ class Exchange {
880
900
  this.#badResponse("the response could not be built");
881
901
  return;
882
902
  }
883
- this.#state = "streaming";
903
+ this.#state = state;
884
904
  this.#respond(response, status);
885
905
  this.#scheduleKeepAlive();
886
906
  }
887
907
  #write(text) {
888
- if (this.#state !== "streaming" || this.#controller === undefined || text === "")
908
+ if (!this.#streaming || this.#controller === undefined || text === "")
889
909
  return;
890
910
  try {
891
911
  this.#controller.enqueue(encoder.encode(text));
@@ -902,16 +922,19 @@ class Exchange {
902
922
  }
903
923
  this.#scheduleKeepAlive();
904
924
  }
925
+ get #streaming() {
926
+ return this.#state !== "waiting" && this.#state !== "done";
927
+ }
905
928
  #scheduleKeepAlive() {
906
929
  clearTimeout(this.#keepAliveTimer);
907
- if (this.#state !== "streaming" || this.#wrap) {
908
- if (this.#state === "streaming") {
909
- this.#keepAliveTimer = setTimeout(() => {
910
- this.#write(`: keep-alive
930
+ if (!this.#streaming)
931
+ return;
932
+ if (this.#state === "relay_wrapped") {
933
+ this.#keepAliveTimer = setTimeout(() => {
934
+ this.#write(`: keep-alive
911
935
 
912
936
  `);
913
- }, this.#options.keepAliveMs);
914
- }
937
+ }, this.#options.keepAliveMs);
915
938
  return;
916
939
  }
917
940
  this.#keepAliveTimer = setTimeout(() => {
@@ -931,8 +954,10 @@ class Exchange {
931
954
  }
932
955
  return headers;
933
956
  }
934
- #errorResponse(status, code, message) {
957
+ #errorResponse(status, code, message, extra) {
935
958
  const headers = this.#headers({ "content-type": "application/json" });
959
+ for (const [k, v] of Object.entries(extra))
960
+ headers.set(k, v);
936
961
  return new Response(rpcErrorBody(this.#options.rpcId, code, message), {
937
962
  status,
938
963
  headers
@@ -946,7 +971,7 @@ class Exchange {
946
971
  #finish(outcome, closeStream = true) {
947
972
  if (this.#state === "done")
948
973
  return;
949
- const wasStreaming = this.#state === "streaming";
974
+ const wasStreaming = this.#streaming;
950
975
  this.#state = "done";
951
976
  this.outcome = outcome;
952
977
  clearTimeout(this.#startTimer);
@@ -1267,9 +1292,11 @@ class TabAssertionVerifier {
1267
1292
  #seen = new Map;
1268
1293
  #perPerson = new Map;
1269
1294
  #sweptAt = Number.NEGATIVE_INFINITY;
1270
- constructor(oidc, keys) {
1295
+ #maxSeen;
1296
+ constructor(oidc, keys, maxSeen = MAX_SEEN_JTI) {
1271
1297
  this.#oidc = oidc;
1272
1298
  this.#keys = keys ?? new JwksCache(oidc.assertionJwksUrl);
1299
+ this.#maxSeen = maxSeen;
1273
1300
  }
1274
1301
  async verify(token, address, now = Date.now()) {
1275
1302
  const { payload } = await verifyJwt(token, this.#keys, {
@@ -1315,15 +1342,18 @@ ${jti}`;
1315
1342
  throw new JwtError("replayed", "the assertion was already used");
1316
1343
  }
1317
1344
  const mine = this.#perPerson.get(key) ?? 0;
1318
- if (mine >= MAX_JTI_PER_PERSON || this.#seen.size >= MAX_SEEN_JTI) {
1345
+ if (mine >= MAX_JTI_PER_PERSON) {
1319
1346
  throw new JwtError("busy", "too many assertions at once");
1320
1347
  }
1348
+ if (this.#seen.size >= this.#maxSeen) {
1349
+ throw new JwtError("relay_busy", "the relay is checking too many assertions; try again shortly");
1350
+ }
1321
1351
  this.#seen.set(seenKey, exp + CLOCK_LEEWAY_MS);
1322
1352
  this.#perPerson.set(key, mine + 1);
1323
1353
  return key;
1324
1354
  }
1325
1355
  #sweep(now) {
1326
- if (now - this.#sweptAt < 1000 && this.#seen.size < MAX_SEEN_JTI)
1356
+ if (now - this.#sweptAt < 1000 && this.#seen.size < this.#maxSeen)
1327
1357
  return;
1328
1358
  this.#sweptAt = now;
1329
1359
  for (const [seenKey, until] of this.#seen) {
@@ -1533,19 +1563,28 @@ function notOpenText(appName) {
1533
1563
  function signedOutText(appName) {
1534
1564
  return `The page is open, but nobody is signed in to it. Ask the user to sign in to ${pageName(appName)}, then try again.`;
1535
1565
  }
1566
+ function restartingText() {
1567
+ return "The relay is restarting. Try again in a few seconds.";
1568
+ }
1536
1569
  function phaseText(phase, appName) {
1570
+ if (phase === "restarting" || phase === "restarting_gone") {
1571
+ return restartingText();
1572
+ }
1537
1573
  return phase === "signed_out" ? signedOutText(appName) : notOpenText(appName);
1538
1574
  }
1539
1575
  function goneDuringCallText(appName) {
1540
1576
  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.`;
1541
1577
  }
1578
+ function restartedDuringCallText() {
1579
+ 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.";
1580
+ }
1542
1581
  function notOpenAnswer(body, rpcId, appName, phase, relay) {
1543
1582
  if (rpcId === undefined)
1544
1583
  return;
1545
1584
  const id = rpcId;
1546
1585
  const method = body["method"];
1547
1586
  if (method === "tools/call") {
1548
- const text = phase === "gone" ? goneDuringCallText(appName) : phaseText(phase, appName);
1587
+ const text = phase === "gone" ? goneDuringCallText(appName) : phase === "restarting_gone" ? restartedDuringCallText() : phaseText(phase, appName);
1549
1588
  return JSON.stringify({
1550
1589
  jsonrpc: "2.0",
1551
1590
  id,
@@ -1580,7 +1619,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
1580
1619
  jsonrpc: "2.0",
1581
1620
  id,
1582
1621
  error: {
1583
- code: phase === "gone" ? RelayErrorCode.tabGone : RelayErrorCode.pageNotOpen,
1622
+ code: phase === "gone" ? RelayErrorCode.tabGone : phase === "restarting" || phase === "restarting_gone" ? RelayErrorCode.restarting : RelayErrorCode.pageNotOpen,
1584
1623
  message: phaseText(phase, appName)
1585
1624
  }
1586
1625
  });
@@ -1620,6 +1659,13 @@ class RateLimiter {
1620
1659
  bucket.tokens -= 1;
1621
1660
  return true;
1622
1661
  }
1662
+ available(key, now = Date.now()) {
1663
+ const bucket = this.#buckets.get(key);
1664
+ if (bucket === undefined)
1665
+ return this.#capacity >= 1;
1666
+ const elapsed = Math.max(0, now - bucket.updatedAt) / 1000;
1667
+ return Math.min(this.#capacity, bucket.tokens + elapsed * this.#refill) >= 1;
1668
+ }
1623
1669
  sweep(now = Date.now()) {
1624
1670
  for (const [key, b] of this.#buckets) {
1625
1671
  const elapsed = Math.max(0, now - b.updatedAt) / 1000;
@@ -1705,7 +1751,7 @@ async function defaultLookup(hostname) {
1705
1751
  const records = await Bun.dns.lookup(hostname, {});
1706
1752
  return records.map((r) => r.address);
1707
1753
  }
1708
- var INVISIBLE = /[\u034F\u115F\u1160\u17B4\u17B5\u2800\u3164\uFFA0]/gu;
1754
+ var INVISIBLE = /[\u034F\u115F\u1160\u17B4\u17B5\u2800\u3164\uFFA0]|\p{Variation_Selector}/gu;
1709
1755
  function displayName(raw) {
1710
1756
  let cleaned = raw.normalize("NFC").replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, " ").replace(/\p{Cf}/gu, "").replace(INVISIBLE, "");
1711
1757
  cleaned = cleaned.replace(/\s+/g, " ").trim();
@@ -1728,10 +1774,17 @@ function isIpLiteral(hostname) {
1728
1774
  const h = hostname.replace(/^\[|\]$/g, "");
1729
1775
  return parseIpv4(h) !== undefined || parseIpv6(h) !== undefined;
1730
1776
  }
1777
+ function limitKey(hostname) {
1778
+ const h = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
1779
+ if (isIpLiteral(h))
1780
+ return h;
1781
+ return h.split(".").slice(-2).join(".");
1782
+ }
1731
1783
 
1732
1784
  class ClientMetadataFetcher {
1733
1785
  #cache = new Map;
1734
1786
  #perHost = new RateLimiter({ capacity: 5, refillPerSecond: 0.2 });
1787
+ #perDomain = new RateLimiter({ capacity: 15, refillPerSecond: 0.6 });
1735
1788
  #global = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
1736
1789
  #allowedHosts;
1737
1790
  #logger;
@@ -1755,22 +1808,28 @@ class ClientMetadataFetcher {
1755
1808
  const url = this.#checkUrl(clientId);
1756
1809
  const now = this.#now();
1757
1810
  const cached = this.#cache.get(clientId);
1758
- if (cached !== undefined && cached.until > now)
1811
+ if (cached !== undefined && cached.until > now) {
1812
+ this.#cache.delete(clientId);
1813
+ this.#cache.set(clientId, cached);
1759
1814
  return cached.meta;
1760
- if (!this.#perHost.take(url.host, now)) {
1815
+ }
1816
+ const key = limitKey(url.hostname);
1817
+ if (!this.#perHost.available(url.host, now) || !this.#perDomain.available(key, now)) {
1761
1818
  throw new ClientMetadataError("rate_limited", "Too many requests for this AI app's details. Try again in a minute.");
1762
1819
  }
1763
1820
  if (!this.#global.take("all", now)) {
1764
1821
  throw new ClientMetadataError("rate_limited", "The relay is busy. Try again in a minute.");
1765
1822
  }
1823
+ this.#perHost.take(url.host, now);
1824
+ this.#perDomain.take(key, now);
1766
1825
  const address = await this.#vettedAddress(url);
1767
1826
  const { meta, ttlMs } = await this.#fetchDocument(url, clientId, address);
1827
+ this.#cache.delete(clientId);
1768
1828
  if (this.#cache.size >= MAX_CACHE_ENTRIES) {
1769
1829
  const oldest = this.#cache.keys().next().value;
1770
1830
  if (oldest !== undefined)
1771
1831
  this.#cache.delete(oldest);
1772
1832
  }
1773
- this.#cache.delete(clientId);
1774
1833
  this.#cache.set(clientId, { meta, until: now + ttlMs });
1775
1834
  return meta;
1776
1835
  }
@@ -1905,6 +1964,27 @@ class ClientMetadataFetcher {
1905
1964
  }
1906
1965
  }
1907
1966
  }
1967
+ var DOMAIN_SHAPED = /(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?\.)+\p{L}{2,63}(?![\p{L}\p{N}])/gu;
1968
+ function comparable(name) {
1969
+ return name.normalize("NFKC").replace(/\p{M}/gu, "").replace(/[\u3002\uFF0E\uFF61]/g, ".").toLowerCase();
1970
+ }
1971
+ function asciiHost(token) {
1972
+ try {
1973
+ return new URL(`https://${token}/`).hostname;
1974
+ } catch {
1975
+ return token;
1976
+ }
1977
+ }
1978
+ function foreignDomain(name, hostname) {
1979
+ const host = hostname.toLowerCase();
1980
+ for (const [match] of comparable(name).matchAll(DOMAIN_SHAPED)) {
1981
+ const token = asciiHost(match);
1982
+ const own = token === host || host.endsWith(`.${token}`) || token.endsWith(`.${host}`);
1983
+ if (!own)
1984
+ return true;
1985
+ }
1986
+ return false;
1987
+ }
1908
1988
  function validateDocument(doc, clientId, url) {
1909
1989
  const invalid = (message) => new ClientMetadataError("invalid_metadata", message);
1910
1990
  if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
@@ -1921,9 +2001,12 @@ function validateDocument(doc, clientId, url) {
1921
2001
  if (typeof name !== "string" || displayName(name) === "") {
1922
2002
  throw invalid("The AI app's details have no client_name.");
1923
2003
  }
1924
- if (/\(\s*[^()\s]*[a-z0-9-]\.[a-z]{2,}[^()]*\)/i.test(displayName(name))) {
2004
+ if (/\(\s*[^()\s]*[a-z0-9-]\.[a-z]{2,}[^()]*\)/i.test(comparable(displayName(name)))) {
1925
2005
  throw invalid("The AI app's name contains a web address in parentheses, which this relay does not show.");
1926
2006
  }
2007
+ if (foreignDomain(displayName(name), url.hostname)) {
2008
+ throw invalid("The AI app's name contains a web address other than its own, which this relay does not show.");
2009
+ }
1927
2010
  const uris = d["redirect_uris"];
1928
2011
  if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECT_URIS || !uris.every((u) => typeof u === "string" && isRedirectUri(u))) {
1929
2012
  throw invalid("The AI app's details have no valid redirect_uris.");
@@ -2138,14 +2221,17 @@ class ProviderDiscovery {
2138
2221
  class OidcPairing {
2139
2222
  kind = "oidc";
2140
2223
  #config;
2224
+ #maxFlows;
2141
2225
  #publicUrl;
2142
2226
  #logger;
2143
2227
  #discovery;
2144
2228
  #jwks;
2145
2229
  #flows = new Map;
2230
+ #flowOf = new Map;
2146
2231
  #mismatched = new Set;
2147
2232
  constructor(options) {
2148
2233
  this.#config = options.config;
2234
+ this.#maxFlows = options.maxFlows ?? MAX_FLOWS;
2149
2235
  this.#publicUrl = options.publicUrl;
2150
2236
  this.#logger = options.logger;
2151
2237
  this.#discovery = new ProviderDiscovery(options.config.issuer, options.development);
@@ -2182,7 +2268,7 @@ class OidcPairing {
2182
2268
  }
2183
2269
  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>`;
2184
2270
  }
2185
- async check(target, _form, _admit, context) {
2271
+ async check(target, _form, admit, context) {
2186
2272
  if (!target.pairingOpen) {
2187
2273
  return {
2188
2274
  ok: false,
@@ -2207,16 +2293,25 @@ class OidcPairing {
2207
2293
  return this.#unavailable(error);
2208
2294
  }
2209
2295
  this.#sweep();
2210
- if (this.#flows.size >= MAX_FLOWS) {
2296
+ this.#dropFlow(context.requestId);
2297
+ if (this.#flows.size >= this.#maxFlows) {
2211
2298
  return {
2212
2299
  ok: false,
2213
2300
  message: "Too many sign-ins are open right now. Wait a few minutes and try again.",
2214
2301
  reason: "busy"
2215
2302
  };
2216
2303
  }
2304
+ if (!admit()) {
2305
+ return {
2306
+ ok: false,
2307
+ message: "Too many attempts for this page. Wait a few minutes and try again.",
2308
+ reason: "rate_limited"
2309
+ };
2310
+ }
2217
2311
  const state = random2(24);
2218
2312
  const nonce = random2(24);
2219
2313
  const verifier = random2(32);
2314
+ this.#flowOf.set(context.requestId, state);
2220
2315
  this.#flows.set(state, {
2221
2316
  requestId: context.requestId,
2222
2317
  browserId: context.browserId,
@@ -2256,6 +2351,7 @@ class OidcPairing {
2256
2351
  }
2257
2352
  this.#flows.delete(state);
2258
2353
  const requestId = flow.requestId;
2354
+ this.#flowOf.delete(requestId);
2259
2355
  const fail = (reason, message) => ({
2260
2356
  requestId,
2261
2357
  result: { ok: false, message, reason }
@@ -2382,12 +2478,25 @@ class OidcPairing {
2382
2478
  reason: "provider_unavailable"
2383
2479
  };
2384
2480
  }
2481
+ forget(requestId) {
2482
+ this.#dropFlow(requestId);
2483
+ this.#mismatched.delete(requestId);
2484
+ }
2485
+ #dropFlow(requestId) {
2486
+ const state = this.#flowOf.get(requestId);
2487
+ if (state !== undefined)
2488
+ this.#flows.delete(state);
2489
+ this.#flowOf.delete(requestId);
2490
+ }
2385
2491
  #sweep() {
2386
2492
  const now = Date.now();
2387
2493
  for (const [state, flow] of this.#flows) {
2388
2494
  if (flow.expiresAt > now)
2389
2495
  break;
2390
2496
  this.#flows.delete(state);
2497
+ if (this.#flowOf.get(flow.requestId) === state) {
2498
+ this.#flowOf.delete(flow.requestId);
2499
+ }
2391
2500
  }
2392
2501
  }
2393
2502
  }
@@ -2525,6 +2634,8 @@ var AUTH_CODE_TTL_MS = 60000;
2525
2634
  var MAX_PENDING = 1e4;
2526
2635
  var MAX_PENDING_PER_IP = 10;
2527
2636
  var MAX_PENDING_PER_ADDRESS = 20;
2637
+ var MAX_PENDING_PER_ADDRESS_IP = 5;
2638
+ var REFRESH_REUSE_WINDOW_MS = 24 * 60 * 60000;
2528
2639
  var MAX_CODES = 1e4;
2529
2640
  var MAX_FORM_BYTES = 8192;
2530
2641
  var MAX_STATE_LENGTH = 1024;
@@ -2605,6 +2716,10 @@ class AuthorizationServer {
2605
2716
  refillPerSecond: 1 / 30
2606
2717
  });
2607
2718
  #tokenPerIp = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
2719
+ #refreshesPerGrant = new RateLimiter({
2720
+ capacity: 20,
2721
+ refillPerSecond: 1 / 60
2722
+ });
2608
2723
  constructor(host, testMode) {
2609
2724
  this.#host = host;
2610
2725
  this.#authCodeTtlMs = testMode?.authCodeTtlMs ?? AUTH_CODE_TTL_MS;
@@ -2616,7 +2731,8 @@ class AuthorizationServer {
2616
2731
  config: oidc,
2617
2732
  publicUrl: () => host.publicUrl(),
2618
2733
  logger: host.logger,
2619
- development: host.config.development
2734
+ development: host.config.development,
2735
+ ...testMode?.oidcMaxFlows === undefined ? {} : { maxFlows: testMode.oidcMaxFlows }
2620
2736
  })
2621
2737
  },
2622
2738
  ...testMode?.pairingMethods
@@ -2782,7 +2898,7 @@ class AuthorizationServer {
2782
2898
  if (error instanceof ClientMetadataError) {
2783
2899
  this.#host.logger.info("oauth.client_refused", {
2784
2900
  reason: error.reason,
2785
- host: hostOf(clientId).slice(0, 100)
2901
+ host: URL.canParse(clientId) ? hostOf(clientId).slice(0, 100) : "(not a URL)"
2786
2902
  });
2787
2903
  return this.#errorPage(400, error.message);
2788
2904
  }
@@ -2828,10 +2944,11 @@ class AuthorizationServer {
2828
2944
  if (scopes === undefined) {
2829
2945
  return fail("invalid_scope", `the only scope is "${SCOPE}"`);
2830
2946
  }
2831
- if (this.#host.session(addressId) === undefined) {
2832
- return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"));
2947
+ const session = this.#host.session(addressId);
2948
+ if (session === undefined || !session.pairingOpen) {
2949
+ return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"), session !== undefined);
2833
2950
  }
2834
- const method = this.#methods[this.#host.session(addressId)?.site.pairing ?? "code"];
2951
+ const method = this.#methods[session.site.pairing];
2835
2952
  if (method?.prepare !== undefined) {
2836
2953
  try {
2837
2954
  await method.prepare();
@@ -2843,9 +2960,10 @@ class AuthorizationServer {
2843
2960
  }
2844
2961
  }
2845
2962
  this.#sweepPending();
2846
- if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP || (this.#pendingPerAddress.get(addressId) ?? 0) >= MAX_PENDING_PER_ADDRESS) {
2963
+ if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP) {
2847
2964
  return this.#errorPage(429, "Too many connection requests are open right now. Wait a few minutes and try again.");
2848
2965
  }
2966
+ this.#makeRoom(addressId, ip);
2849
2967
  const pending = {
2850
2968
  id: random3(24),
2851
2969
  expiresAt: Date.now() + PENDING_TTL_MS,
@@ -2867,6 +2985,36 @@ class AuthorizationServer {
2867
2985
  this.#pendingPerIp.set(p.ip, (this.#pendingPerIp.get(p.ip) ?? 0) + 1);
2868
2986
  this.#pendingPerAddress.set(p.addressId, (this.#pendingPerAddress.get(p.addressId) ?? 0) + 1);
2869
2987
  }
2988
+ #makeRoom(addressId, ip) {
2989
+ const count = this.#pendingPerAddress.get(addressId) ?? 0;
2990
+ if (count < MAX_PENDING_PER_ADDRESS_IP)
2991
+ return;
2992
+ const byIp = new Map;
2993
+ for (const p of this.#pending.values()) {
2994
+ if (p.addressId !== addressId)
2995
+ continue;
2996
+ const list = byIp.get(p.ip);
2997
+ if (list === undefined)
2998
+ byIp.set(p.ip, [p]);
2999
+ else
3000
+ list.push(p);
3001
+ }
3002
+ const own = byIp.get(ip) ?? [];
3003
+ if (own.length >= MAX_PENDING_PER_ADDRESS_IP) {
3004
+ if (own[0] !== undefined)
3005
+ this.#dropPending(own[0]);
3006
+ return;
3007
+ }
3008
+ if (count < MAX_PENDING_PER_ADDRESS)
3009
+ return;
3010
+ let most = [];
3011
+ for (const list of byIp.values()) {
3012
+ if (list.length >= most.length)
3013
+ most = list;
3014
+ }
3015
+ if (most[0] !== undefined)
3016
+ this.#dropPending(most[0]);
3017
+ }
2870
3018
  #dropPending(p) {
2871
3019
  if (!this.#pending.delete(p.id))
2872
3020
  return;
@@ -2879,18 +3027,22 @@ class AuthorizationServer {
2879
3027
  };
2880
3028
  dec(this.#pendingPerIp, p.ip);
2881
3029
  dec(this.#pendingPerAddress, p.addressId);
2882
- }
2883
- #offlinePage(client, retryUrl, cancelUrl, error) {
2884
- const errorHtml = error === undefined ? "" : `<p class="error" role="alert">${escapeHtml(error)}</p>`;
2885
- const body = `<h1>Open the page first</h1>
2886
- ${errorHtml}
2887
- <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>
2888
- <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>
3030
+ for (const method of Object.values(this.#methods))
3031
+ method?.forget?.(p.id);
3032
+ }
3033
+ #offlinePage(client, retryUrl, cancelUrl, connected = false) {
3034
+ const who = `<strong>${escapeHtml(client.name)}</strong> (${escapeHtml(client.host)})`;
3035
+ const title = connected ? "Open \u201CConnect your AI app\u201D first" : "Open the page first";
3036
+ const intro = connected ? `<p>${who} wants to use the tools of a web page, but that page is not connecting right now.</p>
3037
+ <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>
3038
+ <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>`;
3039
+ const body = `<h1>${title}</h1>
3040
+ ${intro}
2889
3041
  <div class="actions">
2890
3042
  <a class="button primary" href="${escapeHtml(retryUrl)}">Try again</a>
2891
3043
  <a class="button" href="${escapeHtml(cancelUrl)}">Cancel</a>
2892
3044
  </div>`;
2893
- return this.#page(200, "Open the page first", body);
3045
+ return this.#page(200, title, body);
2894
3046
  }
2895
3047
  async authorizeSubmit(req, ip) {
2896
3048
  const origin = req.headers.get("origin");
@@ -2969,7 +3121,7 @@ ${errorHtml}
2969
3121
  client: pending.client.host,
2970
3122
  reason: result.reason
2971
3123
  });
2972
- return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" ? 429 : 400);
3124
+ return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" || result.reason === "busy" ? 429 : 400);
2973
3125
  }
2974
3126
  return this.#paired(pending, method.kind, result);
2975
3127
  }
@@ -3313,7 +3465,10 @@ ${cancel}
3313
3465
  throw new OAuthError(400, "invalid_scope", "scope exceeds the grant");
3314
3466
  }
3315
3467
  }
3316
- if (!await this.#host.store.markUsed(hash, now)) {
3468
+ if (!this.#refreshesPerGrant.take(record.grantId, now)) {
3469
+ throw new OAuthError(429, "temporarily_unavailable", "too many refreshes for this connection; try again in a minute");
3470
+ }
3471
+ if (!await this.#host.store.markUsed(hash, now, now + REFRESH_REUSE_WINDOW_MS)) {
3317
3472
  await this.#reuseDetected(record);
3318
3473
  throw invalid("the refresh token was already used; the connection was revoked");
3319
3474
  }
@@ -3422,6 +3577,7 @@ ${cancel}
3422
3577
  this.#guessesPerAddressIp.sweep(now);
3423
3578
  this.#guessesPerAddress.sweep(now);
3424
3579
  this.#tokenPerIp.sweep(now);
3580
+ this.#refreshesPerGrant.sweep(now);
3425
3581
  }
3426
3582
  }
3427
3583
 
@@ -3444,11 +3600,8 @@ function clientHost(clientId) {
3444
3600
  return "";
3445
3601
  }
3446
3602
  }
3447
- function messageId(parsed) {
3448
- if ("message" in parsed) {
3449
- return "id" in parsed.message ? parsed.message.id : undefined;
3450
- }
3451
- return parsed.id;
3603
+ function frameId(data) {
3604
+ return /"id"\s*:\s*"([^"\\]{1,64})"/.exec(data.slice(0, 256))?.[1];
3452
3605
  }
3453
3606
 
3454
3607
  class TabSession {
@@ -3461,7 +3614,9 @@ class TabSession {
3461
3614
  appName;
3462
3615
  codes;
3463
3616
  identity;
3617
+ identityUnchecked = false;
3464
3618
  pending = new Map;
3619
+ #listenGrants = new Map;
3465
3620
  #ws;
3466
3621
  #host;
3467
3622
  #state = "new";
@@ -3526,15 +3681,14 @@ class TabSession {
3526
3681
  this.#protocolError("bad_message", "binary frames are not allowed");
3527
3682
  return;
3528
3683
  }
3529
- const tooLarge = Buffer.byteLength(data) > this.limits.maxMessageBytes;
3530
- const parsed = parseTabMessage(data);
3531
- if (tooLarge) {
3532
- const id = messageId(parsed);
3684
+ if (Buffer.byteLength(data) > this.limits.maxMessageBytes) {
3685
+ const id = frameId(data);
3533
3686
  this.#sendError("too_large", "message exceeds maxMessageBytes", id);
3534
3687
  const exchange = id === undefined ? undefined : this.pending.get(id);
3535
3688
  exchange?.fail(502, RelayErrorCode.tooLarge, "The page sent a response that is too large.", true);
3536
3689
  return;
3537
3690
  }
3691
+ const parsed = parseTabMessage(data);
3538
3692
  if (!parsed.ok) {
3539
3693
  if (this.#state === "new") {
3540
3694
  this.#protocolError("bad_message", parsed.error);
@@ -3663,7 +3817,7 @@ class TabSession {
3663
3817
  } catch (error) {
3664
3818
  this.#host.logger.error("store.error", {
3665
3819
  op: "listGrants",
3666
- message: error instanceof Error ? error.message : "unknown"
3820
+ error: errorName(error)
3667
3821
  });
3668
3822
  }
3669
3823
  if (this.#state !== "hello")
@@ -3695,7 +3849,7 @@ class TabSession {
3695
3849
  library: `${library.name}@${library.version}`,
3696
3850
  replaced: replaced !== undefined
3697
3851
  });
3698
- this.#host.ready?.(this);
3852
+ this.#host.ready(this);
3699
3853
  }
3700
3854
  #serialIdentity(run) {
3701
3855
  const next = this.#identityChain.then(run);
@@ -3716,6 +3870,13 @@ class TabSession {
3716
3870
  this.#missedPongs++;
3717
3871
  this.send({ type: "ping", t: Date.now() });
3718
3872
  }
3873
+ listenStreams(grantId) {
3874
+ let n = 0;
3875
+ for (const g of this.#listenGrants.values())
3876
+ if (g === grantId)
3877
+ n++;
3878
+ return n;
3879
+ }
3719
3880
  forward(input) {
3720
3881
  this.#lastRequestAt = Date.now();
3721
3882
  const id = `r${++this.#nextRequest}`;
@@ -3737,16 +3898,19 @@ class TabSession {
3737
3898
  },
3738
3899
  onDone: (e) => {
3739
3900
  this.pending.delete(id);
3901
+ this.#listenGrants.delete(id);
3740
3902
  this.#lastRequestAt = Date.now();
3741
3903
  input.onDone(e);
3742
3904
  },
3743
3905
  ...input.pageGone === undefined ? {} : { pageGone: input.pageGone }
3744
3906
  });
3745
3907
  this.pending.set(id, exchange);
3908
+ if (input.isListen)
3909
+ this.#listenGrants.set(id, input.grant.grantId);
3746
3910
  const sent = this.send({
3747
3911
  type: "http.request",
3748
3912
  id,
3749
- method: input.method,
3913
+ method: "POST",
3750
3914
  path: "/mcp",
3751
3915
  headers: input.headers,
3752
3916
  body: input.body,
@@ -3848,11 +4012,12 @@ class MemoryTokenStore {
3848
4012
  const r = this.#records.get(hash);
3849
4013
  return r === undefined ? undefined : copy(r);
3850
4014
  }
3851
- async markUsed(hash, now) {
4015
+ async markUsed(hash, now, keepUntil) {
3852
4016
  const r = this.#records.get(hash);
3853
4017
  if (r === undefined || r.usedAt !== undefined)
3854
4018
  return false;
3855
4019
  r.usedAt = now;
4020
+ r.expiresAt = Math.min(r.expiresAt, keepUntil);
3856
4021
  return true;
3857
4022
  }
3858
4023
  async revoke(selector, options) {
@@ -3905,7 +4070,8 @@ class MemoryTokenStore {
3905
4070
 
3906
4071
  // src/store/sqlite.ts
3907
4072
  import { Database } from "bun:sqlite";
3908
- var SCHEMA_VERSION = 3;
4073
+ var SCHEMA_VERSION = 4;
4074
+ var REUSE_WINDOW_MS = 86400000;
3909
4075
  function toRecord(row) {
3910
4076
  const record = {
3911
4077
  hash: row.hash,
@@ -3968,6 +4134,8 @@ class SqliteTokenStore {
3968
4134
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
3969
4135
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
3970
4136
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
4137
+ this.#db.query(`UPDATE tokens SET expires_at = min(expires_at, used_at + $window)
4138
+ WHERE kind = 'refresh' AND used_at IS NOT NULL`).run({ window: REUSE_WINDOW_MS });
3971
4139
  this.#db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);
3972
4140
  })();
3973
4141
  }
@@ -4008,8 +4176,9 @@ class SqliteTokenStore {
4008
4176
  const row = this.#db.query("SELECT * FROM tokens WHERE hash = $hash").get({ hash });
4009
4177
  return row === null ? undefined : toRecord(row);
4010
4178
  }
4011
- async markUsed(hash, now) {
4012
- const result = this.#db.query("UPDATE tokens SET used_at = $now WHERE hash = $hash AND used_at IS NULL").run({ hash, now });
4179
+ async markUsed(hash, now, keepUntil) {
4180
+ const result = this.#db.query(`UPDATE tokens SET used_at = $now, expires_at = min(expires_at, $keep_until)
4181
+ WHERE hash = $hash AND used_at IS NULL`).run({ hash, now, keep_until: keepUntil });
4013
4182
  return result.changes === 1;
4014
4183
  }
4015
4184
  async revoke(selector, options) {
@@ -4048,6 +4217,16 @@ var VERSION = version;
4048
4217
  var RELAY_INFO = { name: "tab-mcp", version };
4049
4218
  var RETRY = Symbol("retry");
4050
4219
  var CLAUDE_MISMATCHED_HEADER = "2025-11-25";
4220
+ var MAX_LISTEN_STREAMS_PER_GRANT = 2;
4221
+ var FRAME_HEADROOM = 4;
4222
+ var TRANSIENT_REFUSALS = new Set([
4223
+ "jwks_unavailable",
4224
+ "store_unavailable",
4225
+ "relay_busy",
4226
+ "error"
4227
+ ]);
4228
+ var UNCHECKED_TEXT = "The relay cannot check the page's user right now. Try again shortly.";
4229
+ var RETRY_AFTER_S = "5";
4051
4230
  var FORWARDED_HEADERS = new Set([
4052
4231
  "content-type",
4053
4232
  "accept",
@@ -4109,10 +4288,10 @@ function startRelay(options, hooks) {
4109
4288
  const perIp = new Map;
4110
4289
  const waiters = new Map;
4111
4290
  let waitingCount = 0;
4112
- const appNames = new Map;
4291
+ const lastSeen = new Map;
4113
4292
  let claudeWorkarounds = 0;
4114
4293
  let publicUrl = config.publicUrl ?? "";
4115
- const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys);
4294
+ const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys, hooks?.maxSeenAssertions);
4116
4295
  const corsHeaders = config.cors ? {
4117
4296
  "access-control-allow-origin": "*",
4118
4297
  "access-control-expose-headers": "www-authenticate, mcp-protocol-version"
@@ -4174,9 +4353,15 @@ function startRelay(options, hooks) {
4174
4353
  }
4175
4354
  };
4176
4355
  const refuse = (reason, message) => {
4356
+ const transient = TRANSIENT_REFUSALS.has(reason);
4357
+ session.identityUnchecked = transient;
4177
4358
  if (session.identity !== undefined) {
4178
4359
  session.identity = undefined;
4179
- failPending(session, "The page's user is no longer signed in.");
4360
+ if (transient) {
4361
+ failPending(session, UNCHECKED_TEXT, true);
4362
+ } else {
4363
+ failPending(session, "The page's user is no longer signed in.");
4364
+ }
4180
4365
  }
4181
4366
  logger.info("identity.refused", {
4182
4367
  address: shortAddress(addressId),
@@ -4213,7 +4398,7 @@ function startRelay(options, hooks) {
4213
4398
  } catch (error) {
4214
4399
  logger.error("store.error", {
4215
4400
  op: "identity",
4216
- message: error instanceof Error ? error.message : "unknown"
4401
+ error: errorName(error)
4217
4402
  });
4218
4403
  refuse("store_unavailable", "the relay cannot check grants right now");
4219
4404
  return;
@@ -4221,6 +4406,7 @@ function startRelay(options, hooks) {
4221
4406
  if (!session.ready)
4222
4407
  return;
4223
4408
  session.identity = key;
4409
+ session.identityUnchecked = false;
4224
4410
  answer({ type: "identity.bound", ...echo });
4225
4411
  logger.info("identity.bound", {
4226
4412
  address: shortAddress(addressId),
@@ -4242,6 +4428,7 @@ function startRelay(options, hooks) {
4242
4428
  return;
4243
4429
  }
4244
4430
  session.identity = undefined;
4431
+ session.identityUnchecked = false;
4245
4432
  failPending(session, "The page's user signed out.");
4246
4433
  try {
4247
4434
  const grants = await store.listGrants(addressId, Date.now());
@@ -4253,10 +4440,7 @@ function startRelay(options, hooks) {
4253
4440
  for (const g of grants)
4254
4441
  grantRemoved(addressId, g.grantId, "revoked");
4255
4442
  } catch (error) {
4256
- logger.error("store.error", {
4257
- op: "signOut",
4258
- message: error instanceof Error ? error.message : "unknown"
4259
- });
4443
+ logger.error("store.error", { op: "signOut", error: errorName(error) });
4260
4444
  }
4261
4445
  },
4262
4446
  claimAddress(session) {
@@ -4278,14 +4462,16 @@ function startRelay(options, hooks) {
4278
4462
  const id = session.addressId;
4279
4463
  if (id === undefined)
4280
4464
  return;
4281
- if (session.appName !== undefined) {
4282
- appNames.delete(id);
4283
- appNames.set(id, session.appName);
4284
- if (appNames.size > config.maxSessions * 2) {
4285
- const oldest = appNames.keys().next().value;
4286
- if (oldest !== undefined)
4287
- appNames.delete(oldest);
4288
- }
4465
+ const appName = session.appName ?? lastSeen.get(id)?.appName;
4466
+ lastSeen.delete(id);
4467
+ lastSeen.set(id, {
4468
+ appName,
4469
+ maxConcurrentRequests: session.limits.maxConcurrentRequests
4470
+ });
4471
+ if (lastSeen.size > config.maxSessions * 2) {
4472
+ const oldest = lastSeen.keys().next().value;
4473
+ if (oldest !== undefined)
4474
+ lastSeen.delete(oldest);
4289
4475
  }
4290
4476
  const waiting = waiters.get(id);
4291
4477
  if (waiting !== undefined)
@@ -4293,9 +4479,11 @@ function startRelay(options, hooks) {
4293
4479
  wake(session);
4294
4480
  }
4295
4481
  };
4296
- function failPending(session, message) {
4482
+ function failPending(session, message, transient = false) {
4483
+ const addressId = session.addressId;
4484
+ const headers = transient ? { "retry-after": RETRY_AFTER_S } : addressId === undefined ? {} : { "www-authenticate": wwwAuthenticate(addressId, true) };
4297
4485
  for (const exchange of [...session.pending.values()]) {
4298
- exchange.fail(401, RelayErrorCode.signedOut, message, true);
4486
+ exchange.fail(transient ? 503 : 401, RelayErrorCode.signedOut, message, true, headers);
4299
4487
  }
4300
4488
  }
4301
4489
  function readySession(addressId, usable = () => true) {
@@ -4309,11 +4497,12 @@ function startRelay(options, hooks) {
4309
4497
  const now = readySession(addressId, usable);
4310
4498
  if (now !== undefined)
4311
4499
  return Promise.resolve(now);
4312
- if (waitMs === 0 || stopping !== undefined) {
4500
+ if (waitMs === 0 || closing) {
4313
4501
  return Promise.resolve(undefined);
4314
4502
  }
4315
4503
  const set = waiters.get(addressId) ?? new Set;
4316
- if (set.size >= config.maxConcurrentRequests || waitingCount >= config.maxSessions) {
4504
+ const limit = sessions.get(addressId)?.limits.maxConcurrentRequests ?? lastSeen.get(addressId)?.maxConcurrentRequests ?? config.maxConcurrentRequests;
4505
+ if (set.size >= limit || waitingCount >= config.maxSessions) {
4317
4506
  return Promise.resolve(undefined);
4318
4507
  }
4319
4508
  return new Promise((resolve) => {
@@ -4442,10 +4631,7 @@ function startRelay(options, hooks) {
4442
4631
  try {
4443
4632
  record = await store.get(hashToken(token));
4444
4633
  } catch (error) {
4445
- logger.error("store.error", {
4446
- op: "get",
4447
- message: error instanceof Error ? error.message : "unknown"
4448
- });
4634
+ logger.error("store.error", { op: "get", error: errorName(error) });
4449
4635
  return "unavailable";
4450
4636
  }
4451
4637
  if (record === undefined || record.kind !== "access" || record.addressId !== addressId || record.expiresAt <= Date.now() || !record.scopes.includes("tools")) {
@@ -4466,8 +4652,12 @@ function startRelay(options, hooks) {
4466
4652
  const deadline = new Promise((resolve) => {
4467
4653
  timer = setTimeout(() => resolve("timeout"), config.bodyTimeoutMs);
4468
4654
  });
4469
- const result = await Promise.race([reading, deadline]);
4470
- clearTimeout(timer);
4655
+ let result;
4656
+ try {
4657
+ result = await Promise.race([reading, deadline]);
4658
+ } finally {
4659
+ clearTimeout(timer);
4660
+ }
4471
4661
  if (result === "timeout") {
4472
4662
  return rpcError(408, undefined, -32600, "The request body did not arrive in time.", { ...corsHeaders, connection: "close" });
4473
4663
  }
@@ -4493,7 +4683,7 @@ function startRelay(options, hooks) {
4493
4683
  }
4494
4684
  function checkVersion(req, body, rpcId) {
4495
4685
  const header = req.headers.get("mcp-protocol-version");
4496
- if (body["method"] === "initialize") {
4686
+ if (body["method"] === "initialize" && !(header === MCP_PROTOCOL_VERSION && metaVersion(body) === MCP_PROTOCOL_VERSION)) {
4497
4687
  const params = body["params"];
4498
4688
  const requested = typeof params?.["protocolVersion"] === "string" ? params["protocolVersion"] : header ?? null;
4499
4689
  return rpcError(400, rpcId, -32022, `Unsupported protocol version: ${String(requested)}. This server supports only ${MCP_PROTOCOL_VERSION}.`, corsHeaders, { supported: [MCP_PROTOCOL_VERSION], requested });
@@ -4537,7 +4727,7 @@ function startRelay(options, hooks) {
4537
4727
  const auth = await authenticate(req, addressId);
4538
4728
  if (auth === "unavailable") {
4539
4729
  logDone(503, method, { reason: "store_unavailable" });
4540
- return rpcError(503, rpcId, RelayErrorCode.storeUnavailable, "The relay cannot check tokens right now. Try again shortly.", { ...corsHeaders, "retry-after": "5" });
4730
+ return rpcError(503, rpcId, RelayErrorCode.storeUnavailable, "The relay cannot check tokens right now. Try again shortly.", { ...corsHeaders, "retry-after": RETRY_AFTER_S });
4541
4731
  }
4542
4732
  if (typeof auth === "string") {
4543
4733
  logDone(401, method);
@@ -4569,12 +4759,9 @@ function startRelay(options, hooks) {
4569
4759
  logDone(499, method, { outcome: "client_closed" });
4570
4760
  return new Response(null, { status: 499 });
4571
4761
  };
4762
+ const idle = (on) => server.timeout(req, on ? config.httpIdleTimeoutS : 0);
4572
4763
  const usable = usableFor(auth.subject);
4573
- if (readySession(addressId, usable) === undefined && waitMs > 0) {
4574
- server.timeout(req, 0);
4575
- }
4576
- let retried = false;
4577
- for (;; ) {
4764
+ const admit = async () => {
4578
4765
  const found = await waitForPage(addressId, req.signal, waitMs, usable);
4579
4766
  if (found === "aborted" || req.signal.aborted)
4580
4767
  return closed();
@@ -4597,14 +4784,30 @@ function startRelay(options, hooks) {
4597
4784
  logDone(429, method);
4598
4785
  return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many requests at once for this page.", corsHeaders);
4599
4786
  }
4600
- server.timeout(req, 0);
4787
+ if (isListen && session.listenStreams(auth.grantId) >= MAX_LISTEN_STREAMS_PER_GRANT) {
4788
+ logDone(429, method, { reason: "listen_streams" });
4789
+ return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many listen streams at once for this AI app.", corsHeaders);
4790
+ }
4791
+ return session;
4792
+ };
4793
+ if (readySession(addressId, usable) === undefined && waitMs > 0) {
4794
+ idle(false);
4795
+ }
4796
+ let retried = false;
4797
+ for (;; ) {
4798
+ const admitted = await admit();
4799
+ if (admitted instanceof Response) {
4800
+ idle(true);
4801
+ return admitted;
4802
+ }
4803
+ const session = admitted;
4804
+ idle(false);
4601
4805
  let retry = () => {};
4602
4806
  const retrying = new Promise((resolve) => {
4603
4807
  retry = () => resolve(RETRY);
4604
4808
  });
4605
4809
  const mayRetry = !retried && waitMs > 0 && isRetryable(body.value["method"]);
4606
4810
  const exchange = session.forward({
4607
- method: "POST",
4608
4811
  headers,
4609
4812
  body: body.text,
4610
4813
  rpcId,
@@ -4618,6 +4821,7 @@ function startRelay(options, hooks) {
4618
4821
  onDone: (e) => {
4619
4822
  if (e.outcome === "page_gone_retry")
4620
4823
  return;
4824
+ idle(true);
4621
4825
  logDone(e.status, method, {
4622
4826
  session: session.sessionId,
4623
4827
  client: clientHostName,
@@ -4630,8 +4834,18 @@ function startRelay(options, hooks) {
4630
4834
  retry();
4631
4835
  return { retry: true };
4632
4836
  }
4633
- const answer = notOpenAnswer(body.value, rpcId, session.appName, "gone", RELAY_INFO);
4634
- return answer === undefined ? { retry: false } : { retry: false, body: answer };
4837
+ const answer = notOpenAnswer(body.value, rpcId, session.appName, closing ? "restarting_gone" : "gone", RELAY_INFO);
4838
+ if (answer !== undefined)
4839
+ return { retry: false, body: answer };
4840
+ return !closing ? { retry: false } : {
4841
+ retry: false,
4842
+ error: {
4843
+ status: 503,
4844
+ code: RelayErrorCode.restarting,
4845
+ message: "The relay is restarting.",
4846
+ headers: { "retry-after": RETRY_AFTER_S }
4847
+ }
4848
+ };
4635
4849
  }
4636
4850
  });
4637
4851
  const onAbort = () => exchange.clientClosed();
@@ -4645,13 +4859,21 @@ function startRelay(options, hooks) {
4645
4859
  }
4646
4860
  function pageNotOpen(value, rpcId, addressId, logDone, method) {
4647
4861
  const open = sessions.get(addressId);
4648
- const phase = open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
4649
- const answer = notOpenAnswer(value, rpcId, open?.appName ?? appNames.get(addressId), phase, RELAY_INFO);
4862
+ if (!closing && open?.ready === true && open.identity === undefined && open.identityUnchecked) {
4863
+ logDone(503, method, { reason: "identity_unchecked" });
4864
+ return rpcError(503, rpcId, RelayErrorCode.signedOut, UNCHECKED_TEXT, {
4865
+ ...corsHeaders,
4866
+ "retry-after": RETRY_AFTER_S
4867
+ });
4868
+ }
4869
+ const phase = closing ? "restarting" : open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
4870
+ const reason = phase === "restarting" ? "restarting" : "page_not_open";
4871
+ const answer = notOpenAnswer(value, rpcId, open?.appName ?? lastSeen.get(addressId)?.appName, phase, RELAY_INFO);
4650
4872
  if (answer === undefined) {
4651
- logDone(503, method, { reason: "page_not_open" });
4652
- return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
4873
+ logDone(503, method, { reason });
4874
+ 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);
4653
4875
  }
4654
- logDone(200, method, { reason: "page_not_open" });
4876
+ logDone(200, method, { reason });
4655
4877
  return new Response(answer, {
4656
4878
  status: 200,
4657
4879
  headers: { ...corsHeaders, "content-type": "application/json" }
@@ -4746,7 +4968,7 @@ function startRelay(options, hooks) {
4746
4968
  fetch: () => new Response("Not found", { status: 404 }),
4747
4969
  websocket: {
4748
4970
  data: {},
4749
- maxPayloadLength: largest * 4,
4971
+ maxPayloadLength: largest * FRAME_HEADROOM,
4750
4972
  backpressureLimit: config.backpressureLimitBytes,
4751
4973
  closeOnBackpressureLimit: false,
4752
4974
  idleTimeout: 120,
@@ -4766,6 +4988,7 @@ function startRelay(options, hooks) {
4766
4988
  }
4767
4989
  });
4768
4990
  let stopping;
4991
+ let closing = false;
4769
4992
  const port = server.port ?? config.port;
4770
4993
  if (publicUrl === "")
4771
4994
  publicUrl = `http://localhost:${port}`;
@@ -4776,7 +4999,7 @@ function startRelay(options, hooks) {
4776
4999
  logger.debug("store.swept", { deleted: n });
4777
5000
  }).catch((error) => logger.error("store.error", {
4778
5001
  op: "deleteExpired",
4779
- message: error instanceof Error ? error.message : "unknown"
5002
+ error: errorName(error)
4780
5003
  }));
4781
5004
  }, config.sweepIntervalMs);
4782
5005
  const siteCount = config.allowedOrigins === "*" ? "*" : config.allowedOrigins.length + config.sites.length;
@@ -4805,6 +5028,7 @@ function startRelay(options, hooks) {
4805
5028
  return allSessions.size;
4806
5029
  },
4807
5030
  stop() {
5031
+ closing = true;
4808
5032
  stopping ??= (async () => {
4809
5033
  clearInterval(sweep);
4810
5034
  for (const set of [...waiters.values()]) {
@@ -4916,5 +5140,5 @@ var shutdown = () => {
4916
5140
  process.on("SIGTERM", shutdown);
4917
5141
  process.on("SIGINT", shutdown);
4918
5142
 
4919
- //# debugId=16AE1B87E7D1BB3C64756E2164756E21
5143
+ //# debugId=07B48995F9433DFC64756E2164756E21
4920
5144
  //# sourceMappingURL=main.js.map