@ractive-ch/tab-mcp 0.4.0 → 0.5.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 +2 -2
- package/index.js +141 -19
- package/index.js.map +8 -8
- package/main.js +141 -19
- package/main.js.map +8 -8
- package/oauth/pairing.d.ts +2 -0
- package/package.json +1 -1
- package/protocol.d.ts +9 -1
- package/relay.d.ts +2 -0
- package/session.d.ts +6 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ and gets an MCP address. The user adds that address to an AI app and pairs it by
|
|
|
10
10
|
a code from the page on the relay's sign-in page. The relay then forwards the AI app's
|
|
11
11
|
requests to the page. It knows nothing about the tools themselves.
|
|
12
12
|
|
|
13
|
-
**Status: 0.
|
|
13
|
+
**Status: 0.5.0.** Until 1.0, a new minor version can still change settings and the API.
|
|
14
14
|
The relay speaks MCP revision 2026-07-28 only and accepts AI apps that register through
|
|
15
15
|
OAuth Client ID Metadata Documents; see [compatibility](https://github.com/ractive/tab-mcp/blob/main/docs/compatibility.md)
|
|
16
16
|
for which apps work.
|
|
@@ -32,7 +32,7 @@ Or with Docker (linux/amd64 and linux/arm64):
|
|
|
32
32
|
docker run -d -p 127.0.0.1:8080:8080 -v tab-mcp-data:/data \
|
|
33
33
|
-e TAB_MCP_PUBLIC_URL=https://relay.example.com \
|
|
34
34
|
-e TAB_MCP_ALLOWED_ORIGINS=https://app.example.com \
|
|
35
|
-
ractive/tab-mcp:0.
|
|
35
|
+
ractive/tab-mcp:0.5.0
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
The image is also on `ghcr.io/ractive/tab-mcp`. Put the relay behind a reverse proxy
|
package/index.js
CHANGED
|
@@ -1033,7 +1033,8 @@ var CloseCode = {
|
|
|
1033
1033
|
heartbeatLost: 4003,
|
|
1034
1034
|
shuttingDown: 4004,
|
|
1035
1035
|
replaced: 4005,
|
|
1036
|
-
rateLimited: 4008
|
|
1036
|
+
rateLimited: 4008,
|
|
1037
|
+
addressMismatch: 4009
|
|
1037
1038
|
};
|
|
1038
1039
|
var MAX_TITLE = 300;
|
|
1039
1040
|
var MAX_NAME = 200;
|
|
@@ -1095,6 +1096,10 @@ function parseTabMessage(text) {
|
|
|
1095
1096
|
if (app !== undefined && (!isObject(app) || !isAppName(app["name"]))) {
|
|
1096
1097
|
return bad(`hello.app.name must be 1-${MAX_APP_NAME} characters`);
|
|
1097
1098
|
}
|
|
1099
|
+
const revokeAll = v["revokeAll"];
|
|
1100
|
+
if (revokeAll !== undefined && typeof revokeAll !== "boolean") {
|
|
1101
|
+
return bad("hello.revokeAll must be a boolean");
|
|
1102
|
+
}
|
|
1098
1103
|
const hello = {
|
|
1099
1104
|
type,
|
|
1100
1105
|
library: { name: library["name"], version: library["version"] },
|
|
@@ -1104,6 +1109,8 @@ function parseTabMessage(text) {
|
|
|
1104
1109
|
if (isObject(app) && isAppName(app["name"])) {
|
|
1105
1110
|
hello.app = { name: app["name"] };
|
|
1106
1111
|
}
|
|
1112
|
+
if (revokeAll === true)
|
|
1113
|
+
hello.revokeAll = true;
|
|
1107
1114
|
return { ok: true, message: hello };
|
|
1108
1115
|
}
|
|
1109
1116
|
case "pong": {
|
|
@@ -1198,7 +1205,7 @@ function parseTabMessage(text) {
|
|
|
1198
1205
|
}
|
|
1199
1206
|
}
|
|
1200
1207
|
// package.json
|
|
1201
|
-
var version = "0.
|
|
1208
|
+
var version = "0.5.0";
|
|
1202
1209
|
|
|
1203
1210
|
// src/exchange.ts
|
|
1204
1211
|
var RelayErrorCode = {
|
|
@@ -2302,7 +2309,7 @@ class OidcPairing {
|
|
|
2302
2309
|
reason: "rate_limited"
|
|
2303
2310
|
};
|
|
2304
2311
|
}
|
|
2305
|
-
const state = random2(24)
|
|
2312
|
+
const state = `${context.addressId}.${random2(24)}`;
|
|
2306
2313
|
const nonce = random2(24);
|
|
2307
2314
|
const verifier = random2(32);
|
|
2308
2315
|
this.#flowOf.set(context.requestId, state);
|
|
@@ -3092,7 +3099,8 @@ ${intro}
|
|
|
3092
3099
|
const admit = () => this.#guessesPerAddressIp.take(`${pending.addressId}|${ip}`) && this.#guessesPerAddress.take(pending.addressId);
|
|
3093
3100
|
const result = await method.check(session, form, admit, {
|
|
3094
3101
|
requestId: pending.id,
|
|
3095
|
-
browserId: browser.id
|
|
3102
|
+
browserId: browser.id,
|
|
3103
|
+
addressId: pending.addressId
|
|
3096
3104
|
});
|
|
3097
3105
|
if (result.ok === "redirect") {
|
|
3098
3106
|
this.#host.logger.info("oauth.pairing_redirect", {
|
|
@@ -3121,7 +3129,7 @@ ${intro}
|
|
|
3121
3129
|
}
|
|
3122
3130
|
#paired(pending, kind, result) {
|
|
3123
3131
|
this.#dropPending(pending);
|
|
3124
|
-
const code = random3(32)
|
|
3132
|
+
const code = `${pending.addressId}.${random3(32)}`;
|
|
3125
3133
|
if (this.#codes.size >= MAX_CODES)
|
|
3126
3134
|
this.#sweepCodes(true);
|
|
3127
3135
|
const { id: _id, expiresAt: _expires, ip: _ip, ...request } = pending;
|
|
@@ -3241,7 +3249,7 @@ ${intro}
|
|
|
3241
3249
|
${errorHtml}
|
|
3242
3250
|
<p>${clientLabel} wants to use the tools of a web page, but that page is not open right now.</p>
|
|
3243
3251
|
<p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>
|
|
3244
|
-
<form method="post" action="/authorize">
|
|
3252
|
+
<form method="post" action="/authorize?a=${pending.addressId}">
|
|
3245
3253
|
${hidden}
|
|
3246
3254
|
<div class="actions">
|
|
3247
3255
|
<button type="submit" class="primary" name="action" value="retry">Try again</button>
|
|
@@ -3262,7 +3270,7 @@ ${cancel}
|
|
|
3262
3270
|
<p>You're connecting ${clientLabel} to ${target}.</p>
|
|
3263
3271
|
<p>${escapeHtml(client.name)} can then use this page's tools while the page is open. You can disconnect it in the page at any time.</p>
|
|
3264
3272
|
${errorHtml}
|
|
3265
|
-
<form method="post" action="/authorize">
|
|
3273
|
+
<form method="post" action="/authorize?a=${pending.addressId}">
|
|
3266
3274
|
${hidden}
|
|
3267
3275
|
${method.render(session)}
|
|
3268
3276
|
<div class="actions">
|
|
@@ -3604,6 +3612,7 @@ class TabSession {
|
|
|
3604
3612
|
origin;
|
|
3605
3613
|
site;
|
|
3606
3614
|
openedAt = Date.now();
|
|
3615
|
+
expectedAddress;
|
|
3607
3616
|
addressId;
|
|
3608
3617
|
appName;
|
|
3609
3618
|
codes;
|
|
@@ -3622,12 +3631,13 @@ class TabSession {
|
|
|
3622
3631
|
#helloTimer;
|
|
3623
3632
|
#heartbeatTimer;
|
|
3624
3633
|
#lifetimeTimer;
|
|
3625
|
-
constructor(ws, ip, origin, site, host) {
|
|
3634
|
+
constructor(ws, ip, origin, site, host, expectedAddress) {
|
|
3626
3635
|
this.#ws = ws;
|
|
3627
3636
|
this.ip = ip;
|
|
3628
3637
|
this.origin = origin;
|
|
3629
3638
|
this.site = site;
|
|
3630
3639
|
this.#host = host;
|
|
3640
|
+
this.expectedAddress = expectedAddress;
|
|
3631
3641
|
this.codes = new PairingCodes((code, expiresAt) => {
|
|
3632
3642
|
this.send({
|
|
3633
3643
|
type: "pairing.code",
|
|
@@ -3716,7 +3726,7 @@ class TabSession {
|
|
|
3716
3726
|
this.#protocolError("bad_message", "hello expected");
|
|
3717
3727
|
return;
|
|
3718
3728
|
}
|
|
3719
|
-
await this.#hello(msg.secret, msg.library, msg.app?.name);
|
|
3729
|
+
await this.#hello(msg.secret, msg.library, msg.app?.name, msg.revokeAll === true);
|
|
3720
3730
|
return;
|
|
3721
3731
|
}
|
|
3722
3732
|
if (this.#state === "hello")
|
|
@@ -3783,19 +3793,38 @@ class TabSession {
|
|
|
3783
3793
|
}
|
|
3784
3794
|
}
|
|
3785
3795
|
}
|
|
3786
|
-
async#hello(secret, library, appName) {
|
|
3796
|
+
async#hello(secret, library, appName, revokeAll = false) {
|
|
3787
3797
|
if (!isWellFormedSecret(secret)) {
|
|
3788
3798
|
this.#protocolError("bad_message", "secret must be 43 base64url characters");
|
|
3789
3799
|
return;
|
|
3790
3800
|
}
|
|
3801
|
+
const addressId = addressIdFromSecret(secret);
|
|
3802
|
+
if (this.expectedAddress !== undefined && this.expectedAddress !== addressId) {
|
|
3803
|
+
this.#host.logger.warn("tab.refused", { reason: "address_mismatch" });
|
|
3804
|
+
this.#sendError("address_mismatch", "the address id in the URL is not the one of the secret");
|
|
3805
|
+
this.end(CloseCode.addressMismatch, "address mismatch");
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3791
3808
|
clearTimeout(this.#helloTimer);
|
|
3792
3809
|
this.#state = "hello";
|
|
3793
|
-
this.addressId =
|
|
3810
|
+
this.addressId = addressId;
|
|
3794
3811
|
this.appName = this.site.name ?? appName;
|
|
3795
3812
|
const replaced = this.#host.claimAddress(this);
|
|
3796
3813
|
if (replaced !== undefined) {
|
|
3797
3814
|
replaced.end(CloseCode.replaced, "replaced by a newer session", "replaced");
|
|
3798
3815
|
}
|
|
3816
|
+
if (revokeAll) {
|
|
3817
|
+
try {
|
|
3818
|
+
await this.#host.revokeAll(this);
|
|
3819
|
+
} catch (error) {
|
|
3820
|
+
this.#host.logger.error("store.error", {
|
|
3821
|
+
op: "revokeAll",
|
|
3822
|
+
error: errorName(error)
|
|
3823
|
+
});
|
|
3824
|
+
this.end(CloseCode.policyViolation, "could not revoke the grants");
|
|
3825
|
+
return;
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3799
3828
|
let grants = [];
|
|
3800
3829
|
try {
|
|
3801
3830
|
const list = await this.#host.store.listGrants(this.addressId, Date.now());
|
|
@@ -4229,6 +4258,33 @@ var FORWARDED_HEADERS = new Set([
|
|
|
4229
4258
|
"traceparent",
|
|
4230
4259
|
"tracestate"
|
|
4231
4260
|
]);
|
|
4261
|
+
var HEALTH_STORE_TIMEOUT_MS = 1000;
|
|
4262
|
+
var HEALTH_REUSE_MS = 1000;
|
|
4263
|
+
var HEALTH_HUNG_RETRY_MS = 30000;
|
|
4264
|
+
var HEALTH_PROBE_HASH = hashToken("tab-mcp /healthz probe");
|
|
4265
|
+
function rawAddressParam(search) {
|
|
4266
|
+
let found;
|
|
4267
|
+
for (const pair of search.replace(/^\?/, "").split("&")) {
|
|
4268
|
+
if (pair === "")
|
|
4269
|
+
continue;
|
|
4270
|
+
const eq = pair.indexOf("=");
|
|
4271
|
+
const rawName = eq < 0 ? pair : pair.slice(0, eq);
|
|
4272
|
+
let name;
|
|
4273
|
+
try {
|
|
4274
|
+
name = decodeURIComponent(rawName.replaceAll("+", " "));
|
|
4275
|
+
} catch {
|
|
4276
|
+
continue;
|
|
4277
|
+
}
|
|
4278
|
+
if (name !== "a")
|
|
4279
|
+
continue;
|
|
4280
|
+
const value = eq < 0 ? "" : pair.slice(eq + 1);
|
|
4281
|
+
if (rawName !== "a" || found !== undefined || !isAddressId(value)) {
|
|
4282
|
+
return false;
|
|
4283
|
+
}
|
|
4284
|
+
found = value;
|
|
4285
|
+
}
|
|
4286
|
+
return found;
|
|
4287
|
+
}
|
|
4232
4288
|
function json(status, value, headers) {
|
|
4233
4289
|
const h = new Headers(headers);
|
|
4234
4290
|
h.set("content-type", "application/json");
|
|
@@ -4584,6 +4640,11 @@ function startRelay(options, hooks) {
|
|
|
4584
4640
|
supported: [SUBPROTOCOL]
|
|
4585
4641
|
});
|
|
4586
4642
|
}
|
|
4643
|
+
const address = rawAddressParam(new URL(req.url).search);
|
|
4644
|
+
if (address === false) {
|
|
4645
|
+
logger.info("tab.refused", { reason: "bad_address" });
|
|
4646
|
+
return json(400, { error: "bad_address" });
|
|
4647
|
+
}
|
|
4587
4648
|
const realIp = clientIp(req, server);
|
|
4588
4649
|
const ip = ipPrefixKey(realIp);
|
|
4589
4650
|
if (allSessions.size >= config.maxSessions) {
|
|
@@ -4600,7 +4661,12 @@ function startRelay(options, hooks) {
|
|
|
4600
4661
|
}
|
|
4601
4662
|
perIp.set(ip, count + 1);
|
|
4602
4663
|
const ok = server.upgrade(req, {
|
|
4603
|
-
data: {
|
|
4664
|
+
data: {
|
|
4665
|
+
ip,
|
|
4666
|
+
origin: origin ?? "",
|
|
4667
|
+
site,
|
|
4668
|
+
...address === undefined ? {} : { address }
|
|
4669
|
+
},
|
|
4604
4670
|
headers: { "Sec-WebSocket-Protocol": SUBPROTOCOL }
|
|
4605
4671
|
});
|
|
4606
4672
|
if (ok)
|
|
@@ -4892,6 +4958,56 @@ function startRelay(options, hooks) {
|
|
|
4892
4958
|
headers: { ...corsHeaders, allow: "POST" }
|
|
4893
4959
|
});
|
|
4894
4960
|
const postOnly = () => new Response(null, { status: 405, headers: { allow: "POST, OPTIONS" } });
|
|
4961
|
+
const healthRetryMs = hooks?.healthRetryMs ?? HEALTH_HUNG_RETRY_MS;
|
|
4962
|
+
let probe;
|
|
4963
|
+
let probed;
|
|
4964
|
+
let hungReads = 0;
|
|
4965
|
+
let lastHungAt = 0;
|
|
4966
|
+
async function runProbe() {
|
|
4967
|
+
let timer;
|
|
4968
|
+
const deadline = new Promise((resolve) => {
|
|
4969
|
+
timer = setTimeout(() => resolve("store_timeout"), HEALTH_STORE_TIMEOUT_MS);
|
|
4970
|
+
});
|
|
4971
|
+
const read = Promise.resolve().then(() => store.get(HEALTH_PROBE_HASH)).then(() => "ok");
|
|
4972
|
+
read.catch(() => {});
|
|
4973
|
+
try {
|
|
4974
|
+
const result = await Promise.race([read, deadline]);
|
|
4975
|
+
if (result === "store_timeout") {
|
|
4976
|
+
logger.warn("health.store", { error: "timeout" });
|
|
4977
|
+
hungReads++;
|
|
4978
|
+
lastHungAt = Date.now();
|
|
4979
|
+
const settled = () => {
|
|
4980
|
+
hungReads--;
|
|
4981
|
+
};
|
|
4982
|
+
read.then(settled, settled);
|
|
4983
|
+
}
|
|
4984
|
+
return result;
|
|
4985
|
+
} catch (error) {
|
|
4986
|
+
logger.warn("health.store", { error: errorName(error) });
|
|
4987
|
+
return "store_unavailable";
|
|
4988
|
+
} finally {
|
|
4989
|
+
clearTimeout(timer);
|
|
4990
|
+
}
|
|
4991
|
+
}
|
|
4992
|
+
function storeHealth() {
|
|
4993
|
+
if (probe !== undefined)
|
|
4994
|
+
return probe;
|
|
4995
|
+
const now = Date.now();
|
|
4996
|
+
if (probed !== undefined && now - probed.at < HEALTH_REUSE_MS) {
|
|
4997
|
+
return Promise.resolve(probed.result);
|
|
4998
|
+
}
|
|
4999
|
+
if (hungReads > 0 && now - lastHungAt < healthRetryMs) {
|
|
5000
|
+
return Promise.resolve("store_timeout");
|
|
5001
|
+
}
|
|
5002
|
+
const current = runProbe().catch(() => "store_unavailable");
|
|
5003
|
+
probe = current;
|
|
5004
|
+
current.then((result) => {
|
|
5005
|
+
probed = { at: Date.now(), result };
|
|
5006
|
+
if (probe === current)
|
|
5007
|
+
probe = undefined;
|
|
5008
|
+
});
|
|
5009
|
+
return current;
|
|
5010
|
+
}
|
|
4895
5011
|
const largest = largestMessageBytes(config);
|
|
4896
5012
|
const ipOf = (req, srv) => ipPrefixKey(clientIp(req, srv));
|
|
4897
5013
|
const server = Bun.serve({
|
|
@@ -4902,11 +5018,17 @@ function startRelay(options, hooks) {
|
|
|
4902
5018
|
idleTimeout: config.httpIdleTimeoutS,
|
|
4903
5019
|
routes: {
|
|
4904
5020
|
"/healthz": {
|
|
4905
|
-
GET: () =>
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
5021
|
+
GET: async () => {
|
|
5022
|
+
const store = await storeHealth();
|
|
5023
|
+
if (store !== "ok") {
|
|
5024
|
+
return json(503, { status: "unhealthy", reason: store, version: VERSION }, { "cache-control": "no-store" });
|
|
5025
|
+
}
|
|
5026
|
+
return json(200, {
|
|
5027
|
+
status: "ok",
|
|
5028
|
+
version: VERSION,
|
|
5029
|
+
counters: { claudeHeaderWorkaround: claudeWorkarounds }
|
|
5030
|
+
}, { "cache-control": "no-store" });
|
|
5031
|
+
}
|
|
4910
5032
|
},
|
|
4911
5033
|
"/": {
|
|
4912
5034
|
GET: () => new Response(INDEX_HTML, {
|
|
@@ -4969,7 +5091,7 @@ function startRelay(options, hooks) {
|
|
|
4969
5091
|
sendPings: true,
|
|
4970
5092
|
perMessageDeflate: false,
|
|
4971
5093
|
open(ws) {
|
|
4972
|
-
const session = new TabSession(ws, ws.data.ip, ws.data.origin, ws.data.site, host);
|
|
5094
|
+
const session = new TabSession(ws, ws.data.ip, ws.data.origin, ws.data.site, host, ws.data.address);
|
|
4973
5095
|
ws.data.session = session;
|
|
4974
5096
|
allSessions.add(session);
|
|
4975
5097
|
},
|
|
@@ -5058,5 +5180,5 @@ export {
|
|
|
5058
5180
|
resolveConfig
|
|
5059
5181
|
};
|
|
5060
5182
|
|
|
5061
|
-
//# debugId=
|
|
5183
|
+
//# debugId=C9E94078D468D4A064756E2164756E21
|
|
5062
5184
|
//# sourceMappingURL=index.js.map
|