@ractive-ch/tab-mcp 0.1.0 → 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";
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
 
@@ -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 app".', 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=9CA2F401657AE02764756E2164756E21
3848
+ //# debugId=0C0FD94EFED0CA8064756E2164756E21
3627
3849
  //# sourceMappingURL=main.js.map