@ractive-ch/tab-mcp 0.1.0-alpha.0 → 0.1.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 ADDED
@@ -0,0 +1,131 @@
1
+ # @ractive-ch/tab-mcp
2
+
3
+ The tab-mcp relay. It lets AI apps (Claude, ChatGPT, Claude Code) call
4
+ [MCP](https://modelcontextprotocol.io) tools that a web page offers while it is open in a
5
+ browser tab.
6
+
7
+ The page connects to the relay over a WebSocket with the browser library
8
+ [`@ractive-ch/tab-mcp-client`](https://www.npmjs.com/package/@ractive-ch/tab-mcp-client)
9
+ and gets an MCP address. The user adds that address to an AI app and pairs it by typing
10
+ a code from the page on the relay's sign-in page. The relay then forwards the AI app's
11
+ requests to the page. It knows nothing about the tools themselves.
12
+
13
+ **Status: 0.1.0.** Until 1.0, a new minor version can still change settings and the API.
14
+ The relay speaks MCP revision 2026-07-28 only and accepts AI apps that register through
15
+ OAuth Client ID Metadata Documents; see [compatibility](https://github.com/ractive/tab-mcp/blob/main/docs/compatibility.md)
16
+ for which apps work.
17
+
18
+ ## Run it
19
+
20
+ The relay needs [Bun](https://bun.sh) 1.4.3 or newer. It does not run on Node.js.
21
+
22
+ ```sh
23
+ bun add --global @ractive-ch/tab-mcp
24
+ TAB_MCP_PUBLIC_URL=https://relay.example.com \
25
+ TAB_MCP_ALLOWED_ORIGINS=https://app.example.com \
26
+ tab-mcp
27
+ ```
28
+
29
+ Or with Docker (linux/amd64 and linux/arm64):
30
+
31
+ ```sh
32
+ docker run -d -p 127.0.0.1:8080:8080 -v tab-mcp-data:/data \
33
+ -e TAB_MCP_PUBLIC_URL=https://relay.example.com \
34
+ -e TAB_MCP_ALLOWED_ORIGINS=https://app.example.com \
35
+ ractive/tab-mcp:0.1.0
36
+ ```
37
+
38
+ The image is also on `ghcr.io/ractive/tab-mcp`. Put the relay behind a reverse proxy
39
+ with TLS; see
40
+ [self-hosting](https://github.com/ractive/tab-mcp/blob/main/docs/self-hosting.md) for
41
+ WebSockets, event streams and proxy time limits.
42
+
43
+ For a local test with a page on `http://localhost:3000`:
44
+
45
+ ```sh
46
+ NODE_ENV=development TAB_MCP_PUBLIC_URL=http://localhost:8080 \
47
+ TAB_MCP_ALLOWED_ORIGINS=http://localhost:3000 TAB_MCP_STORE=memory tab-mcp
48
+ ```
49
+
50
+ ## Configuration
51
+
52
+ Environment variables, all listed by `tab-mcp --help` and in
53
+ [configuration](https://github.com/ractive/tab-mcp/blob/main/docs/configuration.md).
54
+ The most important:
55
+
56
+ | Variable | Default | Meaning |
57
+ |---|---|---|
58
+ | `TAB_MCP_PUBLIC_URL` | required | the relay's public origin; `https:` outside development |
59
+ | `TAB_MCP_ALLOWED_ORIGINS` | empty: no page may connect | comma-separated page origins; `*` makes an open relay (read the warning in the docs first) |
60
+ | `TAB_MCP_SITES` | empty | JSON array of sites with their own app name or limits |
61
+ | `TAB_MCP_STORE` | `sqlite` | `sqlite` or `memory` |
62
+ | `TAB_MCP_SQLITE_PATH` | `tab-mcp.db` | the SQLite file for grants and token hashes |
63
+ | `TAB_MCP_TRUST_PROXY` | `none` | `x-real-ip` or `x-forwarded-for` behind a proxy |
64
+ | `TAB_MCP_PORT` | `8080` | listen port |
65
+
66
+ `GET /healthz` answers `200` with the version, for health checks.
67
+
68
+ ## Use it as a library
69
+
70
+ ```ts
71
+ import { createRelay } from "@ractive-ch/tab-mcp";
72
+
73
+ const relay = createRelay({
74
+ publicUrl: "https://relay.example.com",
75
+ allowedOrigins: ["https://app.example.com"],
76
+ store: "sqlite",
77
+ sqlitePath: "/var/lib/tab-mcp/tab-mcp.db",
78
+ });
79
+ console.log(`listening on port ${relay.port}`);
80
+
81
+ process.on("SIGTERM", () => relay.stop().then(() => process.exit(0)));
82
+ ```
83
+
84
+ `createRelay(options)` starts the server and returns once it listens. The options mean
85
+ the same as the environment variables, with camel-case names, milliseconds instead of
86
+ seconds and arrays instead of comma-separated lists (`allowedOrigins`, `sites`,
87
+ `maxSessionsPerIp`, `requestTimeoutMs`, `maxMessageBytes`, …), plus:
88
+
89
+ - `store`: `"sqlite"` (default), `"memory"`, or your own `TokenStore` object;
90
+ - `logger`: your own logger; by default JSON lines on stdout.
91
+
92
+ The returned `Relay` has `url`, `port`, `config`, `sessionCount`, `addressFor(secret)`
93
+ and `stop()`, which sends `bye` to every page (close code 4004) and stops the server.
94
+
95
+ The package also exports `MemoryTokenStore`, `SqliteTokenStore`, the `TokenStore` types,
96
+ `configFromEnv`, `resolveConfig`, `DEFAULT_CONFIG`, `createLogger`, `SUBPROTOCOL`
97
+ (`tab-mcp.v1`) and `VERSION`.
98
+
99
+ ### Your own token store
100
+
101
+ A store keeps grants and the SHA-256 hashes of tokens, never the tokens. Check yours with
102
+ the conformance suite for `bun test`:
103
+
104
+ ```ts
105
+ import { tokenStoreConformance } from "@ractive-ch/tab-mcp/testing";
106
+ import { MyTokenStore } from "./my-token-store.ts";
107
+
108
+ tokenStoreConformance("MyTokenStore", () => new MyTokenStore());
109
+ ```
110
+
111
+ [Token store](https://github.com/ractive/tab-mcp/blob/main/docs/token-store.md) explains
112
+ the interface, its guarantees and a Redis example.
113
+
114
+ ## Security
115
+
116
+ Connecting an AI app to a page means trusting that page, as with any MCP server. The
117
+ relay operator can read every tool argument and result; the relay does not log them.
118
+ Report vulnerabilities privately as described in
119
+ [SECURITY.md](https://github.com/ractive/tab-mcp/blob/main/SECURITY.md). The
120
+ [threat model](https://github.com/ractive/tab-mcp/blob/main/docs/threat-model.md) covers
121
+ the details.
122
+
123
+ ## More
124
+
125
+ - [Repository and overview](https://github.com/ractive/tab-mcp)
126
+ - [Tab–relay protocol](https://github.com/ractive/tab-mcp/blob/main/docs/protocol.md)
127
+ - [Changelog](https://github.com/ractive/tab-mcp/blob/main/CHANGELOG.md)
128
+
129
+ ## Licence
130
+
131
+ MIT, see [LICENSE](LICENSE) and [AI_NOTICE](AI_NOTICE).
package/index.d.ts CHANGED
@@ -9,4 +9,4 @@ export { SUBPROTOCOL } from "./protocol.js";
9
9
  export { type CreateRelayOptions, createRelay, type Relay, VERSION, } from "./relay.js";
10
10
  export { MemoryTokenStore } from "./store/memory.js";
11
11
  export { SqliteTokenStore } from "./store/sqlite.js";
12
- export type { GrantSummary, RevokeSelector, TokenKind, TokenRecord, TokenStore, } from "./store/types.js";
12
+ export type { GrantSummary, RevokeOptions, RevokeSelector, TokenKind, TokenRecord, TokenStore, } from "./store/types.js";
package/index.js CHANGED
@@ -708,7 +708,7 @@ function parseTabMessage(text) {
708
708
  }
709
709
  }
710
710
  // package.json
711
- var version = "0.1.0-alpha.0";
711
+ var version = "0.1.0";
712
712
 
713
713
  // src/exchange.ts
714
714
  var RelayErrorCode = {
@@ -1461,7 +1461,7 @@ function renderPage(title, body) {
1461
1461
  <body>
1462
1462
  <main>
1463
1463
  ${body}
1464
- <footer>tab-mcp relay. It passes requests between AI tools and web pages and does not store their contents.</footer>
1464
+ <footer>tab-mcp relay. It passes requests between AI apps and web pages and does not store their contents.</footer>
1465
1465
  </main>
1466
1466
  </body>
1467
1467
  </html>`;
@@ -1562,7 +1562,7 @@ var MESSAGES = {
1562
1562
  wrong: "That code is not right. Check the code shown in the page and try again.",
1563
1563
  expired: "That code has expired. The page now shows a new code: enter that one.",
1564
1564
  locked: "Too many wrong codes. The page now shows a new code: enter that one.",
1565
- inactive: "The page is not showing a code right now. In the page, open \u201CConnect your AI tool\u201D to see one, then enter it here.",
1565
+ 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.",
1566
1566
  malformed: "Enter the 6 digits shown in the page.",
1567
1567
  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."
1568
1568
  };
@@ -1571,7 +1571,7 @@ var codePairing = {
1571
1571
  render() {
1572
1572
  return `<p><label for="code">Code shown in the page</label>
1573
1573
  <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>
1574
- <p id="code-help" class="help">In the page, \u201CConnect your AI tool\u201D shows a 6-digit code. It changes every 2 minutes.</p>`;
1574
+ <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>`;
1575
1575
  },
1576
1576
  check(target, form, admit) {
1577
1577
  const result = target.codes.check(form.get("code") ?? "", Date.now(), admit);
@@ -1768,7 +1768,7 @@ class AuthorizationServer {
1768
1768
  #errorPage(status, message) {
1769
1769
  return this.#page(status, "This connection cannot be made", `<h1>This connection cannot be made</h1>
1770
1770
  <p role="alert">${escapeHtml(message)}</p>
1771
- <p>Go back to your AI tool and try again. If it keeps happening, the AI tool may not work with this relay.</p>`);
1771
+ <p>Go back to your AI app and try again. If it keeps happening, the AI app may not work with this relay.</p>`);
1772
1772
  }
1773
1773
  #errorUrl(redirectUri, state, error, description) {
1774
1774
  const url = new URL(redirectUri);
@@ -1786,8 +1786,8 @@ class AuthorizationServer {
1786
1786
  const url = this.#errorUrl(redirectUri, state, error, description);
1787
1787
  const host = hostOf(redirectUri);
1788
1788
  return this.#page(400, "This connection cannot be made", `<h1>This connection cannot be made</h1>
1789
- <p role="alert">The AI tool sent a request this relay cannot accept: ${escapeHtml(description)} (${escapeHtml(error)}).</p>
1790
- <p>You can go back to the AI tool at ${escapeHtml(host)} to tell it, or close this page.</p>
1789
+ <p role="alert">The AI app sent a request this relay cannot accept: ${escapeHtml(description)} (${escapeHtml(error)}).</p>
1790
+ <p>You can go back to the AI app at ${escapeHtml(host)} to tell it, or close this page.</p>
1791
1791
  <div class="actions"><a class="button" href="${escapeHtml(url)}">Go back to ${escapeHtml(host)}</a></div>`);
1792
1792
  }
1793
1793
  #redirectError(redirectUri, state, error, description) {
@@ -1937,7 +1937,7 @@ class AuthorizationServer {
1937
1937
  const body = `<h1>Open the page first</h1>
1938
1938
  ${errorHtml}
1939
1939
  <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>
1940
- <p>Open the page, click \u201CConnect your AI tool\u201D, then try again here.</p>
1940
+ <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>
1941
1941
  <div class="actions">
1942
1942
  <a class="button primary" href="${escapeHtml(retryUrl)}">Try again</a>
1943
1943
  <a class="button" href="${escapeHtml(cancelUrl)}">Cancel</a>
@@ -1951,7 +1951,7 @@ ${errorHtml}
1951
1951
  return this.#errorPage(403, "This form can only be sent from this page.");
1952
1952
  }
1953
1953
  if (!this.#attemptsPerIp.take(ip)) {
1954
- return this.#errorPage(429, "Too many attempts from your network. Wait a few minutes, then start again from your AI tool.");
1954
+ return this.#errorPage(429, "Too many attempts from your network. Wait a few minutes, then start again from your AI app.");
1955
1955
  }
1956
1956
  const type = (req.headers.get("content-type") ?? "").split(";")[0]?.trim();
1957
1957
  if (type !== "application/x-www-form-urlencoded") {
@@ -2090,7 +2090,7 @@ ${errorHtml}
2090
2090
  const body = `<h1>Open the page first</h1>
2091
2091
  ${errorHtml}
2092
2092
  <p>${clientLabel} wants to use the tools of a web page, but that page is not open right now.</p>
2093
- <p>Open the page, click \u201CConnect your AI tool\u201D, then try again here.</p>
2093
+ <p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>
2094
2094
  <form method="post" action="/authorize">
2095
2095
  ${hidden}
2096
2096
  <div class="actions">
@@ -2119,7 +2119,7 @@ ${method.render(session)}
2119
2119
  ${cancel}
2120
2120
  </div>
2121
2121
  </form>
2122
- <p class="help">After you connect, your browser goes back to ${escapeHtml(hostOf(pending.redirectUri))}. Only connect AI tools you started yourself.</p>`;
2122
+ <p class="help">After you connect, your browser goes back to ${escapeHtml(hostOf(pending.redirectUri))}. Only connect AI apps you started yourself.</p>`;
2123
2123
  return this.#page(status, heading, body, targets, extra);
2124
2124
  }
2125
2125
  async token(req, ip) {
@@ -3332,7 +3332,7 @@ function startRelay(options, hooks) {
3332
3332
  const session = sessions.get(addressId);
3333
3333
  if (session === undefined || !session.ready) {
3334
3334
  logDone(503, method, { reason: "page_not_open" });
3335
- 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);
3335
+ 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);
3336
3336
  }
3337
3337
  if (session.pending.size >= session.limits.maxConcurrentRequests) {
3338
3338
  logDone(429, method);
@@ -3554,5 +3554,5 @@ export {
3554
3554
  resolveConfig
3555
3555
  };
3556
3556
 
3557
- //# debugId=ACF1F76550B2129B64756E2164756E21
3557
+ //# debugId=0BEEC7645167157064756E2164756E21
3558
3558
  //# sourceMappingURL=index.js.map