@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 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.4.0.** Until 1.0, a new minor version can still change settings and the API.
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.4.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.4.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 = addressIdFromSecret(secret);
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: { ip, origin: origin ?? "", site },
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: () => json(200, {
4906
- status: "ok",
4907
- version: VERSION,
4908
- counters: { claudeHeaderWorkaround: claudeWorkarounds }
4909
- }, { "cache-control": "no-store" })
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=8481FBCB87B247C864756E2164756E21
5183
+ //# debugId=C9E94078D468D4A064756E2164756E21
5062
5184
  //# sourceMappingURL=index.js.map