@ractive-ch/tab-mcp 0.1.0-alpha.1 → 0.2.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
@@ -3,6 +3,7 @@
3
3
 
4
4
  // src/config.ts
5
5
  var MIN_MESSAGE_BYTES = 16384;
6
+ var MAX_PAGE_WAIT_MS = 60000;
6
7
  var MAX_APP_NAME = 64;
7
8
  var DEFAULT_CONFIG = {
8
9
  port: 8080,
@@ -24,6 +25,7 @@ var DEFAULT_CONFIG = {
24
25
  heartbeatMs: 25000,
25
26
  helloTimeoutMs: 1e4,
26
27
  bodyTimeoutMs: 1e4,
28
+ pageWaitMs: 1e4,
27
29
  maxMessageBytes: 1048576,
28
30
  accessTokenTtlMs: 3600000,
29
31
  refreshTokenTtlMs: 2592000000,
@@ -107,6 +109,9 @@ function resolveConfig(input = {}) {
107
109
  throw new ConfigError(`${key} must be a positive number`);
108
110
  }
109
111
  }
112
+ if (typeof config.pageWaitMs !== "number" || !Number.isFinite(config.pageWaitMs) || config.pageWaitMs < 0 || config.pageWaitMs > MAX_PAGE_WAIT_MS) {
113
+ throw new ConfigError(`pageWaitMs must be 0-${MAX_PAGE_WAIT_MS} (0-${MAX_PAGE_WAIT_MS / 1000} seconds)`);
114
+ }
110
115
  if (config.maxMessageBytes < MIN_MESSAGE_BYTES) {
111
116
  throw new ConfigError(`maxMessageBytes must be at least ${MIN_MESSAGE_BYTES} (16 KiB)`);
112
117
  }
@@ -230,6 +235,16 @@ function seconds(env, name) {
230
235
  }
231
236
  return Math.round(n * 1000);
232
237
  }
238
+ function secondsOrZero(env, name) {
239
+ const raw = env[name];
240
+ if (raw === undefined || raw === "")
241
+ return;
242
+ const n = Number(raw);
243
+ if (!Number.isFinite(n) || n < 0) {
244
+ throw new ConfigError(`${name} must be 0 or a positive number of seconds`);
245
+ }
246
+ return Math.round(n * 1000);
247
+ }
233
248
  function integer(env, name) {
234
249
  const raw = env[name];
235
250
  if (raw === undefined || raw === "")
@@ -306,6 +321,7 @@ function configFromEnv(env) {
306
321
  set("sessionLifetimeMs", seconds(env, "TAB_MCP_SESSION_LIFETIME_S"));
307
322
  set("heartbeatMs", seconds(env, "TAB_MCP_HEARTBEAT_S"));
308
323
  set("bodyTimeoutMs", seconds(env, "TAB_MCP_BODY_TIMEOUT_S"));
324
+ set("pageWaitMs", secondsOrZero(env, "TAB_MCP_PAGE_WAIT_S"));
309
325
  set("maxMessageBytes", integer(env, "TAB_MCP_MAX_MESSAGE_BYTES"));
310
326
  set("accessTokenTtlMs", seconds(env, "TAB_MCP_ACCESS_TOKEN_TTL_S"));
311
327
  set("refreshTokenTtlMs", seconds(env, "TAB_MCP_REFRESH_TOKEN_TTL_S"));
@@ -509,7 +525,7 @@ function installCrashLogging(logger, exit = (code) => process.exit(code)) {
509
525
  };
510
526
  }
511
527
  // package.json
512
- var version = "0.1.0-alpha.1";
528
+ var version = "0.2.0";
513
529
 
514
530
  // src/address.ts
515
531
  var BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
@@ -705,6 +721,31 @@ class Exchange {
705
721
  this.#options.cancel("client_closed");
706
722
  this.#finish("client_closed", false);
707
723
  }
724
+ tabGone() {
725
+ if (this.#state === "done")
726
+ return;
727
+ const waiting = this.#state === "waiting";
728
+ const reply = this.#options.pageGone?.(waiting);
729
+ if (reply?.retry === true && waiting) {
730
+ clearTimeout(this.#startTimer);
731
+ this.#finish("page_gone_retry");
732
+ return;
733
+ }
734
+ const body = reply?.retry === false ? reply.body : undefined;
735
+ if (body === undefined) {
736
+ this.fail(503, RelayErrorCode.tabGone, "The page closed its connection to the relay.", false);
737
+ return;
738
+ }
739
+ if (waiting) {
740
+ this.#respond(new Response(body, {
741
+ status: 200,
742
+ headers: this.#headers({ "content-type": "application/json" })
743
+ }), 200);
744
+ } else if (this.#atBoundary) {
745
+ this.#write(sseEvent(body));
746
+ }
747
+ this.#finish("page_gone");
748
+ }
708
749
  fail(status, code, message, cancelTab) {
709
750
  if (this.#state === "done")
710
751
  return;
@@ -843,6 +884,233 @@ class Exchange {
843
884
  }
844
885
  }
845
886
 
887
+ // src/protocol.ts
888
+ var SUBPROTOCOL = "tab-mcp.v1";
889
+ var MCP_PROTOCOL_VERSION = "2026-07-28";
890
+ var PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion";
891
+ var CloseCode = {
892
+ normal: 1000,
893
+ policyViolation: 1008,
894
+ tooLarge: 1009,
895
+ sessionExpired: 4001,
896
+ idleTimeout: 4002,
897
+ heartbeatLost: 4003,
898
+ shuttingDown: 4004,
899
+ replaced: 4005,
900
+ rateLimited: 4008
901
+ };
902
+ var MAX_TITLE = 300;
903
+ var MAX_NAME = 200;
904
+ var MAX_ID = 128;
905
+ var MAX_HEADER_COUNT = 32;
906
+ var MAX_HEADER_VALUE = 8192;
907
+ var FIELD_VALUE = /^[\t\x20-\x7E\x80-\xFF]*$/;
908
+ function isObject(v) {
909
+ return typeof v === "object" && v !== null && !Array.isArray(v);
910
+ }
911
+ function isString(v, max) {
912
+ return typeof v === "string" && v.length <= max;
913
+ }
914
+ function isId(v) {
915
+ return typeof v === "string" && v.length > 0 && v.length <= MAX_ID;
916
+ }
917
+ function isStatus(v) {
918
+ return typeof v === "number" && Number.isInteger(v) && v >= 200 && v <= 599;
919
+ }
920
+ function isHeaders(v) {
921
+ if (!isObject(v))
922
+ return false;
923
+ const entries = Object.entries(v);
924
+ if (entries.length > MAX_HEADER_COUNT)
925
+ return false;
926
+ return entries.every(([k, val]) => /^[a-z0-9-]{1,64}$/.test(k) && isString(val, MAX_HEADER_VALUE) && FIELD_VALUE.test(val));
927
+ }
928
+ function idOf(v) {
929
+ return isId(v["id"]) ? v["id"] : undefined;
930
+ }
931
+ function parseTabMessage(text) {
932
+ let v;
933
+ try {
934
+ v = JSON.parse(text);
935
+ } catch {
936
+ return { ok: false, error: "not JSON" };
937
+ }
938
+ if (!isObject(v))
939
+ return { ok: false, error: "not a JSON object" };
940
+ const type = v["type"];
941
+ if (typeof type !== "string" || type.length === 0 || type.length > 64) {
942
+ return { ok: false, error: "missing type" };
943
+ }
944
+ const id = idOf(v);
945
+ const bad = (error) => id === undefined ? { ok: false, error } : { ok: false, error, id };
946
+ switch (type) {
947
+ case "hello": {
948
+ const library = v["library"];
949
+ const page = v["page"];
950
+ if (!isObject(library) || !isString(library["name"], MAX_NAME) || !isString(library["version"], MAX_NAME)) {
951
+ return bad("hello.library must be {name, version}");
952
+ }
953
+ if (!isObject(page) || !isString(page["title"], MAX_TITLE)) {
954
+ return bad("hello.page must be {title}");
955
+ }
956
+ if (typeof v["secret"] !== "string")
957
+ return bad("hello.secret missing");
958
+ const app = v["app"];
959
+ if (app !== undefined && (!isObject(app) || !isAppName(app["name"]))) {
960
+ return bad(`hello.app.name must be 1-${MAX_APP_NAME} characters`);
961
+ }
962
+ const hello = {
963
+ type,
964
+ library: { name: library["name"], version: library["version"] },
965
+ page: { title: page["title"] },
966
+ secret: v["secret"]
967
+ };
968
+ if (isObject(app) && isAppName(app["name"])) {
969
+ hello.app = { name: app["name"] };
970
+ }
971
+ return { ok: true, message: hello };
972
+ }
973
+ case "pong": {
974
+ const t = v["t"];
975
+ if (typeof t !== "number")
976
+ return bad("pong.t must be a number");
977
+ return { ok: true, message: { type, t } };
978
+ }
979
+ case "bye": {
980
+ const reason = v["reason"];
981
+ return {
982
+ ok: true,
983
+ message: {
984
+ type,
985
+ reason: isString(reason, MAX_NAME) ? reason : ""
986
+ }
987
+ };
988
+ }
989
+ case "http.response": {
990
+ if (id === undefined)
991
+ return bad("http.response.id missing");
992
+ const { status, headers, body } = v;
993
+ if (!isStatus(status))
994
+ return bad("http.response.status invalid");
995
+ if (!isHeaders(headers))
996
+ return bad("http.response.headers invalid");
997
+ if (typeof body !== "string")
998
+ return bad("http.response.body invalid");
999
+ return { ok: true, message: { type, id, status, headers, body } };
1000
+ }
1001
+ case "http.response.start": {
1002
+ if (id === undefined)
1003
+ return bad("http.response.start.id missing");
1004
+ const { status, headers } = v;
1005
+ if (!isStatus(status))
1006
+ return bad("http.response.start.status invalid");
1007
+ if (!isHeaders(headers))
1008
+ return bad("http.response.start.headers invalid");
1009
+ return { ok: true, message: { type, id, status, headers } };
1010
+ }
1011
+ case "http.response.chunk": {
1012
+ if (id === undefined)
1013
+ return bad("http.response.chunk.id missing");
1014
+ const data = v["data"];
1015
+ if (typeof data !== "string")
1016
+ return bad("http.response.chunk.data invalid");
1017
+ return { ok: true, message: { type, id, data } };
1018
+ }
1019
+ case "http.response.end": {
1020
+ if (id === undefined)
1021
+ return bad("http.response.end.id missing");
1022
+ return { ok: true, message: { type, id } };
1023
+ }
1024
+ case "pairing.start":
1025
+ case "pairing.stop":
1026
+ case "grants.revokeAll":
1027
+ return { ok: true, message: { type } };
1028
+ case "grant.revoke": {
1029
+ const grantId = v["grantId"];
1030
+ if (!isId(grantId))
1031
+ return bad("grant.revoke.grantId missing");
1032
+ return { ok: true, message: { type, grantId } };
1033
+ }
1034
+ case "error": {
1035
+ const code = v["code"];
1036
+ const message = v["message"];
1037
+ if (!isString(code, MAX_NAME))
1038
+ return bad("error.code invalid");
1039
+ const m = {
1040
+ type,
1041
+ code,
1042
+ message: isString(message, 1000) ? message : ""
1043
+ };
1044
+ if (id !== undefined)
1045
+ m.id = id;
1046
+ return { ok: true, message: m };
1047
+ }
1048
+ default:
1049
+ return id === undefined ? { ok: true, unknown: true, type } : { ok: true, unknown: true, type, id };
1050
+ }
1051
+ }
1052
+
1053
+ // src/notopen.ts
1054
+ var RETRYABLE = new Set(["tools/list"]);
1055
+ function isRetryable(method) {
1056
+ return typeof method === "string" && RETRYABLE.has(method);
1057
+ }
1058
+ function pageName(appName) {
1059
+ return appName ?? "the web page";
1060
+ }
1061
+ function notOpenText(appName) {
1062
+ return `The page is not open, or not in front. Ask the user to open ${pageName(appName)} and keep it in front (on a phone, in split screen), then try again.`;
1063
+ }
1064
+ function goneDuringCallText(appName) {
1065
+ 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.`;
1066
+ }
1067
+ function notOpenAnswer(body, rpcId, appName, phase, relay) {
1068
+ if (rpcId === undefined)
1069
+ return;
1070
+ const id = rpcId;
1071
+ const method = body["method"];
1072
+ if (method === "tools/call") {
1073
+ const text = phase === "gone" ? goneDuringCallText(appName) : notOpenText(appName);
1074
+ return JSON.stringify({
1075
+ jsonrpc: "2.0",
1076
+ id,
1077
+ result: {
1078
+ resultType: "complete",
1079
+ content: [{ type: "text", text }],
1080
+ isError: true
1081
+ }
1082
+ });
1083
+ }
1084
+ if (method === "server/discover") {
1085
+ return JSON.stringify({
1086
+ jsonrpc: "2.0",
1087
+ id,
1088
+ result: {
1089
+ resultType: "complete",
1090
+ supportedVersions: [MCP_PROTOCOL_VERSION],
1091
+ capabilities: { tools: { listChanged: true } },
1092
+ instructions: notOpenText(appName),
1093
+ ttlMs: 0,
1094
+ cacheScope: "private",
1095
+ _meta: {
1096
+ "io.modelcontextprotocol/serverInfo": {
1097
+ name: relay.name,
1098
+ version: relay.version
1099
+ }
1100
+ }
1101
+ }
1102
+ });
1103
+ }
1104
+ return JSON.stringify({
1105
+ jsonrpc: "2.0",
1106
+ id,
1107
+ error: {
1108
+ code: phase === "gone" ? RelayErrorCode.tabGone : RelayErrorCode.pageNotOpen,
1109
+ message: notOpenText(appName)
1110
+ }
1111
+ });
1112
+ }
1113
+
846
1114
  // src/oauth/server.ts
847
1115
  import { timingSafeEqual as timingSafeEqual2 } from "crypto";
848
1116
 
@@ -1300,7 +1568,7 @@ function renderPage(title, body) {
1300
1568
  <body>
1301
1569
  <main>
1302
1570
  ${body}
1303
- <footer>tab-mcp relay. It passes requests between AI tools and web pages and does not store their contents.</footer>
1571
+ <footer>tab-mcp relay. It passes requests between AI apps and web pages and does not store their contents.</footer>
1304
1572
  </main>
1305
1573
  </body>
1306
1574
  </html>`;
@@ -1401,7 +1669,7 @@ var MESSAGES = {
1401
1669
  wrong: "That code is not right. Check the code shown in the page and try again.",
1402
1670
  expired: "That code has expired. The page now shows a new code: enter that one.",
1403
1671
  locked: "Too many wrong codes. The page now shows a new code: enter that one.",
1404
- inactive: "The page is not showing a code right now. In the page, open \u201CConnect your AI tool\u201D to see one, then enter it here.",
1672
+ inactive: "The page is not showing a code right now. In the page, open \u201CConnect your AI app\u201D to see one, then enter it here.",
1405
1673
  malformed: "Enter the 6 digits shown in the page.",
1406
1674
  limited: "Too many attempts for this page. Wait a few minutes and try again. If it keeps happening, someone else may be guessing: use \u201CNew address\u201D in the page."
1407
1675
  };
@@ -1410,7 +1678,7 @@ var codePairing = {
1410
1678
  render() {
1411
1679
  return `<p><label for="code">Code shown in the page</label>
1412
1680
  <input id="code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9 ]{6,7}" maxlength="7" required autofocus aria-describedby="code-help"></p>
1413
- <p id="code-help" class="help">In the page, \u201CConnect your AI tool\u201D shows a 6-digit code. It changes every 2 minutes.</p>`;
1681
+ <p id="code-help" class="help">In the page, \u201CConnect your AI app\u201D shows a 6-digit code. It changes every 2 minutes.</p>`;
1414
1682
  },
1415
1683
  check(target, form, admit) {
1416
1684
  const result = target.codes.check(form.get("code") ?? "", Date.now(), admit);
@@ -1607,7 +1875,7 @@ class AuthorizationServer {
1607
1875
  #errorPage(status, message) {
1608
1876
  return this.#page(status, "This connection cannot be made", `<h1>This connection cannot be made</h1>
1609
1877
  <p role="alert">${escapeHtml(message)}</p>
1610
- <p>Go back to your AI tool and try again. If it keeps happening, the AI tool may not work with this relay.</p>`);
1878
+ <p>Go back to your AI app and try again. If it keeps happening, the AI app may not work with this relay.</p>`);
1611
1879
  }
1612
1880
  #errorUrl(redirectUri, state, error, description) {
1613
1881
  const url = new URL(redirectUri);
@@ -1625,8 +1893,8 @@ class AuthorizationServer {
1625
1893
  const url = this.#errorUrl(redirectUri, state, error, description);
1626
1894
  const host = hostOf(redirectUri);
1627
1895
  return this.#page(400, "This connection cannot be made", `<h1>This connection cannot be made</h1>
1628
- <p role="alert">The AI tool sent a request this relay cannot accept: ${escapeHtml(description)} (${escapeHtml(error)}).</p>
1629
- <p>You can go back to the AI tool at ${escapeHtml(host)} to tell it, or close this page.</p>
1896
+ <p role="alert">The AI app sent a request this relay cannot accept: ${escapeHtml(description)} (${escapeHtml(error)}).</p>
1897
+ <p>You can go back to the AI app at ${escapeHtml(host)} to tell it, or close this page.</p>
1630
1898
  <div class="actions"><a class="button" href="${escapeHtml(url)}">Go back to ${escapeHtml(host)}</a></div>`);
1631
1899
  }
1632
1900
  #redirectError(redirectUri, state, error, description) {
@@ -1776,7 +2044,7 @@ class AuthorizationServer {
1776
2044
  const body = `<h1>Open the page first</h1>
1777
2045
  ${errorHtml}
1778
2046
  <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>
1779
- <p>Open the page, click \u201CConnect your AI tool\u201D, then try again here.</p>
2047
+ <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>
1780
2048
  <div class="actions">
1781
2049
  <a class="button primary" href="${escapeHtml(retryUrl)}">Try again</a>
1782
2050
  <a class="button" href="${escapeHtml(cancelUrl)}">Cancel</a>
@@ -1790,7 +2058,7 @@ ${errorHtml}
1790
2058
  return this.#errorPage(403, "This form can only be sent from this page.");
1791
2059
  }
1792
2060
  if (!this.#attemptsPerIp.take(ip)) {
1793
- return this.#errorPage(429, "Too many attempts from your network. Wait a few minutes, then start again from your AI tool.");
2061
+ return this.#errorPage(429, "Too many attempts from your network. Wait a few minutes, then start again from your AI app.");
1794
2062
  }
1795
2063
  const type = (req.headers.get("content-type") ?? "").split(";")[0]?.trim();
1796
2064
  if (type !== "application/x-www-form-urlencoded") {
@@ -1929,7 +2197,7 @@ ${errorHtml}
1929
2197
  const body = `<h1>Open the page first</h1>
1930
2198
  ${errorHtml}
1931
2199
  <p>${clientLabel} wants to use the tools of a web page, but that page is not open right now.</p>
1932
- <p>Open the page, click \u201CConnect your AI tool\u201D, then try again here.</p>
2200
+ <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>
1933
2201
  <form method="post" action="/authorize">
1934
2202
  ${hidden}
1935
2203
  <div class="actions">
@@ -1958,7 +2226,7 @@ ${method.render(session)}
1958
2226
  ${cancel}
1959
2227
  </div>
1960
2228
  </form>
1961
- <p class="help">After you connect, your browser goes back to ${escapeHtml(hostOf(pending.redirectUri))}. Only connect AI tools you started yourself.</p>`;
2229
+ <p class="help">After you connect, your browser goes back to ${escapeHtml(hostOf(pending.redirectUri))}. Only connect AI apps you started yourself.</p>`;
1962
2230
  return this.#page(status, heading, body, targets, extra);
1963
2231
  }
1964
2232
  async token(req, ip) {
@@ -2254,172 +2522,6 @@ ${cancel}
2254
2522
  }
2255
2523
  }
2256
2524
 
2257
- // src/protocol.ts
2258
- var SUBPROTOCOL = "tab-mcp.v1";
2259
- var MCP_PROTOCOL_VERSION = "2026-07-28";
2260
- var PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion";
2261
- var CloseCode = {
2262
- normal: 1000,
2263
- policyViolation: 1008,
2264
- tooLarge: 1009,
2265
- sessionExpired: 4001,
2266
- idleTimeout: 4002,
2267
- heartbeatLost: 4003,
2268
- shuttingDown: 4004,
2269
- replaced: 4005,
2270
- rateLimited: 4008
2271
- };
2272
- var MAX_TITLE = 300;
2273
- var MAX_NAME = 200;
2274
- var MAX_ID = 128;
2275
- var MAX_HEADER_COUNT = 32;
2276
- var MAX_HEADER_VALUE = 8192;
2277
- var FIELD_VALUE = /^[\t\x20-\x7E\x80-\xFF]*$/;
2278
- function isObject(v) {
2279
- return typeof v === "object" && v !== null && !Array.isArray(v);
2280
- }
2281
- function isString(v, max) {
2282
- return typeof v === "string" && v.length <= max;
2283
- }
2284
- function isId(v) {
2285
- return typeof v === "string" && v.length > 0 && v.length <= MAX_ID;
2286
- }
2287
- function isStatus(v) {
2288
- return typeof v === "number" && Number.isInteger(v) && v >= 200 && v <= 599;
2289
- }
2290
- function isHeaders(v) {
2291
- if (!isObject(v))
2292
- return false;
2293
- const entries = Object.entries(v);
2294
- if (entries.length > MAX_HEADER_COUNT)
2295
- return false;
2296
- return entries.every(([k, val]) => /^[a-z0-9-]{1,64}$/.test(k) && isString(val, MAX_HEADER_VALUE) && FIELD_VALUE.test(val));
2297
- }
2298
- function idOf(v) {
2299
- return isId(v["id"]) ? v["id"] : undefined;
2300
- }
2301
- function parseTabMessage(text) {
2302
- let v;
2303
- try {
2304
- v = JSON.parse(text);
2305
- } catch {
2306
- return { ok: false, error: "not JSON" };
2307
- }
2308
- if (!isObject(v))
2309
- return { ok: false, error: "not a JSON object" };
2310
- const type = v["type"];
2311
- if (typeof type !== "string" || type.length === 0 || type.length > 64) {
2312
- return { ok: false, error: "missing type" };
2313
- }
2314
- const id = idOf(v);
2315
- const bad = (error) => id === undefined ? { ok: false, error } : { ok: false, error, id };
2316
- switch (type) {
2317
- case "hello": {
2318
- const library = v["library"];
2319
- const page = v["page"];
2320
- if (!isObject(library) || !isString(library["name"], MAX_NAME) || !isString(library["version"], MAX_NAME)) {
2321
- return bad("hello.library must be {name, version}");
2322
- }
2323
- if (!isObject(page) || !isString(page["title"], MAX_TITLE)) {
2324
- return bad("hello.page must be {title}");
2325
- }
2326
- if (typeof v["secret"] !== "string")
2327
- return bad("hello.secret missing");
2328
- const app = v["app"];
2329
- if (app !== undefined && (!isObject(app) || !isAppName(app["name"]))) {
2330
- return bad(`hello.app.name must be 1-${MAX_APP_NAME} characters`);
2331
- }
2332
- const hello = {
2333
- type,
2334
- library: { name: library["name"], version: library["version"] },
2335
- page: { title: page["title"] },
2336
- secret: v["secret"]
2337
- };
2338
- if (isObject(app) && isAppName(app["name"])) {
2339
- hello.app = { name: app["name"] };
2340
- }
2341
- return { ok: true, message: hello };
2342
- }
2343
- case "pong": {
2344
- const t = v["t"];
2345
- if (typeof t !== "number")
2346
- return bad("pong.t must be a number");
2347
- return { ok: true, message: { type, t } };
2348
- }
2349
- case "bye": {
2350
- const reason = v["reason"];
2351
- return {
2352
- ok: true,
2353
- message: {
2354
- type,
2355
- reason: isString(reason, MAX_NAME) ? reason : ""
2356
- }
2357
- };
2358
- }
2359
- case "http.response": {
2360
- if (id === undefined)
2361
- return bad("http.response.id missing");
2362
- const { status, headers, body } = v;
2363
- if (!isStatus(status))
2364
- return bad("http.response.status invalid");
2365
- if (!isHeaders(headers))
2366
- return bad("http.response.headers invalid");
2367
- if (typeof body !== "string")
2368
- return bad("http.response.body invalid");
2369
- return { ok: true, message: { type, id, status, headers, body } };
2370
- }
2371
- case "http.response.start": {
2372
- if (id === undefined)
2373
- return bad("http.response.start.id missing");
2374
- const { status, headers } = v;
2375
- if (!isStatus(status))
2376
- return bad("http.response.start.status invalid");
2377
- if (!isHeaders(headers))
2378
- return bad("http.response.start.headers invalid");
2379
- return { ok: true, message: { type, id, status, headers } };
2380
- }
2381
- case "http.response.chunk": {
2382
- if (id === undefined)
2383
- return bad("http.response.chunk.id missing");
2384
- const data = v["data"];
2385
- if (typeof data !== "string")
2386
- return bad("http.response.chunk.data invalid");
2387
- return { ok: true, message: { type, id, data } };
2388
- }
2389
- case "http.response.end": {
2390
- if (id === undefined)
2391
- return bad("http.response.end.id missing");
2392
- return { ok: true, message: { type, id } };
2393
- }
2394
- case "pairing.start":
2395
- case "pairing.stop":
2396
- case "grants.revokeAll":
2397
- return { ok: true, message: { type } };
2398
- case "grant.revoke": {
2399
- const grantId = v["grantId"];
2400
- if (!isId(grantId))
2401
- return bad("grant.revoke.grantId missing");
2402
- return { ok: true, message: { type, grantId } };
2403
- }
2404
- case "error": {
2405
- const code = v["code"];
2406
- const message = v["message"];
2407
- if (!isString(code, MAX_NAME))
2408
- return bad("error.code invalid");
2409
- const m = {
2410
- type,
2411
- code,
2412
- message: isString(message, 1000) ? message : ""
2413
- };
2414
- if (id !== undefined)
2415
- m.id = id;
2416
- return { ok: true, message: m };
2417
- }
2418
- default:
2419
- return id === undefined ? { ok: true, unknown: true, type } : { ok: true, unknown: true, type, id };
2420
- }
2421
- }
2422
-
2423
2525
  // src/session.ts
2424
2526
  function limitsOf(limits) {
2425
2527
  return {
@@ -2674,6 +2776,7 @@ class TabSession {
2674
2776
  library: `${library.name}@${library.version}`,
2675
2777
  replaced: replaced !== undefined
2676
2778
  });
2779
+ this.#host.ready?.(this);
2677
2780
  }
2678
2781
  #tick() {
2679
2782
  if (this.#state !== "ready")
@@ -2712,7 +2815,8 @@ class TabSession {
2712
2815
  this.pending.delete(id);
2713
2816
  this.#lastRequestAt = Date.now();
2714
2817
  input.onDone(e);
2715
- }
2818
+ },
2819
+ ...input.pageGone === undefined ? {} : { pageGone: input.pageGone }
2716
2820
  });
2717
2821
  this.pending.set(id, exchange);
2718
2822
  const sent = this.send({
@@ -2751,10 +2855,9 @@ class TabSession {
2751
2855
  clearTimeout(this.#helloTimer);
2752
2856
  clearInterval(this.#heartbeatTimer);
2753
2857
  clearTimeout(this.#lifetimeTimer);
2754
- for (const exchange of [...this.pending.values()]) {
2755
- exchange.fail(503, RelayErrorCode.tabGone, "The page closed its connection to the relay.", false);
2756
- }
2757
2858
  this.#host.released(this);
2859
+ for (const exchange of [...this.pending.values()])
2860
+ exchange.tabGone();
2758
2861
  if (wasReady) {
2759
2862
  this.#host.logger.info("tab.closed", {
2760
2863
  session: this.sessionId,
@@ -3006,6 +3109,8 @@ class SqliteTokenStore {
3006
3109
 
3007
3110
  // src/relay.ts
3008
3111
  var VERSION = version;
3112
+ var RELAY_INFO = { name: "tab-mcp", version };
3113
+ var RETRY = Symbol("retry");
3009
3114
  var CLAUDE_MISMATCHED_HEADER = "2025-11-25";
3010
3115
  var FORWARDED_HEADERS = new Set([
3011
3116
  "content-type",
@@ -3066,6 +3171,9 @@ function startRelay(options, hooks) {
3066
3171
  const sessions = new Map;
3067
3172
  const allSessions = new Set;
3068
3173
  const perIp = new Map;
3174
+ const waiters = new Map;
3175
+ let waitingCount = 0;
3176
+ const appNames = new Map;
3069
3177
  let claudeWorkarounds = 0;
3070
3178
  let publicUrl = config.publicUrl ?? "";
3071
3179
  const corsHeaders = config.cors ? {
@@ -3129,8 +3237,68 @@ function startRelay(options, hooks) {
3129
3237
  sessions.delete(session.addressId);
3130
3238
  }
3131
3239
  releaseIp(session.ip);
3240
+ },
3241
+ ready(session) {
3242
+ const id = session.addressId;
3243
+ if (id === undefined)
3244
+ return;
3245
+ if (session.appName !== undefined) {
3246
+ appNames.delete(id);
3247
+ appNames.set(id, session.appName);
3248
+ if (appNames.size > config.maxSessions * 2) {
3249
+ const oldest = appNames.keys().next().value;
3250
+ if (oldest !== undefined)
3251
+ appNames.delete(oldest);
3252
+ }
3253
+ }
3254
+ const waiting = waiters.get(id);
3255
+ if (waiting !== undefined)
3256
+ for (const wake of [...waiting])
3257
+ wake(session);
3132
3258
  }
3133
3259
  };
3260
+ function readySession(addressId) {
3261
+ const s = sessions.get(addressId);
3262
+ return s?.ready === true ? s : undefined;
3263
+ }
3264
+ function waitForPage(addressId, signal, waitMs) {
3265
+ const now = readySession(addressId);
3266
+ if (now !== undefined)
3267
+ return Promise.resolve(now);
3268
+ if (waitMs === 0 || stopping !== undefined) {
3269
+ return Promise.resolve(undefined);
3270
+ }
3271
+ const set = waiters.get(addressId) ?? new Set;
3272
+ if (set.size >= config.maxConcurrentRequests || waitingCount >= config.maxSessions) {
3273
+ return Promise.resolve(undefined);
3274
+ }
3275
+ return new Promise((resolve) => {
3276
+ let timer;
3277
+ let settled = false;
3278
+ const done = (result) => {
3279
+ if (settled)
3280
+ return;
3281
+ settled = true;
3282
+ clearTimeout(timer);
3283
+ signal.removeEventListener("abort", onAbort);
3284
+ set.delete(wake);
3285
+ waitingCount--;
3286
+ if (set.size === 0 && waiters.get(addressId) === set) {
3287
+ waiters.delete(addressId);
3288
+ }
3289
+ resolve(result);
3290
+ };
3291
+ const wake = (session) => done(session);
3292
+ const onAbort = () => done("aborted");
3293
+ set.add(wake);
3294
+ waiters.set(addressId, set);
3295
+ waitingCount++;
3296
+ timer = setTimeout(() => done(undefined), waitMs);
3297
+ signal.addEventListener("abort", onAbort, { once: true });
3298
+ if (signal.aborted)
3299
+ onAbort();
3300
+ });
3301
+ }
3134
3302
  function releaseIp(ip) {
3135
3303
  const n = (perIp.get(ip) ?? 1) - 1;
3136
3304
  if (n <= 0)
@@ -3334,15 +3502,6 @@ function startRelay(options, hooks) {
3334
3502
  "www-authenticate": wwwAuthenticate(addressId, auth === "invalid")
3335
3503
  });
3336
3504
  }
3337
- const session = sessions.get(addressId);
3338
- if (session === undefined || !session.ready) {
3339
- logDone(503, method, { reason: "page_not_open" });
3340
- return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, 'The page is not open. Open the web page that offers these tools and click "Connect your AI tool".', corsHeaders);
3341
- }
3342
- if (session.pending.size >= session.limits.maxConcurrentRequests) {
3343
- logDone(429, method);
3344
- return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many requests at once for this page.", corsHeaders);
3345
- }
3346
3505
  const headers = {};
3347
3506
  for (const [k, v] of req.headers) {
3348
3507
  const key = k.toLowerCase();
@@ -3357,33 +3516,89 @@ function startRelay(options, hooks) {
3357
3516
  return "";
3358
3517
  }
3359
3518
  })();
3360
- if (req.signal.aborted) {
3519
+ const isListen = body.value["method"] === "subscriptions/listen";
3520
+ const waitMs = body.value["method"] === "server/discover" || Buffer.byteLength(body.text) > MIN_MESSAGE_BYTES ? 0 : config.pageWaitMs;
3521
+ const closed = () => {
3361
3522
  logDone(499, method, { outcome: "client_closed" });
3362
3523
  return new Response(null, { status: 499 });
3524
+ };
3525
+ if (readySession(addressId) === undefined && waitMs > 0) {
3526
+ server.timeout(req, 0);
3363
3527
  }
3364
- server.timeout(req, 0);
3365
- const exchange = session.forward({
3366
- method: "POST",
3367
- headers,
3368
- body: body.text,
3369
- rpcId,
3370
- isListen: body.value["method"] === "subscriptions/listen",
3371
- grant: {
3372
- grantId: auth.grantId,
3373
- clientId: auth.clientId,
3374
- scopes: auth.scopes
3375
- },
3376
- extraHeaders: corsHeaders,
3377
- onDone: (e) => logDone(e.status, method, {
3378
- session: session.sessionId,
3379
- client: clientHostName,
3380
- outcome: e.outcome
3381
- })
3382
- });
3383
- req.signal.addEventListener("abort", () => exchange.clientClosed(), {
3384
- once: true
3528
+ let retried = false;
3529
+ for (;; ) {
3530
+ const found = await waitForPage(addressId, req.signal, waitMs);
3531
+ if (found === "aborted" || req.signal.aborted)
3532
+ return closed();
3533
+ if (found === undefined) {
3534
+ return pageNotOpen(body.value, rpcId, addressId, logDone, method);
3535
+ }
3536
+ const session = found;
3537
+ if (Buffer.byteLength(body.text) > session.limits.maxMessageBytes) {
3538
+ logDone(413, method);
3539
+ return rpcError(413, rpcId, -32600, "Request body too large.", corsHeaders);
3540
+ }
3541
+ if (session.pending.size >= session.limits.maxConcurrentRequests) {
3542
+ logDone(429, method);
3543
+ return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many requests at once for this page.", corsHeaders);
3544
+ }
3545
+ server.timeout(req, 0);
3546
+ let retry = () => {};
3547
+ const retrying = new Promise((resolve) => {
3548
+ retry = () => resolve(RETRY);
3549
+ });
3550
+ const mayRetry = !retried && waitMs > 0 && isRetryable(body.value["method"]);
3551
+ const exchange = session.forward({
3552
+ method: "POST",
3553
+ headers,
3554
+ body: body.text,
3555
+ rpcId,
3556
+ isListen,
3557
+ grant: {
3558
+ grantId: auth.grantId,
3559
+ clientId: auth.clientId,
3560
+ scopes: auth.scopes
3561
+ },
3562
+ extraHeaders: corsHeaders,
3563
+ onDone: (e) => {
3564
+ if (e.outcome === "page_gone_retry")
3565
+ return;
3566
+ logDone(e.status, method, {
3567
+ session: session.sessionId,
3568
+ client: clientHostName,
3569
+ outcome: e.outcome,
3570
+ ...e.outcome === "page_gone" ? { reason: "page_not_open" } : {}
3571
+ });
3572
+ },
3573
+ pageGone: (waiting) => {
3574
+ if (waiting && mayRetry && !req.signal.aborted) {
3575
+ retry();
3576
+ return { retry: true };
3577
+ }
3578
+ const answer = notOpenAnswer(body.value, rpcId, session.appName, "gone", RELAY_INFO);
3579
+ return answer === undefined ? { retry: false } : { retry: false, body: answer };
3580
+ }
3581
+ });
3582
+ const onAbort = () => exchange.clientClosed();
3583
+ req.signal.addEventListener("abort", onAbort, { once: true });
3584
+ const result = await Promise.race([exchange.response, retrying]);
3585
+ if (result !== RETRY)
3586
+ return result;
3587
+ req.signal.removeEventListener("abort", onAbort);
3588
+ retried = true;
3589
+ }
3590
+ }
3591
+ function pageNotOpen(value, rpcId, addressId, logDone, method) {
3592
+ const answer = notOpenAnswer(value, rpcId, appNames.get(addressId), "not_open", RELAY_INFO);
3593
+ if (answer === undefined) {
3594
+ logDone(503, method, { reason: "page_not_open" });
3595
+ return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
3596
+ }
3597
+ logDone(200, method, { reason: "page_not_open" });
3598
+ return new Response(answer, {
3599
+ status: 200,
3600
+ headers: { ...corsHeaders, "content-type": "application/json" }
3385
3601
  });
3386
- return exchange.response;
3387
3602
  }
3388
3603
  function preflight(req) {
3389
3604
  if (!config.cors)
@@ -3489,6 +3704,7 @@ function startRelay(options, hooks) {
3489
3704
  }
3490
3705
  }
3491
3706
  });
3707
+ let stopping;
3492
3708
  const port = server.port ?? config.port;
3493
3709
  if (publicUrl === "")
3494
3710
  publicUrl = `http://localhost:${port}`;
@@ -3519,7 +3735,6 @@ function startRelay(options, hooks) {
3519
3735
  store: ownsStore ? config.store : "custom",
3520
3736
  origins: siteCount
3521
3737
  });
3522
- let stopping;
3523
3738
  return {
3524
3739
  url: publicUrl,
3525
3740
  port,
@@ -3531,6 +3746,10 @@ function startRelay(options, hooks) {
3531
3746
  stop() {
3532
3747
  stopping ??= (async () => {
3533
3748
  clearInterval(sweep);
3749
+ for (const set of [...waiters.values()]) {
3750
+ for (const wake of [...set])
3751
+ wake(undefined);
3752
+ }
3534
3753
  for (const session of [...allSessions]) {
3535
3754
  session.end(CloseCode.shuttingDown, "relay shutting down", "shutdown");
3536
3755
  }
@@ -3577,6 +3796,9 @@ Configuration is read from environment variables:
3577
3796
  TAB_MCP_SESSION_LIFETIME_S absolute tab session lifetime (43200)
3578
3797
  TAB_MCP_HEARTBEAT_S protocol ping interval (25)
3579
3798
  TAB_MCP_BODY_TIMEOUT_S deadline for receiving an MCP request body (10)
3799
+ TAB_MCP_PAGE_WAIT_S how long an MCP request waits for its page to open or
3800
+ reconnect before the relay answers "the page is not
3801
+ open" (10; 0 = answer at once; at most 60)
3580
3802
  TAB_MCP_MAX_MESSAGE_BYTES request body and WebSocket message limit (1048576;
3581
3803
  at least 16384)
3582
3804
  TAB_MCP_ACCESS_TOKEN_TTL_S access token lifetime (3600)
@@ -3623,5 +3845,5 @@ var shutdown = () => {
3623
3845
  process.on("SIGTERM", shutdown);
3624
3846
  process.on("SIGINT", shutdown);
3625
3847
 
3626
- //# debugId=4EB94971B128195D64756E2164756E21
3848
+ //# debugId=0C0FD94EFED0CA8064756E2164756E21
3627
3849
  //# sourceMappingURL=main.js.map