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 +362 -0
- package/bin/js-bridge-mcp.js +2 -0
- package/dist/client/main.js +69 -0
- package/dist/src/server.js +41 -0
- package/dist/src/tools/hello-tools.js +33 -0
- package/dist/src/tools/register.js +16 -0
- package/dist/src/types.js +4 -0
- package/package.json +52 -0
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,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
|
+
}
|
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
|
+
}
|