@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/README.md +131 -0
- package/config.d.ts +7 -0
- package/exchange.d.ts +23 -0
- package/index.d.ts +1 -1
- package/index.js +268 -49
- package/index.js.map +11 -10
- package/main.js +437 -215
- package/main.js.map +13 -12
- package/notopen.d.ts +24 -0
- package/package.json +1 -1
- package/session.d.ts +5 -1
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1629
|
-
<p>You can go back to the AI
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
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=
|
|
3848
|
+
//# debugId=0C0FD94EFED0CA8064756E2164756E21
|
|
3627
3849
|
//# sourceMappingURL=main.js.map
|