js-bridge-mcp 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,362 @@
1
+ # js-bridge-mcp
2
+
3
+ A generic bridge: MCP tools are discovered at runtime from a connected
4
+ page's own JSON tool manifest, and dispatched to that page's `window.*`
5
+ functions over a cross-origin WebSocket. This package ships a worked
6
+ "hello world" example of `@avo-mcp-tools/mcp-tenant-lib`'s
7
+ **Pattern B**: AI-enabling an existing static page that this stack
8
+ doesn't own or build.
9
+
10
+ Two independent servers, two independent origins:
11
+
12
+ - **`js-bridge-mcp`'s own server** — MCP endpoint (`/mcp`) + WebSocket
13
+ bridge (`/ws`) + one static asset (`/main.js`), on port 8766.
14
+ - **`legacy-page/hello-world.html`** — a plain HTML page with an `<h1>`
15
+ and a `<main>`, no build step, no dependency on this stack. Served by
16
+ any static file server — this example uses `http-server --cors` on
17
+ port 8080.
18
+
19
+ The page and the MCP server never talk to each other directly. The
20
+ *browser* bridges them: a one-line executable JS snippet (generated by the
21
+ `get_embed_snippet` tool) connects straight to `js-bridge-mcp`'s WebSocket,
22
+ cross-origin. The primary way to run it is pasting it directly into the
23
+ target page's DevTools console — no editing the page's source required,
24
+ which is the expected workflow for a developer today. It can also be
25
+ wrapped in a `<script type="module">...</script>` tag and baked into the
26
+ page's HTML if preferred. Once connected, the page pushes its own
27
+ `#mcp-tools` JSON manifest over that socket, and the server registers the
28
+ tools it describes — see `packages/mcp-tenant-lib/BRIDGING.md` for how
29
+ to write one for a new page.
30
+
31
+ ## Run it
32
+
33
+ ```bash
34
+ npm run build # bundle src/client/main.ts -> dist/client/main.js
35
+ npm run start:mcp # MCP + WS + main.js server, port 8766
36
+ npm run start:static # serves legacy-page/, port 8080, in a second terminal
37
+ ```
38
+
39
+ Open `http://localhost:8080/hello-world.html` — it renders as-is, not yet
40
+ connected to anything.
41
+
42
+ ## Connect an agent
43
+
44
+ 1. Point an MCP client at `http://localhost:8766/mcp`.
45
+ 2. Call `get_embed_snippet`. It returns something like:
46
+ ```js
47
+ import("http://localhost:8766/main.js?server=http%3A%2F%2Flocalhost%3A8766&tenant=<uuid>");
48
+ ```
49
+ 3. Open `legacy-page/hello-world.html` in a browser, open DevTools, go to
50
+ the Console tab, paste that line, and press enter. (Alternatively, wrap
51
+ it in a `<script type="module">...</script>` tag and paste it into the
52
+ page's HTML before `</body>` — there's a comment marking the spot — then
53
+ reload.)
54
+ 4. Call `insert_title` / `insert_main` from the MCP client — the open tab
55
+ updates live, no page refresh needed.
56
+
57
+ The tenant id embedded in the snippet is this MCP session's own tenant, so
58
+ repeated `get_embed_snippet` calls within the same session return the same
59
+ tenant id — the page stays connected to whichever session generated the
60
+ snippet it's using.
61
+
62
+ ## Bridge any other project's static HTML to this MCP server
63
+
64
+ `js-bridge-mcp` doesn't care what the page is — `legacy-page/hello-world.html`
65
+ is just a worked example. Any static HTML page (in this repo or a totally
66
+ unrelated project) can become a tenant of an already-running `js-bridge-mcp`
67
+ server by adding two things to its own source, with zero build-step
68
+ dependency on this package. This section is the complete recipe — no need to
69
+ go spelunking in other packages' docs.
70
+
71
+ ### 1. Define `window.__mcpTools` in the page, before the bridge script runs
72
+
73
+ A global array of tool definitions, each holding a **real function
74
+ reference** (not a string name):
75
+
76
+ ```html
77
+ <script>
78
+ function highlightRow({ rowId, color }) {
79
+ const row = document.getElementById(rowId);
80
+ if (!row) throw new Error(`no row with id "${rowId}"`);
81
+ row.style.backgroundColor = color ?? 'yellow';
82
+ return `highlighted ${rowId}`;
83
+ }
84
+
85
+ window.__mcpTools = [
86
+ {
87
+ name: 'highlight_row',
88
+ description: 'Highlights the table row matching the given id. Call list_rows first if you don\'t know valid ids.',
89
+ params: {
90
+ rowId: { type: 'string', description: 'The id attribute of the <tr> to highlight' },
91
+ color: { type: 'string', description: 'CSS color name, defaults to yellow if omitted', optional: true },
92
+ },
93
+ example: { rowId: 'row-42', color: 'yellow' },
94
+ fn: highlightRow,
95
+ },
96
+ ];
97
+ </script>
98
+ ```
99
+
100
+ Schema per entry:
101
+
102
+ - **`name`** — snake_case, unique on the page. What the MCP-connected agent
103
+ sees and calls.
104
+ - **`description`** — written for the *agent*, not a human reader: state what
105
+ it does, preconditions ("call X first"), and side effects. See
106
+ `get_embed_snippet`'s description in `src/tools/hello-tools.ts` for the bar
107
+ to hit.
108
+ - **`params`** — flat object only, values are `{ type, description?, optional? }`
109
+ with `type` one of `"string"` / `"number"` / `"boolean"`. No nested objects
110
+ or arrays — the server's JSON→zod converter only supports these three
111
+ primitives and throws a registration error otherwise. Need structured data?
112
+ Encode it as a JSON string param and `JSON.parse` inside `fn`.
113
+ - **`example`** — a realistic call, useful both as page-source documentation
114
+ and as something you should actually try once connected.
115
+ - **`fn`** — called with a single args object matching `params` (never
116
+ positional args). Its return value, or a thrown `Error`'s message, becomes
117
+ the tool call's result. `fn` never leaves the browser — the bridge strips
118
+ it before talking to the server, which only ever sees
119
+ `name`/`description`/`params`/`example` and dispatches calls back by
120
+ `name` against its local copy of `window.__mcpTools`.
121
+
122
+ Optionally also set `window.__mcpAppName` (a short string, e.g. `"formalin"`
123
+ or `"htmlpaint"`) before the embed snippet runs. It identifies this page/app
124
+ when the *same* `get_embed_snippet` output gets pasted into more than one
125
+ browser tab — see "Multiple tabs on one tenant" below. Falls back to
126
+ `document.title` if omitted, and has no effect at all with a single
127
+ connection.
128
+
129
+ If the page is an ES module build rather than plain script tags, define
130
+ `window.__mcpTools` in whichever module already has the real functions in
131
+ scope — same shape, still a direct function reference, no string lookup.
132
+
133
+ ### 2. Add the embed snippet, after `window.__mcpTools` is defined
134
+
135
+ Get it by calling this server's `get_embed_snippet` MCP tool (from any MCP
136
+ client pointed at `http://localhost:8766/mcp`); it returns one line like:
137
+
138
+ ```js
139
+ import("http://localhost:8766/main.js?server=http%3A%2F%2Flocalhost%3A8766&tenant=<uuid>");
140
+ ```
141
+
142
+ Two ways to run it, both fine:
143
+
144
+ - **Paste into DevTools console** on the already-open target page — no
145
+ source edit at all. This is the default workflow when you (or the agent)
146
+ have the page open in a browser you control.
147
+ - **Bake into the page's HTML**, wrapped in a module script tag, placed
148
+ *after* the `window.__mcpTools` block:
149
+ ```html
150
+ <script type="module">import("http://localhost:8766/main.js?server=...&tenant=...");</script>
151
+ ```
152
+
153
+ Either way, the bridge reads `window.__mcpTools` **once**, synchronously, at
154
+ load/(re)connect time — it does not poll. Edit the tool list, then reload the
155
+ page (and let the bridge reconnect) before the new tools show up.
156
+
157
+ ### Full example: a bare page, wired up end-to-end
158
+
159
+ This is `legacy-page/hello-world.html` in full — copy it as a starting point
160
+ for any project's own static page. The only things that change per-project
161
+ are the functions and tool definitions inside the `<script>` block; the
162
+ embed-snippet line at the bottom is generated fresh per tenant by
163
+ `get_embed_snippet` and pasted in (or run from DevTools instead of baked in).
164
+
165
+ ```html
166
+ <!doctype html>
167
+ <html lang="en">
168
+ <head>
169
+ <meta charset="utf-8" />
170
+ <title>Hello World</title>
171
+ </head>
172
+ <body>
173
+ <h1>Hello, world!</h1>
174
+ <main>Waiting for an agent to say something...</main>
175
+
176
+ <script>
177
+ function insertTitle({ title }) {
178
+ document.title = title;
179
+ document.querySelector('h1').textContent = title;
180
+ return `title set to "${title}"`;
181
+ }
182
+
183
+ function insertMain({ main }) {
184
+ document.querySelector('main').textContent = main;
185
+ return 'main content updated';
186
+ }
187
+
188
+ // window.__mcpTools is the contract the injected bridge script looks
189
+ // for: an array of { name, description, params, example, fn } — real
190
+ // function references, not string lookups. Must be defined before the
191
+ // embed snippet below runs.
192
+ window.__mcpTools = [
193
+ {
194
+ name: 'insert_title',
195
+ description: 'Sets the <h1> title shown on this page.',
196
+ params: { title: { type: 'string', description: 'New page title' } },
197
+ example: { title: 'Welcome, Ada!' },
198
+ fn: insertTitle,
199
+ },
200
+ {
201
+ name: 'insert_main',
202
+ description: 'Sets the <main> body content shown on this page.',
203
+ params: { main: { type: 'string', description: 'New body text' } },
204
+ example: { main: 'Here is your daily summary...' },
205
+ fn: insertMain,
206
+ },
207
+ ];
208
+ </script>
209
+
210
+ <!-- Paste the snippet from get_embed_snippet here, wrapped in a
211
+ <script type="module"> tag — or just run it from DevTools instead. -->
212
+ <script type="module">import("http://localhost:8766/main.js?server=http%3A%2F%2Flocalhost%3A8766&tenant=<uuid>");</script>
213
+ </body>
214
+ </html>
215
+ ```
216
+
217
+ Run this repo's copy of it via `npm run start:static` (see "Run it" above),
218
+ or drop the equivalent markup into any other project's page — nothing here
219
+ depends on this package's build tooling.
220
+
221
+ ### Optional: `run_transient` for one-off computations
222
+
223
+ A page can optionally define one more tool, alongside its regular fixed
224
+ ones, that lets an agent write and immediately run a throwaway JS
225
+ computation for the current session only — see `legacy-page/hello-world.html`
226
+ for the full worked pilot (`runTransient` + its manifest entry). The
227
+ motivating case: a page exposes some data (e.g. a big list of numbers,
228
+ durations, or other values via a `get_*` tool), and the agent needs an
229
+ aggregate over it — sum, average, max/min, a multi-step filter. Doing that
230
+ arithmetic itself, in-context, token by token, gets unreliable as the list
231
+ grows — it produces a plausible-looking wrong number with no error signal.
232
+ Real JS run against the real data is deterministic.
233
+
234
+ ```js
235
+ function runTransient({ code, args }) {
236
+ const parsedArgs = typeof args === 'string' && args ? JSON.parse(args) : undefined;
237
+ // eslint-disable-next-line no-new-func -- deliberate, see hello-world.html for the full rationale
238
+ const fn = new Function('args', 'document', 'window', code);
239
+ const result = fn(parsedArgs, document, window);
240
+ return typeof result === 'string' ? result : JSON.stringify(result);
241
+ }
242
+
243
+ window.__mcpTools.push({
244
+ name: 'run_transient',
245
+ description: '...(see hello-world.html for the bar to hit — must state clearly this is ' +
246
+ 'for large/complex computations only, not a replacement for fixed tools, and that "code" ' +
247
+ 'is a function BODY whose return value becomes the result)',
248
+ params: {
249
+ code: { type: 'string', description: 'JS function body; receives (args, document, window), return value becomes the result' },
250
+ args: { type: 'string', description: 'JSON string passed as `args`; omit if code takes no input', optional: true },
251
+ },
252
+ fn: runTransient,
253
+ });
254
+ ```
255
+
256
+ Deliberately **not** a new registered MCP tool per definition, and
257
+ **not** persisted anywhere (not `localStorage`, not appended to
258
+ `window.__mcpTools`) — each call compiles `code`, runs it once, and
259
+ discards it:
260
+
261
+ - The bridge reads `window.__mcpTools` once at connect and does not poll
262
+ (see "Common mistakes" above) — a page tool array mutated mid-session
263
+ wouldn't reach the *current* session's MCP client without a reconnect
264
+ anyway, so "register a new tool name per definition" doesn't reliably
265
+ work today even where the server-side sync supports it in principle.
266
+ - Persisting agent-authored code across page loads is a materially
267
+ different, larger risk than running it once in the current tab: it turns
268
+ into arbitrary code that runs automatically on every future load with no
269
+ review step. Keep it session-scoped; if a computation turns out to be
270
+ worth reusing, promote it to a normal hand-authored, reviewed tool in the
271
+ page's own source instead of auto-persisting what the agent wrote.
272
+
273
+ **This is still full code execution in the page's own origin** —
274
+ `new Function` is not meaningfully safer than `eval`; session-scoping
275
+ bounds *persistence*, not *capability*. Fine for a page with no
276
+ auth/secrets (like the demo page here). A page carrying real session state,
277
+ cookies, or API access should treat adding this tool as a deliberate,
278
+ visible grant — document it clearly in `window.__mcpSummary` — not a
279
+ default to copy onto every bridged page. The call itself (tool name, the
280
+ `code` string, `args`, and the result) is an ordinary logged MCP
281
+ call/result like any other tool call, so even though the code is
282
+ agent-authored, what actually ran is auditable after the fact from the
283
+ session transcript.
284
+
285
+ ### Common mistakes
286
+
287
+ - Positional args instead of one args object (`fn({ rowId })`, not
288
+ `fn(rowId)`).
289
+ - Defining `window.__mcpTools` *after* the embed snippet already ran.
290
+ - Reusing a `name` across two entries in the **same page**'s own
291
+ `window.__mcpTools` array — this is still a real bug (last one wins).
292
+ Reusing a `name` across two *different* pages/tabs sharing a tenant is
293
+ fine now — see "Multiple tabs on one tenant" below, each gets an
294
+ automatic per-connection prefix.
295
+ - Expecting a live-edited `window.__mcpTools` to take effect without a page
296
+ reload — it's read once per connect, not watched.
297
+ - Pasting the embed snippet into the page **after** your MCP client already
298
+ connected: some clients (Claude Code included, observed against
299
+ `js-bridge-mcp`) fetch `tools/list` once at `initialize` and won't re-poll
300
+ on the server's `tools/list_changed` notification mid-session. New tools
301
+ may need a full MCP client restart to appear, even though the browser
302
+ tenant is connected and the server registered them correctly.
303
+ - Two tabs of the *same* page connected to the same tenant get
304
+ ordinal-suffixed prefixes (`tab__`, `tab2__`, ...) unless
305
+ `window.__mcpAppName`/`document.title` differ between them — call
306
+ `describe_tools` to see current prefixes rather than guessing.
307
+
308
+ ### Multiple tabs on one tenant
309
+
310
+ `get_embed_snippet` returns the same tenant id for the life of an MCP
311
+ session, so pasting that same snippet into more than one browser tab — a
312
+ different app in each tab, or several tabs of the same app — connects all
313
+ of them to the same tenant. This is supported, not just an edge case to
314
+ avoid: it's how one MCP session can drive multiple pages at once (e.g.
315
+ "read form data from tab A, use it to drive tab B").
316
+
317
+ - Each WS connection is tracked separately server-side. As soon as a
318
+ **second** connection registers tools, every registered MCP tool name
319
+ gets an automatic prefix: `${slug}__${name}` — e.g. `formalin__submit_form`,
320
+ `htmlpaint__clear_canvas`. With only one connection, tool names stay
321
+ exactly as they'd be alone (`submit_form`), no prefix.
322
+ - The slug comes from `window.__mcpAppName` (or `document.title` if unset),
323
+ sanitized to `[a-z0-9_]`. Two connections that land on the same slug (same
324
+ app, or both unlabeled) get ordinal-suffixed: the first to connect keeps
325
+ the bare slug, the next becomes `slug2`, then `slug3`, etc. — so "use the
326
+ first htmlpaint tab" maps to the plain `htmlpaint__...` tools, and "use
327
+ the second" maps to `htmlpaint2__...`.
328
+ - Calling `describe_tools` with 2+ connections returns a `connections[]`
329
+ array (`id`, `label`, `toolPrefix`, `summary`, `tools[]`) instead of the
330
+ single-connection flat shape — call it whenever you're not sure which
331
+ prefix routes to which tab.
332
+ - Calls are routed to exactly one connection's socket — the other
333
+ tab(s) never see or respond to a call meant for a different one.
334
+ - Closing a tab drops its connection; if that leaves exactly one connection
335
+ behind, that one's tools become unprefixed again on the next call.
336
+
337
+ ### Validation checklist before calling it done
338
+
339
+ 1. Call `get_embed_snippet`, run the snippet against the target page.
340
+ 2. Call `tools/list` (or just try the new tool by name) — confirm it appears.
341
+ 3. Call each new tool with its `example` args, confirm the page visibly
342
+ updates and the result isn't an error.
343
+ 4. Open a second tenant (call `get_embed_snippet` again from a fresh MCP
344
+ session) and confirm the new tools do **not** appear there — manifests
345
+ are per-tenant, never global.
346
+ 5. Paste the *same* `get_embed_snippet` snippet into a second browser tab
347
+ (same tenant, deliberately). Call `describe_tools` — confirm it lists
348
+ two connections with distinct labels/prefixes (`tab`/`tab2` if neither
349
+ page set `window.__mcpAppName`/title). Call one of the newly prefixed
350
+ tools (e.g. `tab__insert_title`) and confirm only that tab updates, not
351
+ the other. Close one tab, call `describe_tools` again, confirm it now
352
+ reports a single connection and that connection's tools are reachable
353
+ unprefixed again.
354
+
355
+ For the fuller version of this recipe (including how to scaffold a brand-new
356
+ MCP server package, "Pattern A" vs "Pattern B") see
357
+ `packages/mcp-tenant-lib/BRIDGING.md` and `AGENTS.md`.
358
+
359
+ ## Why this shape
360
+
361
+ See `packages/mcp-tenant-lib/AGENTS.md`, "Pattern B" section, for the
362
+ general recipe this example follows.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/src/server.js';
@@ -0,0 +1,69 @@
1
+ function f(e) {
2
+ const t = [], o = /* @__PURE__ */ new Map();
3
+ for (const { fn: c, ...s } of e)
4
+ t.push(s), o.set(s.name, c);
5
+ return { manifest: t, fnByName: o };
6
+ }
7
+ function u(e, t = {}) {
8
+ let o, c = !1, s = 0;
9
+ const p = () => {
10
+ const i = t.tenant ?? (location.pathname.startsWith("/t/") ? location.pathname.slice(3).split("/")[0] : ""), g = i ? `/ws?tenant=${encodeURIComponent(i)}` : "/ws", m = `${t.serverUrl ? t.serverUrl.replace(/^http/, "ws") : `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}`}${g}`;
11
+ console.log(`[mcp-ws] connecting (attempt ${s + 1}): ${m}`), o = new WebSocket(m), o.onopen = () => {
12
+ console.log(`[mcp-ws] connected${s > 0 ? ` after ${s} reconnect attempt(s)` : ""}`), s = 0, e.onConnect?.();
13
+ }, o.onclose = (r) => {
14
+ if (console.log(`[mcp-ws] disconnected: code=${r.code} reason=${r.reason || "(none)"} wasClean=${r.wasClean}`), e.onDisconnect?.(), !c) {
15
+ if (r.code === 4404) {
16
+ console.log("[mcp-ws] tenant unknown/expired (4404) — not retrying");
17
+ return;
18
+ }
19
+ s++, console.log(`[mcp-ws] retrying in 2s (attempt ${s + 1})`), setTimeout(p, 2e3);
20
+ }
21
+ }, o.onerror = () => {
22
+ console.log("[mcp-ws] socket error (see close event for details)");
23
+ }, o.onmessage = (r) => {
24
+ const n = JSON.parse(r.data);
25
+ n.type === "init" && e.onInit?.(n.schema, n.state), n.type === "reinit" && e.onReinit?.(n.schema, n.state), n.type === "update" && e.onUpdate?.(n.field, n.value), n.type === "call" && e.onCall?.(n.id, n.name, n.args);
26
+ };
27
+ };
28
+ return p(), {
29
+ send(i) {
30
+ o?.send(JSON.stringify(i));
31
+ },
32
+ close() {
33
+ c = !0, o?.close();
34
+ }
35
+ };
36
+ }
37
+ const y = window.__mcpTools ?? [], { manifest: h, fnByName: $ } = f(y), _ = window.__mcpSummary ?? void 0;
38
+ let a = window.__mcpAppName ?? document.title ?? void 0, d = !1;
39
+ function C() {
40
+ if (d) return a;
41
+ d = !0;
42
+ const e = prompt("Name this MCP connection (used to identify it to the agent):", a ?? "");
43
+ return e && (a = e), a;
44
+ }
45
+ const w = new URL(import.meta.url), b = w.searchParams.get("server") ?? void 0, S = w.searchParams.get("tenant") ?? void 0, l = u(
46
+ {
47
+ onConnect() {
48
+ console.log("[js-bridge-mcp] connected"), l.send({ type: "register_tools", tools: h, summary: _, appLabel: C() });
49
+ },
50
+ async onCall(e, t, o) {
51
+ try {
52
+ const c = $.get(t);
53
+ if (!c) throw new Error(`no page tool named "${t}" — was it in window.__mcpTools when this script loaded?`);
54
+ const s = await c(o);
55
+ l.send({ type: "call_result", id: e, result: s });
56
+ } catch (c) {
57
+ l.send({ type: "call_result", id: e, error: String(c.message) });
58
+ }
59
+ },
60
+ onDisconnect() {
61
+ console.log("[js-bridge-mcp] disconnected, retrying...");
62
+ }
63
+ },
64
+ { serverUrl: b, tenant: S }
65
+ );
66
+ window.__mcpRename = (e) => {
67
+ const t = e ?? prompt("Rename this MCP connection:", a ?? "");
68
+ t && (a = t, l.send({ type: "rename_connection", appLabel: t }), console.log(`[js-bridge-mcp] renamed connection to "${t}"`));
69
+ };
@@ -0,0 +1,41 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { getOrCreateTenant as getOrCreateTenantFor, tenants, startIdleSweep, createHttpServer, attachWebSocketServer } from 'mcp-tenant-lib';
4
+ import { initialHelloState } from './types.js';
5
+ import { registerHelloTools } from './tools/register.js';
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ // __dirname is <pkg>/src when run via tsx (dev/test) and <pkg>/dist/src once
8
+ // built for publishing.
9
+ const packageRoot = __dirname.endsWith(`${path.sep}dist${path.sep}src`)
10
+ ? path.join(__dirname, '..', '..')
11
+ : path.join(__dirname, '..');
12
+ const PORT = process.env.PORT ? Number(process.env.PORT) : 8766;
13
+ const getOrCreateTenant = (id) => getOrCreateTenantFor(id, undefined, { ...initialHelloState });
14
+ // The 'default' tenant backs direct access with no explicit ?tenant= param.
15
+ getOrCreateTenant('default');
16
+ startIdleSweep((id) => console.error(`[mcp] sweeping idle tenant: ${id}`));
17
+ // Only main.js is ever fetched from here — the legacy page this bundle is
18
+ // injected into is hosted separately (see legacy-page/, run via
19
+ // `npm run start:static`), not by this server.
20
+ const STATIC_DIR = path.join(packageRoot, 'dist', 'client');
21
+ const httpServer = createHttpServer({
22
+ port: PORT,
23
+ staticDir: STATIC_DIR,
24
+ initialSchema: undefined,
25
+ initialValues: initialHelloState,
26
+ identity: { name: 'js-bridge-mcp', version: '0.1.0' },
27
+ registerFn: registerHelloTools,
28
+ // js-bridge-mcp typically bridges a single browser page per server; MCP
29
+ // clients aren't expected to pin ?tenant= themselves (some, like VS Code
30
+ // Copilot, open a fresh MCP session with no ?tenant= on every
31
+ // reconnect/idle DELETE cycle). Sharing the one 'default' tenant keeps
32
+ // every such session pointed at the same already-bridged browser tab
33
+ // instead of each reconnect minting a new, empty tenant.
34
+ defaultTenantMode: 'shared',
35
+ });
36
+ attachWebSocketServer(httpServer, PORT, undefined, initialHelloState);
37
+ httpServer.listen(PORT, () => {
38
+ console.error(`[js-bridge-mcp] MCP + bridge server listening on http://localhost:${PORT}`);
39
+ console.error(`[js-bridge-mcp] serve legacy-page/hello-world.html separately: npm run start:static`);
40
+ });
41
+ export { getOrCreateTenant, tenants, httpServer };
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ const getEmbedSnippet = {
3
+ name: 'get_embed_snippet',
4
+ description: 'Returns a single line of executable JavaScript that connects the current page to this ' +
5
+ 'MCP session\'s tenant. Primary use: paste it directly into the browser\'s DevTools console ' +
6
+ '(Chrome/Firefox/etc.) on the target page and press enter — no editing the page\'s source ' +
7
+ 'required. It can also be wrapped in a <script type="module">...</script> tag if the user ' +
8
+ 'wants to bake it into the page\'s HTML instead (e.g. right before </body>). Either way, once ' +
9
+ 'it runs, the page pushes its own tool manifest (window.__mcpTools, plus an optional ' +
10
+ 'window.__mcpSummary string with shared cross-tool context — see the describe_tools tool once ' +
11
+ 'connected) to this session\'s tenant, and the tools it declares become available to call from ' +
12
+ 'THIS conversation. By default this server shares one tenant across every MCP session that ' +
13
+ 'doesn\'t explicitly pin one (this keeps a single browser connection stable across an agent ' +
14
+ 'harness\'s own session churn) — so unless the server was started with per-session tenants, ' +
15
+ 'pages bridged from different conversations end up on the same tenant and see each other\'s ' +
16
+ 'tools rather than staying isolated. Running the snippet immediately opens a browser prompt() ' +
17
+ 'asking the user to name this connection (pre-filled with the page title) — warn the user ' +
18
+ 'about this pop-up before they paste it so it isn\'t a surprise, and know that ' +
19
+ 'dismissing/cancelling it is safe (falls back to the page title, connection proceeds either ' +
20
+ 'way). The chosen name becomes this connection\'s tool-name prefix once a second connection ' +
21
+ 'joins the same tenant (see describe_tools). Share the returned snippet with the user and tell ' +
22
+ 'them to open DevTools on the target page, go to the Console tab, paste it, and press enter; do ' +
23
+ 'not construct this URL by hand.',
24
+ schema: {},
25
+ handler: async (_args, tenant, port) => {
26
+ const tenantId = tenant().id;
27
+ const serverUrl = `http://localhost:${port}`;
28
+ const moduleUrl = `${serverUrl}/main.js?server=${encodeURIComponent(serverUrl)}&tenant=${tenantId}`;
29
+ const snippet = `import(${JSON.stringify(moduleUrl)});`;
30
+ return { content: [{ type: 'text', text: snippet }] };
31
+ },
32
+ };
33
+ export const helloTools = [getEmbedSnippet];
@@ -0,0 +1,16 @@
1
+ import { createManifestToolRegistry } from 'mcp-tenant-lib';
2
+ import { helloTools } from './hello-tools.js';
3
+ export function registerHelloTools(mcp, tenant, port) {
4
+ for (const tool of helloTools) {
5
+ mcp.tool(tool.name, tool.description, tool.schema, (args) => tool.handler(args, tenant, port));
6
+ }
7
+ const registry = createManifestToolRegistry(mcp, tenant);
8
+ // addManifestToolRegistry syncs immediately and keeps this registry
9
+ // subscribed to future tenant.syncManifestToolRegistries() calls (see
10
+ // tenant.ts) — required under defaultTenantMode: 'shared', where
11
+ // multiple concurrent MCP sessions' McpServer/registry pairs can be
12
+ // bound to the same tenant at once and all need to stay in sync, not
13
+ // just whichever one connected last.
14
+ tenant().addManifestToolRegistry(registry);
15
+ mcp.server.onclose = () => tenant().removeManifestToolRegistry(registry);
16
+ }
@@ -0,0 +1,4 @@
1
+ export const initialHelloState = {
2
+ title: 'Hello, world!',
3
+ main: 'Waiting for an agent to say something...',
4
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "js-bridge-mcp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "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/.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/anatolipr/avo-mcp-tools.git",
9
+ "directory": "packages/js-bridge-mcp"
10
+ },
11
+ "license": "MIT",
12
+ "bin": {
13
+ "js-bridge-mcp": "bin/js-bridge-mcp.js"
14
+ },
15
+ "main": "dist/src/server.js",
16
+ "files": [
17
+ "bin",
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "dev": "npm-run-all --parallel dev:client dev:mcp",
26
+ "dev:client": "vite build --watch",
27
+ "dev:mcp": "tsx watch src/server.ts",
28
+ "build": "tsc -p tsconfig.build.json && vite build",
29
+ "prestart:mcp": "npm run build",
30
+ "start:mcp": "tsx src/server.ts",
31
+ "start:static": "http-server legacy-page --cors -p 8080",
32
+ "prepublishOnly": "npm run build",
33
+ "typecheck": "tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.client.json",
34
+ "stop": "kill -9 $(lsof -ti :${PORT:-8766}) 2>/dev/null && echo 'Server stopped' || echo 'No server running'",
35
+ "test": "node --import tsx --test test/**/*.test.ts"
36
+ },
37
+ "dependencies": {
38
+ "mcp-tenant-lib": "^0.1.0",
39
+ "@modelcontextprotocol/sdk": "^1.12.0",
40
+ "ws": "^8.18.0",
41
+ "zod": "^3.23.8"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^24.0.0",
45
+ "@types/ws": "^8.5.0",
46
+ "http-server": "^14.1.1",
47
+ "npm-run-all": "^4.1.5",
48
+ "tsx": "^4.19.0",
49
+ "typescript": "^5.7.0",
50
+ "vite": "^6.0.0"
51
+ }
52
+ }