@ractive-ch/tab-mcp 0.1.0 → 0.2.1
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 +2 -2
- package/config.d.ts +7 -0
- package/exchange.d.ts +23 -0
- package/index.js +261 -41
- package/index.js.map +8 -7
- package/main.js +430 -207
- package/main.js.map +10 -9
- 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.1
|
|
528
|
+
var version = "0.2.1";
|
|
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
|
-
|
|
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)
|
|
@@ -3418,14 +3633,15 @@ function startRelay(options, hooks) {
|
|
|
3418
3633
|
status: "ok",
|
|
3419
3634
|
version: VERSION,
|
|
3420
3635
|
counters: { claudeHeaderWorkaround: claudeWorkarounds }
|
|
3421
|
-
})
|
|
3636
|
+
}, { "cache-control": "no-store" })
|
|
3422
3637
|
},
|
|
3423
3638
|
"/": {
|
|
3424
3639
|
GET: () => new Response(INDEX_HTML, {
|
|
3425
3640
|
headers: {
|
|
3426
3641
|
"content-type": "text/html; charset=utf-8",
|
|
3427
3642
|
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
|
3428
|
-
"x-content-type-options": "nosniff"
|
|
3643
|
+
"x-content-type-options": "nosniff",
|
|
3644
|
+
"cache-control": "public, max-age=3600"
|
|
3429
3645
|
}
|
|
3430
3646
|
})
|
|
3431
3647
|
},
|
|
@@ -3489,6 +3705,7 @@ function startRelay(options, hooks) {
|
|
|
3489
3705
|
}
|
|
3490
3706
|
}
|
|
3491
3707
|
});
|
|
3708
|
+
let stopping;
|
|
3492
3709
|
const port = server.port ?? config.port;
|
|
3493
3710
|
if (publicUrl === "")
|
|
3494
3711
|
publicUrl = `http://localhost:${port}`;
|
|
@@ -3519,7 +3736,6 @@ function startRelay(options, hooks) {
|
|
|
3519
3736
|
store: ownsStore ? config.store : "custom",
|
|
3520
3737
|
origins: siteCount
|
|
3521
3738
|
});
|
|
3522
|
-
let stopping;
|
|
3523
3739
|
return {
|
|
3524
3740
|
url: publicUrl,
|
|
3525
3741
|
port,
|
|
@@ -3531,6 +3747,10 @@ function startRelay(options, hooks) {
|
|
|
3531
3747
|
stop() {
|
|
3532
3748
|
stopping ??= (async () => {
|
|
3533
3749
|
clearInterval(sweep);
|
|
3750
|
+
for (const set of [...waiters.values()]) {
|
|
3751
|
+
for (const wake of [...set])
|
|
3752
|
+
wake(undefined);
|
|
3753
|
+
}
|
|
3534
3754
|
for (const session of [...allSessions]) {
|
|
3535
3755
|
session.end(CloseCode.shuttingDown, "relay shutting down", "shutdown");
|
|
3536
3756
|
}
|
|
@@ -3577,6 +3797,9 @@ Configuration is read from environment variables:
|
|
|
3577
3797
|
TAB_MCP_SESSION_LIFETIME_S absolute tab session lifetime (43200)
|
|
3578
3798
|
TAB_MCP_HEARTBEAT_S protocol ping interval (25)
|
|
3579
3799
|
TAB_MCP_BODY_TIMEOUT_S deadline for receiving an MCP request body (10)
|
|
3800
|
+
TAB_MCP_PAGE_WAIT_S how long an MCP request waits for its page to open or
|
|
3801
|
+
reconnect before the relay answers "the page is not
|
|
3802
|
+
open" (10; 0 = answer at once; at most 60)
|
|
3580
3803
|
TAB_MCP_MAX_MESSAGE_BYTES request body and WebSocket message limit (1048576;
|
|
3581
3804
|
at least 16384)
|
|
3582
3805
|
TAB_MCP_ACCESS_TOKEN_TTL_S access token lifetime (3600)
|
|
@@ -3623,5 +3846,5 @@ var shutdown = () => {
|
|
|
3623
3846
|
process.on("SIGTERM", shutdown);
|
|
3624
3847
|
process.on("SIGINT", shutdown);
|
|
3625
3848
|
|
|
3626
|
-
//# debugId=
|
|
3849
|
+
//# debugId=8EA68051C77BF17764756E2164756E21
|
|
3627
3850
|
//# sourceMappingURL=main.js.map
|