# js-bridge-mcp

A generic bridge: MCP tools are discovered at runtime from a connected
page's own JSON tool manifest, and dispatched to that page's `window.*`
functions over a cross-origin WebSocket. This package ships a worked
"hello world" example of `@avo-mcp-tools/mcp-tenant-lib`'s
**Pattern B**: AI-enabling an existing static page that this stack
doesn't own or build.

Two independent servers, two independent origins:

- **`js-bridge-mcp`'s own server** — MCP endpoint (`/mcp`) + WebSocket
  bridge (`/ws`) + one static asset (`/main.js`), on port 8766.
- **`legacy-page/hello-world.html`** — a plain HTML page with an `<h1>`
  and a `<main>`, no build step, no dependency on this stack. Served by
  any static file server — this example uses `http-server --cors` on
  port 8080.

The page and the MCP server never talk to each other directly. The
*browser* bridges them: a one-line executable JS snippet (generated by the
`get_embed_snippet` tool) connects straight to `js-bridge-mcp`'s WebSocket,
cross-origin. The primary way to run it is pasting it directly into the
target page's DevTools console — no editing the page's source required,
which is the expected workflow for a developer today. It can also be
wrapped in a `<script type="module">...</script>` tag and baked into the
page's HTML if preferred. Once connected, the page pushes its own
`#mcp-tools` JSON manifest over that socket, and the server registers the
tools it describes — see `packages/mcp-tenant-lib/BRIDGING.md` for how
to write one for a new page.

## Run it

### Via npx

```bash
npx js-bridge-mcp
```

Starts the MCP + WS + `main.js` server on port 8766. Point an MCP client at
`http://localhost:8766/mcp`, then serve `legacy-page/hello-world.html` (or
your own page) with any static file server.

### From this repo

```bash
npm run build        # bundle src/client/main.ts -> dist/client/main.js
npm run start:mcp     # MCP + WS + main.js server, port 8766
npm run start:static  # serves legacy-page/, port 8080, in a second terminal
```

Open `http://localhost:8080/hello-world.html` — it renders as-is, not yet
connected to anything.

### Options

`js-bridge-mcp --help` prints this list. From this repo, pass flags after
`--`, e.g. `npm run start:mcp -- --allow-remote`.

| Flag | Environment variable | Effect |
| --- | --- | --- |
| `--allow-remote` | `JS_BRIDGE_MCP_ALLOW_REMOTE=1` | Accept connections from other devices. Default: this machine only. |
| `--allow-origin <url>` (repeatable) | `JS_BRIDGE_MCP_ALLOWED_ORIGINS=<url>,<url>` | Let page JavaScript on that web origin call `/mcp` and `/api/*`. |
| `--force-kill-port` | `JS_BRIDGE_MCP_FORCE_KILL_PORT=1` | Kill whatever holds the port, even if it is not js-bridge-mcp. |
| `--persist-page-tools` | `JS_BRIDGE_MCP_PERSIST_PAGE_TOOLS=1` | Save agent-registered page tools and register them again when the same page reconnects. Default: off. See "Keeping registered page tools". |
| | `PORT` | Port to listen on. Default 8766. |
| | `PROXY_CONFIG_PATH` | Proxy config file. Default `~/.js-bridge-mcp-proxies.json`. |
| | `PROXY_CALL_TIMEOUT_MS` | Default timeout for a proxied tool call. Default 60000. |
| | `JS_BRIDGE_MCP_PROXY_RECONNECT=0` | Do not auto-reconnect a proxy whose upstream dropped. |
| | `JS_BRIDGE_MCP_NO_OPEN=1` | Do not open the dashboard in a browser on start. |
| | `PAGE_TOOLS_PATH` | File for `--persist-page-tools`. Default `~/.js-bridge-mcp-page-tools.json`. |
| | `JS_BRIDGE_MCP_CALL_LOG_LIMIT` | How many tool calls the call log keeps. Default 200, maximum 5000, 0 turns the log off. |
| | `JS_BRIDGE_MCP_CALL_LOG_TTL_MINUTES` | How long the call log keeps a call. Default 60. |

On start, the server replaces an older js-bridge-mcp instance that still
holds its port. A process that is not js-bridge-mcp is left running and the
new instance exits with a message instead.

### Who can reach the server

This server can spawn processes (a stdio proxy's `command`) and run
JavaScript in bridged tabs, so its control surface is restricted by default.
The startup log states the current setting.

- **Other devices are refused.** Only connections from this machine are
  accepted. Start with `--allow-remote` to accept the LAN.
- **`/mcp`, `/api/*` and `/upload` check the caller.** A request with no
  `Origin` header (Claude Code, VS Code, curl, any non-browser client) is
  accepted. So is one from a page on `localhost` / `127.0.0.1` (any port) or
  from a browser extension. Page JavaScript on any other website is refused
  with `403`; add the site with `--allow-origin https://example.com` if it
  must call these routes itself. The `Host` header must also be a loopback
  name, which blocks DNS-rebinding.
- **Bridging a page in is not restricted.** The WebSocket (`/ws`) and the
  static files (`/main.js`, `/connect.js`, `/tool-bus.js`) stay reachable
  from any origin. A hosted site that connects with `connect.js`, the
  `js-bridge-mcp/client` SDK, or a pasted `import(...)` snippet needs no
  allowlist entry.

The proxy config file is written with owner-only permissions (`0600`); it
holds any `env` values and HTTP `headers` you give a proxy.

### MCP proxies

`/admin` (or the `admin__*` MCP tools) adds upstream MCP servers whose tools
then appear in every session, prefixed `<slug>__`.

- A proxy whose upstream goes away after connecting (a crashed stdio server,
  a restarted HTTP one) is marked disconnected, its tools are removed, and it
  reconnects by itself with a growing delay (1s to 30s, six attempts). A
  proxy that never connected is not retried; fix its config and restart it.
- When the upstream announces a changed tool list, the proxy re-reads it.
- An upstream tool parameter that cannot be expressed precisely (`oneOf`,
  `$ref`, a free-form object) is accepted unvalidated, with its JSON Schema
  shown in the parameter description. String `enum`s are enforced.
- Calls time out after 60s by default. Set `callTimeoutMs` in a proxy's JSON
  config, or `timeoutSeconds` on `admin__add_mcp_proxy` /
  `admin__edit_mcp_proxy`, to change it for one proxy.
- HTTP `headers` (for `sse` / `streamableHttp` proxies) can be set from the
  JSON form in `/admin` or with the `headers` parameter of the admin tools.
  An edit that does not mention `headers` keeps the existing ones.
- A proxied tool's result reaches the MCP client as the upstream sent it:
  its content blocks (text, image, audio, resource) and its `isError` flag.
  Tool `annotations` (`readOnlyHint`, `destructiveHint`, ...) are passed
  through to `tools/list`.
- When the MCP client cancels a call, the cancel is sent to the upstream.
  When the client asks for progress, the upstream's progress notifications
  are sent back to it. Neither changes a call's timeout.

#### OAuth

Set `oauth` on an `sse` / `streamableHttp` proxy (the checkbox in `/admin`,
`"oauth": true` in the JSON form, or the `oauth` parameter of the admin
tools) for an upstream that uses the MCP authorization flow. The proxy then
shows **authorization required** with an **Authorize…** link in `/admin`
(`authUrl` in `admin__list_mcp_proxies`). Open the link, approve, and the
upstream redirects to `http://localhost:<port>/oauth/callback`, which
finishes the flow and connects the proxy. The link is never opened for you.

- Tokens are stored next to the proxy config, in
  `~/.js-bridge-mcp-proxies-oauth.json` (owner-only, `0600`), and refreshed
  automatically. A restart does not ask for approval again.
- Removing a proxy, changing its `url`, or turning `oauth` off deletes its
  tokens.
- For an upstream without dynamic client registration, use the JSON form:
  `"oauth": { "clientId": "...", "clientSecret": "...", "scope": "..." }`
  (all three optional). Register `http://localhost:<port>/oauth/callback`
  as the redirect URI there.

#### Lazy proxies

A proxy normally adds one tool per upstream tool to every session's
`tools/list`. Set `lazy` (the checkbox in `/admin`, `"lazy": true`, or the
`lazy` parameter of the admin tools) and it adds two instead:

- `<slug>__list_tools` — the upstream's tool names and one-line
  descriptions; pass `tool` for one tool's full description and input
  JSON Schema.
- `<slug>__call_tool` — calls one upstream tool: `{ "tool": "<name>",
  "args": { ... } }`.

Use it for a large upstream server that is needed only now and then. The
upstream validates the arguments; the call log records the upstream tool's
own name.

## Tool call log

`/calls` (the **Tool calls** link on the dashboard, in its own window) lists
the recent tool calls made through `/mcp`: time, MCP session, connection,
tool, duration, result or error, and the arguments. Click a row for the full
arguments and the start of the result.

- **Sessions.** Each call carries the id of the MCP session that made it and
  that client's name. Filter by session, or tick **group by session**, when
  more than one agent uses the server at once. Connection, tool name and
  errors-only filters are also there.
- **How much is kept.** The last N calls, for M minutes — both set in the
  window (or with the environment variables above). The log is in memory
  only and is gone on restart. Arguments longer than 4000 characters and
  results longer than 600 are cut.
- **Export.** **Export JSON** saves the calls shown. **Export report** saves
  a Markdown report of them, written for an LLM that should improve the
  tools: per tool, call and error counts, each distinct error with the
  arguments that caused it, which parameters callers passed against which
  the tool declares, calls repeated straight after an error, durations and
  result sizes. An agent gets the same report with
  `admin__tool_call_report` (`session: "current"` limits it to its own
  calls).

The log holds tool arguments, so its routes (`/api/dashboard/calls`) are
guarded like the rest of `/api/*`.

## Connect an agent

1. Point an MCP client at `http://localhost:8766/mcp`.
2. Call `get_embed_snippet`. It returns something like:
   ```js
   import("http://localhost:8766/main.js?server=http%3A%2F%2Flocalhost%3A8766&tenant=js-bridge-mcp");
   ```
   The `tenant` value is the name the page connects under, and its tool
   prefix. Call `join_channel` with a name of your choice first to change it.
3. Open `legacy-page/hello-world.html` in a browser, open DevTools, go to
   the Console tab, paste that line, and press enter. (Alternatively, wrap
   it in a `<script type="module">...</script>` tag and paste it into the
   page's HTML before `</body>` — there's a comment marking the spot — then
   reload.)
4. Call `js_bridge_mcp__insert_title` / `js_bridge_mcp__insert_main` (see
   `describe_tools` for the exact names) from the MCP client — the open tab
   updates live, no page refresh needed.

Repeated `get_embed_snippet` calls within the same session return the same
snippet. A page connected by bare name like this is a root connection: its
tools are visible to every MCP session on this server, not only the one that
generated the snippet.

## Auto-connect on page load (no DevTools paste)

The manual paste above is the right default for a one-off static page, but a
real app with its own build (Vite/webpack/etc) that wants to stay connected
across every reload doesn't need a human to paste anything, ever. Instead of
a session-minted tenant UUID, the app connects itself on boot using a fixed,
human-readable name. By default this becomes a **root connection** —
addressed directly, its tools merged into every MCP session automatically,
no `join_channel` needed. Pass `"channel:app-name"` instead to join a real,
agent-joinable **channel**, reachable later via `join_channel("<channel-name>")`.
Either way, zero interaction is needed on the page side after the first
connect.

There's also a packaged skill for this exact recipe:
`.agents/skills/js-bridge-mcp-auto-connect-button/SKILL.md` — load it before
implementing so you don't reinvent the wiring below from scratch.

**Requires `window.__mcpTools` to already be defined by the page** (see
above) — this only handles the *connection*, not the tool contract.

The connect lifecycle itself (probe, connect, rename, leave-old-channel-on-
switch) is shared infrastructure, served by this package the same way
`tool-bus.js` is: `src/client/connect.js`, importable by URL at
`<server>/connect.js`, exporting one factory:

```js
import { createMcpConnect } from 'http://localhost:8766/connect.js';

const connect = createMcpConnect({ appName: 'myapp' }); // localStorage key + tool-name label; becomes its own ROOT connection named "myapp"
connect.init();                                          // connects on page load
connect.handleConnectClick();                             // wire to a toolbar button
connect.onConnectionStateChange((state, channel, appLabel) => { /* render a status indicator */ });
connect.getConnectionState();                              // { state, channel, appLabel } - synchronous
```

`createMcpConnect` also accepts `defaultChannel` (the raw connect string used
before any human retargets it — defaults to `appName`, i.e. this app becomes
its own root connection with no channel needed; pass a `"channel:app-name"`
string instead to join a real, agent-joinable channel by default) and
`beforeConnect` (an optional async hook run once, before the first
`main.js` import — for a host page that layers extra tool providers onto
`window.__mcpTools` first, e.g. via `tool-bus.js`; see bulletino-1's
`mcp-connect.mjs` for a worked example).

If the host page's bundler doesn't support top-level await at its configured
build target (common with Vite's default target), `connect.js` still has to
be reached via a dynamic `import()` rather than a static one — wrap it in a
small synchronous stub that starts `'disconnected'` and swaps in the real
instance once the import resolves, so a UI component that reads
`getConnectionState()` synchronously at its own module-eval time still
works. See htmlpaint.com's or mindfoo's `mcp-connect.js`/`.ts` for the
pattern (native ESM pages with no bundler, like bulletino-1's
`mcp-connect.mjs`, can just top-level-`await` it directly).

### Root connections vs. "channel:app-name" — joining a shared channel

By default, typing a bare name (`"htmlpaint2"`) in the connect prompt makes
that page its own **root connection** — addressed directly by name, with its
tools always prefixed `htmlpaint2__...` and merged into every MCP session
automatically. No `join_channel` needed; any agent can call
`describe_connection("htmlpaint2")` to inspect it directly.

Typing `"channel:app-name"` instead (`"bug123:htmlpaint"`) joins a real,
agent-joinable **channel** — the part before the colon is the channel name,
the part after sets `window.__mcpAppName` for this connection specifically.
This is how several different apps deliberately join the **same** channel
(like inviting several people into one Slack channel) while each keeps its
own readable tool-name prefix instead of colliding on the channel name as
its label: type `bug123:htmlpaint` in one tab and `bug123:bulletino` in
another, and both land on channel `bug123` with tools prefixed
`htmlpaint__...` / `bulletino__...` — see "Multiple tabs on one tenant"
below for how that prefixing works. An agent then reaches them via
`join_channel("bug123")`.

### Orphaned channels get cleaned up automatically

Two independent mechanisms, so switching channels (or just closing a tab)
doesn't leave a dead tenant sitting around for the 2-hour general idle sweep
to eventually notice:

- **Explicit switch**: when `connect.js` reconnects a tab from channel A to
  channel B (via `handleConnectClick` or a fresh `init()`), it sends a
  `leave_channel` message on A's socket before opening the new one on B. The
  server drops A's tenant immediately if that was its last connection — a
  no-op if other tabs/apps are still on A.
- **Tab closed / crashed**: the server can't distinguish a genuine tab close
  from a brief network drop — both look like the same WebSocket `close`
  event. So instead it tracks how long a tenant has had *zero* connections
  and disposes it once that exceeds `TENANT_EMPTY_TIMEOUT_MS` (default
  15s, separate from and much shorter than `TENANT_IDLE_TIMEOUT_MS`'s 2-hour
  default) — comfortably above the client's ~2s reconnect retry, so a reload
  or brief blip never trips it, but an actually-closed tab is gone within
  seconds rather than hours.

Add a thin connector module, e.g. `src/mcp-connect.ts`, wrapping the shared
factory shown above:

```ts
import { createMcpConnect } from 'http://localhost:8766/connect.js';

export const connect = createMcpConnect({ appName: 'myapp' });
```

(If your bundler can't top-level-`await` a dynamic import at its configured
build target, wrap this in the synchronous-stub pattern described above
instead of a bare re-export — see htmlpaint.com's/mindfoo's `mcp-connect`
files for the full worked version.)

Wire it into the app's entry point, **after** `window.__mcpTools` is set:

```ts
import './mcpbridge'; // sets window.__mcpTools
import { connect } from './mcp-connect';

connect.init(); // no dev-mode gate - JSBRIDGE_HOST is always localhost
```

And a status button somewhere in the toolbar, bound to
`connect.onConnectionStateChange` and `connect.handleConnectClick`:

```
⚪ myapp        -- disconnected, click to connect
🟡 connecting…  -- probing/importing
🟢 myapp        -- connected as root connection "myapp", click to rename (or "channel:app-name" to join a shared channel)
```

Any MCP client can now reach this page's tools without ever touching
DevTools or calling `join_channel` first — its tools already appear in
`tools/list`, prefixed `myapp__...` (see "Multiple tabs on one tenant"
below for how that prefixing works when more than one connection is
involved).

## Multiple entrypoints (`js-bridge-mcp` vs `js-bridge-mcp/client` vs `/bus` vs `/connect`)

Four ways to consume this package, depending on what you're building:

1. **`js-bridge-mcp`** (npm dependency, server) — `npx js-bridge-mcp` or
   programmatic server usage. Unchanged, this is the same package entrypoint
   as always.
2. **`js-bridge-mcp/client`** (npm sub-path, bundler-based host app) —
   `import { connectMcpBridge, defineTool } from 'js-bridge-mcp/client'`.
   The ergonomic all-in-one entrypoint for a normal Vite/TS app: composes
   `createMcpConnect` with an automatic tool-bus load, replacing the
   hand-rolled `JSBRIDGE_HOST` + dynamic-`import()` boilerplate a consumer
   would otherwise write itself.
3. **`<server>/tool-bus.js`** (URL import, DevTools-pasteable, zero
   baggage) — `window.__mcpToolBus.registerTool(...)`. Works standing
   alone, no other piece of this package required.
4. **`<server>/connect.js`** (URL import) — used internally by
   `js-bridge-mcp/client`, and still directly importable for a page with no
   bundler at all (e.g. a plain `<script type="module">` app).

A jsDelivr URL to the published npm package's `client` sub-path (e.g.
`https://cdn.jsdelivr.net/npm/js-bridge-mcp@<version>/dist/client/sdk.js`)
is a fifth, equivalent way to reach path 2 without installing anything —
useful for a no-bundler host that still wants `connectMcpBridge`/`defineTool`'s
ergonomics. `tool-bus.js`/`connect.js` are deliberately **not** added as npm
`exports` sub-paths (no `"./bus"`/`"./connect"` in `package.json`) — their
whole reason for existing is runtime-URL-import (jsDelivr or a local
server fetch), not bundler resolution; don't "fix" this by adding them to
`exports` later.

## Bridge any other project's static HTML to this MCP server

`js-bridge-mcp` doesn't care what the page is — `legacy-page/hello-world.html`
is just a worked example. Any static HTML page (in this repo or a totally
unrelated project) can become a tenant of an already-running `js-bridge-mcp`
server by adding two things to its own source, with zero build-step
dependency on this package. This section is the complete recipe — no need to
go spelunking in other packages' docs.

### 1. Define `window.__mcpTools` in the page, before the bridge script runs

A global array of tool definitions, each holding a **real function
reference** (not a string name):

```html
<script>
  function highlightRow({ rowId, color }) {
    const row = document.getElementById(rowId);
    if (!row) throw new Error(`no row with id "${rowId}"`);
    row.style.backgroundColor = color ?? 'yellow';
    return `highlighted ${rowId}`;
  }

  window.__mcpTools = [
    {
      name: 'highlight_row',
      description: 'Highlights the table row matching the given id. Call list_rows first if you don\'t know valid ids.',
      params: {
        rowId: { type: 'string', description: 'The id attribute of the <tr> to highlight' },
        color: { type: 'string', description: 'CSS color name, defaults to yellow if omitted', optional: true },
      },
      example: { rowId: 'row-42', color: 'yellow' },
      fn: highlightRow,
    },
  ];
</script>
```

Schema per entry:

- **`name`** — snake_case, unique on the page. What the MCP-connected agent
  sees and calls.
- **`description`** — written for the *agent*, not a human reader: state what
  it does, preconditions ("call X first"), and side effects. See
  `get_embed_snippet`'s description in `src/tools/hello-tools.ts` for the bar
  to hit.
- **`params`** — an object of `{ type, description?, optional? }` values.
  `type` is `"string"` (optionally with `enum: [...]`), `"number"`,
  `"boolean"`, `"array"` (with an `items` spec of this same shape),
  `"object"` (with a `properties` map of this same shape), or `"any"`.
- **`example`** — a realistic call, useful both as page-source documentation
  and as something you should actually try once connected.
- **`annotations`** — optional MCP hints for the client, e.g.
  `{ readOnlyHint: true }` (the client may call it without asking) or
  `{ destructiveHint: true }`.
- **`context`** — optional. Set `true` and `fn` is called as
  `fn(args, { signal, progress })`: `signal` aborts when the MCP client
  cancels the call, and `progress(done, total?, message?)` reports progress
  to it.
- **`fn`** — called with a single args object matching `params` (never
  positional args). Its return value, or a thrown `Error`'s message, becomes
  the tool call's result. To return an image, return an MCP tool result:
  `{ content: [{ type: 'image', data: '<base64>', mimeType: 'image/png' }] }`
  (an object with only `content` / `isError`, whose `content` is a non-empty
  list of MCP content blocks, is passed to the client as it is; any other
  value is returned as JSON text). `fn` never leaves the browser — the bridge strips
  it before talking to the server, which only ever sees
  `name`/`description`/`params`/`example` and dispatches calls back by
  `name` against its local copy of `window.__mcpTools`.

Optionally also set `window.__mcpAppName` (a short string, e.g. `"formalin"`
or `"htmlpaint"`) before the embed snippet runs. It becomes this
connection's tool-name prefix, always — matters most when the *same*
`get_embed_snippet` output gets pasted into more than one browser tab, see
"Multiple tabs on one tenant" below. Falls back to `document.title` if
omitted.

If the page is an ES module build rather than plain script tags, define
`window.__mcpTools` in whichever module already has the real functions in
scope — same shape, still a direct function reference, no string lookup.

### 2. Add the embed snippet, after `window.__mcpTools` is defined

Get it by calling this server's `get_embed_snippet` MCP tool (from any MCP
client pointed at `http://localhost:8766/mcp`); it returns one line like:

```js
import("http://localhost:8766/main.js?server=http%3A%2F%2Flocalhost%3A8766&tenant=<uuid>");
```

Two ways to run it, both fine:

- **Paste into DevTools console** on the already-open target page — no
  source edit at all. This is the default workflow when you (or the agent)
  have the page open in a browser you control.
- **Bake into the page's HTML**, wrapped in a module script tag, placed
  *after* the `window.__mcpTools` block:
  ```html
  <script type="module">import("http://localhost:8766/main.js?server=...&tenant=...");</script>
  ```

Either way, the bridge reads `window.__mcpTools` **once**, synchronously, at
load/(re)connect time — it does not poll. Edit the tool list, then reload the
page (and let the bridge reconnect) before the new tools show up.

### Full example: a bare page, wired up end-to-end

This is `legacy-page/hello-world.html` in full — copy it as a starting point
for any project's own static page. The only things that change per-project
are the functions and tool definitions inside the `<script>` block; the
embed-snippet line at the bottom is generated fresh per tenant by
`get_embed_snippet` and pasted in (or run from DevTools instead of baked in).

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Hello World</title>
</head>
<body>
  <h1>Hello, world!</h1>
  <main>Waiting for an agent to say something...</main>

  <script>
    function insertTitle({ title }) {
      document.title = title;
      document.querySelector('h1').textContent = title;
      return `title set to "${title}"`;
    }

    function insertMain({ main }) {
      document.querySelector('main').textContent = main;
      return 'main content updated';
    }

    // window.__mcpTools is the contract the injected bridge script looks
    // for: an array of { name, description, params, example, fn } — real
    // function references, not string lookups. Must be defined before the
    // embed snippet below runs.
    window.__mcpTools = [
      {
        name: 'insert_title',
        description: 'Sets the <h1> title shown on this page.',
        params: { title: { type: 'string', description: 'New page title' } },
        example: { title: 'Welcome, Ada!' },
        fn: insertTitle,
      },
      {
        name: 'insert_main',
        description: 'Sets the <main> body content shown on this page.',
        params: { main: { type: 'string', description: 'New body text' } },
        example: { main: 'Here is your daily summary...' },
        fn: insertMain,
      },
    ];
  </script>

  <!-- Paste the snippet from get_embed_snippet here, wrapped in a
       <script type="module"> tag — or just run it from DevTools instead. -->
  <script type="module">import("http://localhost:8766/main.js?server=http%3A%2F%2Flocalhost%3A8766&tenant=<uuid>");</script>
</body>
</html>
```

Run this repo's copy of it via `npm run start:static` (see "Run it" above),
or drop the equivalent markup into any other project's page — nothing here
depends on this package's build tooling.

### Optional: `run_transient` for one-off computations

A page can optionally define one more tool, alongside its regular fixed
ones, that lets an agent write and immediately run a throwaway JS
computation for the current session only — see `legacy-page/hello-world.html`
for the full worked pilot (`runTransient` + its manifest entry). The
motivating case: a page exposes some data (e.g. a big list of numbers,
durations, or other values via a `get_*` tool), and the agent needs an
aggregate over it — sum, average, max/min, a multi-step filter. Doing that
arithmetic itself, in-context, token by token, gets unreliable as the list
grows — it produces a plausible-looking wrong number with no error signal.
Real JS run against the real data is deterministic.

```js
function runTransient({ code, args }) {
  const parsedArgs = typeof args === 'string' && args ? JSON.parse(args) : undefined;
  // eslint-disable-next-line no-new-func -- deliberate, see hello-world.html for the full rationale
  const fn = new Function('args', 'document', 'window', code);
  const result = fn(parsedArgs, document, window);
  return typeof result === 'string' ? result : JSON.stringify(result);
}

window.__mcpTools.push({
  name: 'run_transient',
  description: '...(see hello-world.html for the bar to hit — must state clearly this is ' +
    'for large/complex computations only, not a replacement for fixed tools, and that "code" ' +
    'is a function BODY whose return value becomes the result)',
  params: {
    code: { type: 'string', description: 'JS function body; receives (args, document, window), return value becomes the result' },
    args: { type: 'string', description: 'JSON string passed as `args`; omit if code takes no input', optional: true },
  },
  fn: runTransient,
});
```

Deliberately **not** a new registered MCP tool per definition, and
**not** persisted anywhere (not `localStorage`, not appended to
`window.__mcpTools`) — each call compiles `code`, runs it once, and
discards it:

- The bridge reads `window.__mcpTools` once at connect and does not poll
  (see "Common mistakes" above) — a page tool array mutated mid-session
  wouldn't reach the *current* session's MCP client without a reconnect
  anyway, so "register a new tool name per definition" doesn't reliably
  work today even where the server-side sync supports it in principle.
- Persisting agent-authored code across page loads is a materially
  different, larger risk than running it once in the current tab: it turns
  into arbitrary code that runs automatically on every future load with no
  review step. Keep it session-scoped; if a computation turns out to be
  worth reusing, promote it to a normal hand-authored, reviewed tool in the
  page's own source instead of auto-persisting what the agent wrote.

**This is still full code execution in the page's own origin** —
`new Function` is not meaningfully safer than `eval`; session-scoping
bounds *persistence*, not *capability*. Fine for a page with no
auth/secrets (like the demo page here). A page carrying real session state,
cookies, or API access should treat adding this tool as a deliberate,
visible grant — document it clearly in `window.__mcpSummary` — not a
default to copy onto every bridged page. The call itself (tool name, the
`code` string, `args`, and the result) is an ordinary logged MCP
call/result like any other tool call, so even though the code is
agent-authored, what actually ran is auditable after the fact from the
session transcript.

### Common mistakes

- Positional args instead of one args object (`fn({ rowId })`, not
  `fn(rowId)`).
- Defining `window.__mcpTools` *after* the embed snippet already ran.
- Reusing a `name` across two entries in the **same page**'s own
  `window.__mcpTools` array — this is still a real bug (last one wins).
  Reusing a `name` across two *different* pages/tabs sharing a tenant is
  fine now — see "Multiple tabs on one tenant" below, each gets an
  automatic per-connection prefix.
- Expecting a plain, direct edit to `window.__mcpTools` itself to take
  effect without a page reload — that array is still only read fresh when
  something triggers a re-send (see "Live/late tool registration" below);
  editing it in place with nothing watching for the change is a no-op
  until the next reload.
- Pasting the embed snippet into the page **after** your MCP client already
  connected: some clients (Claude Code included, observed against
  `js-bridge-mcp`) fetch `tools/list` once at `initialize` and won't re-poll
  on the server's `tools/list_changed` notification mid-session. New tools
  may need a full MCP client restart to appear, even though the browser
  tenant is connected and the server registered them correctly. This is the
  SAME caveat that applies to live/late registration below — the server
  always registers correctly and always emits `tools/list_changed`; whether
  your MCP *client* notices is a separate, per-client question.

### Live/late tool registration

A page's tools no longer have to all exist before the very first connect.
`window.__mcpToolBus` (see `tool-bus.js` above) supports registering a tool
at ANY point during an already-connected session — main.js subscribes to
the bus's `onChange` directly and re-sends the full merged tool list
(`window.__mcpTools` + the bus's current tools) every time it fires, no
page reload required. The single-tool primitive for this is
`registerTool`, a DevTools-pasteable sibling to `registerProvider`:

```js
window.__mcpToolBus.registerTool('save_current_note', () => window.myApp.save(), {
  description: 'Saves the currently open note',
});
```

This is the mechanism behind mapping an ad-hoc `window.*` function (e.g. a
Vue app's exposed instance method) to a tool name with zero source changes
to the host app — paste it in DevTools, and (subject to the MCP-client
caveat immediately above) the tool becomes callable without reconnecting.

**Prefer a guided UI over hand-typing `registerTool` calls?** The dashboard
(`localhost:8766/`) has a tools panel for exactly this — click a
connection's tool count to open it, browse what's registered (tagged host
vs. dynamic), add a new tool by pointing at an existing `window.*` function
or pasting fresh code, and remove any dynamically-added tool you no longer
need. Host-defined tools can never be removed this way.

### Remote registration via MCP tools

The same registration/unregistration mechanism above is also available to
agents, not just humans at the dashboard — three MCP tools (defined in
`mcp-tenant-lib`, available to any tenant-lib consumer, not js-bridge-mcp-
specific):

- **`register_page_tool_by_path(id?, name, description, path)`** — points
  at an existing `window.*` function (e.g. `path: "myApp.save"` resolves
  `window.myApp.save`). Use when something the page already does just
  needs exposing.
- **`register_page_tool_by_code(id?, name, description, code)`** — agent
  authors a brand-new function body, compiled and run as
  `new Function('args', 'document', 'window', code)` — the same trust model
  as pasting code into DevTools, but this is standing/persistent, not
  one-shot. Registers immediately, with no human approval step of any kind —
  the name/description/code are logged as a sticky toast on this MCP
  server's dashboard so a human can review what got registered, but that's
  purely informational and doesn't block anything. A throwing/invalid
  snippet surfaces as a real tool error, not a silent failure. Good for
  exploration too: a discovery/inspection function can inform what other
  tools to register next — this is the closest an agent gets to "do what a
  human can do at DevTools."
- **`unregister_page_tool(id?, toolName)`** — removes a previously
  dynamically-added tool by name. Can NEVER remove a host-defined tool
  (one the page itself defined in `window.__mcpTools`) — errors clearly
  instead of silently no-op'ing if the name isn't a currently-tracked
  dynamic registration.

#### Keeping registered page tools

By default a tool registered this way lasts until its page reloads (a quick
reload under the same name gets it back from memory; a server restart or a
closed tab loses it). Start the server with `--persist-page-tools` to keep
such tools: each is saved, with its code or path and its `params`, to
`~/.js-bridge-mcp-page-tools.json` (owner-only, `0600`) under the page's
connection name, and registered again the next time a page connects under
that name. `unregister_page_tool` removes it from the file. A tool that
fails to register again (its `window.*` path is gone) is dropped. Entries
not used for 90 days are dropped.

This is off by default because it makes agent-written code outlive the
session that wrote it. The code is compiled on reconnect and runs only when
the tool is called.

All three accept an optional connection `id` (from `describe_tools`'
`connections` array — omit when only one connection is live, same
convention as `identify_connection`) and wait for the browser to confirm
success/failure before returning, so a bad path or a failed compile
surfaces as a real tool error, not a silent no-op.

The dashboard's tools panel also lets a human save any dynamic tool (one
with a captured `origin`) to a `.tool.json` file via a save button on its
row, select several via checkbox and export them as separate files at
once, and later re-register one or more of them from an "Import tool(s)"
file picker — going through the same register-by-path/register-by-code
routes described above.

- Two tabs of the *same* page connected to the same tenant get
  ordinal-suffixed prefixes (`tab__`, `tab2__`, ...) unless
  `window.__mcpAppName`/`document.title` differ between them — call
  `describe_tools` to see current prefixes rather than guessing.

### Multiple tabs on one tenant

`get_embed_snippet` returns the same tenant id for the life of an MCP
session, so pasting that same snippet into more than one browser tab — a
different app in each tab, or several tabs of the same app — connects all
of them to the same tenant. This is supported, not just an edge case to
avoid: it's how one MCP session can drive multiple pages at once (e.g.
"read form data from tab A, use it to drive tab B").

- Each WS connection is tracked separately server-side. Every registered MCP
  tool name always gets an automatic prefix — `${name}__${tool}` — e.g.
  `formalin__submit_form`, `htmlpaint__clear_canvas`, even when it's the
  only connection present. This is deliberate, not just a collision-avoidance
  fallback: it's what lets a prompt like "use dbhub-local" resolve directly
  to `dbhub_local__query` in a flat `tools/list`, with no `join_channel`/
  `describe_channel` round-trip needed first.
- The name comes from `window.__mcpAppName` (or `document.title` if unset),
  sanitized to `[a-z0-9_]`. Two connections that land on the same name (same
  app, or both unlabeled) get ordinal-suffixed at registration time: the
  first to connect keeps the bare name, the next becomes `name2`, then
  `name3`, etc. — so "use the first htmlpaint tab" maps to `htmlpaint__...`
  tools, and "use the second" maps to `htmlpaint2__...`.
- Calling `describe_tools` always returns a `connections[]` array (`id`,
  `label`, `toolPrefix`, `summary`, `tools[]`), for 0, 1, or many
  connections alike — call it whenever you're not sure which prefix routes
  to which tab.
- Calls are routed to exactly one connection's socket — the other
  tab(s) never see or respond to a call meant for a different one.
- Closing a tab drops its connection and its prefixed tools disappear from
  `tools/list` — any remaining connection keeps its own prefix unchanged
  (names are stable once assigned, never renumbered by another connection
  leaving).

### Validation checklist before calling it done

1. Call `get_embed_snippet`, run the snippet against the target page.
2. Call `tools/list` (or just try the new tool by name) — confirm it appears.
3. Call each new tool with its `example` args, confirm the page visibly
   updates and the result isn't an error.
4. Open a second tenant (call `get_embed_snippet` again from a fresh MCP
   session) and confirm the new tools do **not** appear there — manifests
   are per-tenant, never global.
5. Paste the *same* `get_embed_snippet` snippet into a second browser tab
   (same tenant, deliberately). Call `describe_tools` — confirm it lists
   two connections with distinct labels/prefixes (`tab`/`tab2` if neither
   page set `window.__mcpAppName`/title). Call one of the prefixed tools
   (e.g. `tab__insert_title`) and confirm only that tab updates, not the
   other. Close one tab, call `describe_tools` again, confirm it now
   reports a single connection, still prefixed by its own name.

For the fuller version of this recipe (including how to scaffold a brand-new
MCP server package, "Pattern A" vs "Pattern B") see
`packages/mcp-tenant-lib/BRIDGING.md` and `AGENTS.md`.

## Why this shape

See `packages/mcp-tenant-lib/AGENTS.md`, "Pattern B" section, for the
general recipe this example follows.
