npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

js-bridge-mcp

v0.6.6

Published

Generic bridge: exposes MCP tools discovered from a connected page's own JSON tool manifest, dispatched over a cross-origin WebSocket. Ships a hello-world example page under legacy-page/.

Readme

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

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

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.

Connect an agent

  1. Point an MCP client at http://localhost:8766/mcp.
  2. Call get_embed_snippet. It returns something like:
    import("http://localhost:8766/main.js?server=http%3A%2F%2Flocalhost%3A8766&tenant=<uuid>");
  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 insert_title / insert_main from the MCP client — the open tab updates live, no page refresh needed.

The tenant id embedded in the snippet is this MCP session's own tenant, so repeated get_embed_snippet calls within the same session return the same tenant id — the page stays connected to whichever session generated the snippet it's using.

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:

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:

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:

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):

<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 — flat object only, values are { type, description?, optional? } with type one of "string" / "number" / "boolean". No nested objects or arrays — the server's JSON→zod converter only supports these three primitives and throws a registration error otherwise. Need structured data? Encode it as a JSON string param and JSON.parse inside fn.
  • example — a realistic call, useful both as page-source documentation and as something you should actually try once connected.
  • 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. 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:

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:
    <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).

<!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.

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:

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.

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.