js-bridge-mcp 0.1.9 → 0.2.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 +121 -0
- package/dist/client/connect.js +230 -0
- package/dist/client/main.js +27 -24
- package/dist/src/server.js +45 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -71,6 +71,127 @@ repeated `get_embed_snippet` calls within the same session return the same
|
|
|
71
71
|
tenant id — the page stays connected to whichever session generated the
|
|
72
72
|
snippet it's using.
|
|
73
73
|
|
|
74
|
+
## Auto-connect on page load (no DevTools paste)
|
|
75
|
+
|
|
76
|
+
The manual paste above is the right default for a one-off static page, but a
|
|
77
|
+
real app with its own build (Vite/webpack/etc) that wants to stay connected
|
|
78
|
+
across every reload doesn't need a human to paste anything, ever. Instead of
|
|
79
|
+
a session-minted tenant UUID, the app connects itself on boot using a fixed,
|
|
80
|
+
human-readable **channel** name — `js-bridge-mcp`'s channel support
|
|
81
|
+
(`mcp-tenant-lib` 0.3.3+) treats a channel name as the same string-keyed
|
|
82
|
+
tenant id `main.js` already accepts via its `tenant` query param, so any MCP
|
|
83
|
+
client can attach to the exact same live connection later via
|
|
84
|
+
`join_channel("<channel-name>")`, with zero interaction on the page side.
|
|
85
|
+
|
|
86
|
+
There's also a packaged skill for this exact recipe:
|
|
87
|
+
`.agents/skills/js-bridge-mcp-auto-connect-button/SKILL.md` — load it before
|
|
88
|
+
implementing so you don't reinvent the wiring below from scratch.
|
|
89
|
+
|
|
90
|
+
**Requires `window.__mcpTools` to already be defined by the page** (see
|
|
91
|
+
above) — this only handles the *connection*, not the tool contract.
|
|
92
|
+
|
|
93
|
+
The connect lifecycle itself (probe, connect, rename, leave-old-channel-on-
|
|
94
|
+
switch) is shared infrastructure, served by this package the same way
|
|
95
|
+
`tool-bus.js` is: `src/client/connect.js`, importable by URL at
|
|
96
|
+
`<server>/connect.js`, exporting one factory:
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import { createMcpConnect } from 'http://localhost:8766/connect.js';
|
|
100
|
+
|
|
101
|
+
const connect = createMcpConnect({ appName: 'myapp' }); // localStorage key + default channel + tool-name label
|
|
102
|
+
connect.init(); // connects on page load
|
|
103
|
+
connect.handleConnectClick(); // wire to a toolbar button
|
|
104
|
+
connect.onConnectionStateChange((state, channel, appLabel) => { /* render a status indicator */ });
|
|
105
|
+
connect.getConnectionState(); // { state, channel, appLabel } - synchronous
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`createMcpConnect` also accepts `defaultChannel` (defaults to `appName`) and
|
|
109
|
+
`beforeConnect` (an optional async hook run once, before the first
|
|
110
|
+
`main.js` import — for a host page that layers extra tool providers onto
|
|
111
|
+
`window.__mcpTools` first, e.g. via `tool-bus.js`; see bulletino-1's
|
|
112
|
+
`mcp-connect.mjs` for a worked example).
|
|
113
|
+
|
|
114
|
+
If the host page's bundler doesn't support top-level await at its configured
|
|
115
|
+
build target (common with Vite's default target), `connect.js` still has to
|
|
116
|
+
be reached via a dynamic `import()` rather than a static one — wrap it in a
|
|
117
|
+
small synchronous stub that starts `'disconnected'` and swaps in the real
|
|
118
|
+
instance once the import resolves, so a UI component that reads
|
|
119
|
+
`getConnectionState()` synchronously at its own module-eval time still
|
|
120
|
+
works. See htmlpaint.com's or mindfoo's `mcp-connect.js`/`.ts` for the
|
|
121
|
+
pattern (native ESM pages with no bundler, like bulletino-1's
|
|
122
|
+
`mcp-connect.mjs`, can just top-level-`await` it directly).
|
|
123
|
+
|
|
124
|
+
### Channel:app-name — joining a shared channel
|
|
125
|
+
|
|
126
|
+
The prompt `handleConnectClick()` shows (and the one in this recipe's own
|
|
127
|
+
UI wiring below) accepts either a bare channel name (`"bug123"`) or
|
|
128
|
+
`"channel:app-name"` (`"bug123:htmlpaint"`) — the part after the colon sets
|
|
129
|
+
`window.__mcpAppName` explicitly, independent of the channel itself. This is
|
|
130
|
+
how several different apps deliberately join the **same** channel (like
|
|
131
|
+
inviting several people into one Slack channel) while each keeps its own
|
|
132
|
+
readable tool-name prefix instead of colliding on the channel name as its
|
|
133
|
+
label: type `bug123:htmlpaint` in one tab and `bug123:bulletino` in another,
|
|
134
|
+
and both land on tenant `bug123` with tools prefixed `htmlpaint__...` /
|
|
135
|
+
`bulletino__...` — see "Multiple tabs on one tenant" below for how that
|
|
136
|
+
prefixing works. Omitting `:app-name` keeps the app's own default label.
|
|
137
|
+
|
|
138
|
+
### Orphaned channels get cleaned up automatically
|
|
139
|
+
|
|
140
|
+
Two independent mechanisms, so switching channels (or just closing a tab)
|
|
141
|
+
doesn't leave a dead tenant sitting around for the 2-hour general idle sweep
|
|
142
|
+
to eventually notice:
|
|
143
|
+
|
|
144
|
+
- **Explicit switch**: when `connect.js` reconnects a tab from channel A to
|
|
145
|
+
channel B (via `handleConnectClick` or a fresh `init()`), it sends a
|
|
146
|
+
`leave_channel` message on A's socket before opening the new one on B. The
|
|
147
|
+
server drops A's tenant immediately if that was its last connection — a
|
|
148
|
+
no-op if other tabs/apps are still on A.
|
|
149
|
+
- **Tab closed / crashed**: the server can't distinguish a genuine tab close
|
|
150
|
+
from a brief network drop — both look like the same WebSocket `close`
|
|
151
|
+
event. So instead it tracks how long a tenant has had *zero* connections
|
|
152
|
+
and disposes it once that exceeds `TENANT_EMPTY_TIMEOUT_MS` (default
|
|
153
|
+
15s, separate from and much shorter than `TENANT_IDLE_TIMEOUT_MS`'s 2-hour
|
|
154
|
+
default) — comfortably above the client's ~2s reconnect retry, so a reload
|
|
155
|
+
or brief blip never trips it, but an actually-closed tab is gone within
|
|
156
|
+
seconds rather than hours.
|
|
157
|
+
|
|
158
|
+
Add a thin connector module, e.g. `src/mcp-connect.ts`, wrapping the shared
|
|
159
|
+
factory shown above:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { createMcpConnect } from 'http://localhost:8766/connect.js';
|
|
163
|
+
|
|
164
|
+
export const connect = createMcpConnect({ appName: 'myapp' });
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
(If your bundler can't top-level-`await` a dynamic import at its configured
|
|
168
|
+
build target, wrap this in the synchronous-stub pattern described above
|
|
169
|
+
instead of a bare re-export — see htmlpaint.com's/mindfoo's `mcp-connect`
|
|
170
|
+
files for the full worked version.)
|
|
171
|
+
|
|
172
|
+
Wire it into the app's entry point, **after** `window.__mcpTools` is set:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import './mcpbridge'; // sets window.__mcpTools
|
|
176
|
+
import { connect } from './mcp-connect';
|
|
177
|
+
|
|
178
|
+
connect.init(); // no dev-mode gate - JSBRIDGE_HOST is always localhost
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
And a status button somewhere in the toolbar, bound to
|
|
182
|
+
`connect.onConnectionStateChange` and `connect.handleConnectClick`:
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
⚪ myapp -- disconnected, click to connect
|
|
186
|
+
🟡 connecting… -- probing/importing
|
|
187
|
+
🟢 myapp -- connected on channel "myapp", click to rename (or "channel:app-name" to join a shared channel)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Any MCP client can now reach this page's tools without ever touching
|
|
191
|
+
DevTools: `join_channel("myapp")`, then call tools by name (or their
|
|
192
|
+
prefixed form if more than one connection shares the channel — see
|
|
193
|
+
"Multiple tabs on one tenant" below).
|
|
194
|
+
|
|
74
195
|
## Bridge any other project's static HTML to this MCP server
|
|
75
196
|
|
|
76
197
|
`js-bridge-mcp` doesn't care what the page is — `legacy-page/hello-world.html`
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Shared connect-lifecycle module for any page auto-connecting to a locally
|
|
2
|
+
// running js-bridge-mcp server via a named CHANNEL (not a session-minted
|
|
3
|
+
// tenant UUID) - see packages/js-bridge-mcp/README.md's "Auto-connect on
|
|
4
|
+
// page load" section for the full design rationale.
|
|
5
|
+
//
|
|
6
|
+
// This used to be copy-pasted per host app (mindfoo/src/mcp-connect.ts,
|
|
7
|
+
// bulletino-1/mcp-connect.mjs, htmlpaint.com/src/mcp-connect.js - all three
|
|
8
|
+
// nearly byte-identical). Hoisted here, alongside tool-bus.js, as another
|
|
9
|
+
// hand-written vanilla ES module any host page can import by URL:
|
|
10
|
+
// <script type="module" src="http://<js-bridge-mcp host>/connect.js"></script>
|
|
11
|
+
// Deliberately NOT part of main.ts's build (see vite.config.ts's
|
|
12
|
+
// copy-client-extras plugin), same reasoning as tool-bus.js: this is
|
|
13
|
+
// infrastructure shared across host apps/origins, not app-specific code
|
|
14
|
+
// bundled with the bridge itself.
|
|
15
|
+
//
|
|
16
|
+
// Usage from a host app's own thin per-app module:
|
|
17
|
+
//
|
|
18
|
+
// import { createMcpConnect } from 'http://localhost:8766/connect.js';
|
|
19
|
+
// export const mcpConnect = createMcpConnect({ appName: 'htmlpaint' });
|
|
20
|
+
// mcpConnect.init();
|
|
21
|
+
//
|
|
22
|
+
// Channel identity: js-bridge-mcp's channel support (mcp-tenant-lib 0.3.3+)
|
|
23
|
+
// makes a channel name the same string-keyed tenant id main.js accepts via
|
|
24
|
+
// the `tenant` query param - so a host app can connect with a fixed,
|
|
25
|
+
// human-readable name with zero interaction, and any MCP client can attach
|
|
26
|
+
// to the exact same live connection via join_channel("<name>").
|
|
27
|
+
//
|
|
28
|
+
// Channel:app-name syntax: a chosen channel may be typed as "channel:app" -
|
|
29
|
+
// e.g. "bug123:htmlpaint" - to explicitly set BOTH the shared channel
|
|
30
|
+
// (join_channel target) and this connection's own app label/tool-name
|
|
31
|
+
// prefix in one prompt, letting several different apps deliberately join
|
|
32
|
+
// the same channel (like inviting several people into one Slack channel)
|
|
33
|
+
// while each still gets a distinct, readable tool prefix instead of
|
|
34
|
+
// colliding on "channel" as its own label. Omitting the ":app" part keeps
|
|
35
|
+
// the app's own default label.
|
|
36
|
+
|
|
37
|
+
// js-bridge-mcp has no production deployment - it only ever runs locally,
|
|
38
|
+
// launched via `npx` (see packages/js-bridge-mcp), so this always targets
|
|
39
|
+
// localhost regardless of where the host app is served from.
|
|
40
|
+
const JSBRIDGE_HOST = 'http://localhost:8766';
|
|
41
|
+
|
|
42
|
+
// Must match js-bridge-mcp's own isValidChannelName (mcp-tenant-lib/src/tenant.ts)
|
|
43
|
+
// exactly - channel names become the WS `?tenant=` query param, and the
|
|
44
|
+
// server rejects anything outside this set with a 4404 close before a
|
|
45
|
+
// Tenant is ever created.
|
|
46
|
+
const VALID_CHANNEL_NAME = /^[a-zA-Z0-9_-]+$/;
|
|
47
|
+
|
|
48
|
+
function sanitizeToValidChannelName(raw) {
|
|
49
|
+
return raw.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Splits a user-typed "channel" or "channel:app" string into its parts.
|
|
54
|
+
* A bare name (no colon) is just the channel, with no app-label override.
|
|
55
|
+
*/
|
|
56
|
+
function parseChannelInput(input) {
|
|
57
|
+
const idx = input.indexOf(':');
|
|
58
|
+
if (idx === -1) return { channel: input, appLabel: undefined };
|
|
59
|
+
const channel = input.slice(0, idx).trim();
|
|
60
|
+
const appLabel = input.slice(idx + 1).trim();
|
|
61
|
+
return { channel, appLabel: appLabel || undefined };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {object} opts
|
|
66
|
+
* @param {string} opts.appName - Short app-specific identifier, e.g.
|
|
67
|
+
* "htmlpaint", "bulletino", "mindfoo". Used as: the localStorage key
|
|
68
|
+
* namespace, the default channel name, and (unless a "channel:app" prompt
|
|
69
|
+
* input overrides it) the connection's window.__mcpAppName label.
|
|
70
|
+
* @param {string} [opts.defaultChannel] - Defaults to opts.appName.
|
|
71
|
+
* @param {(state: 'disconnected'|'connecting'|'connected', channel: string, appLabel: string) => void} [opts.onStateChange]
|
|
72
|
+
* Optional convenience callback, called on every state transition - an
|
|
73
|
+
* alternative to onConnectionStateChange() below for a caller that just
|
|
74
|
+
* wants one function rather than subscribing.
|
|
75
|
+
* @param {() => Promise<void>|void} [opts.beforeConnect]
|
|
76
|
+
* Optional hook run once, before the very first main.js import - for a
|
|
77
|
+
* host page that layers extra tool providers onto window.__mcpTools ahead
|
|
78
|
+
* of connecting (e.g. bulletino-1 loading tool-bus.js + folderfoo's
|
|
79
|
+
* provider). Not re-run on a later channel switch/rename - main.js reads
|
|
80
|
+
* window.__mcpTools fresh on every import, so whatever this hook set up
|
|
81
|
+
* the first time is still in place for subsequent connects.
|
|
82
|
+
*/
|
|
83
|
+
export function createMcpConnect(opts) {
|
|
84
|
+
const appName = opts.appName;
|
|
85
|
+
const defaultChannel = opts.defaultChannel ?? appName;
|
|
86
|
+
const CHANNEL_STORAGE_KEY = `${appName}_mcp_channel`;
|
|
87
|
+
const APP_LABEL_STORAGE_KEY = `${appName}_mcp_app_label`;
|
|
88
|
+
|
|
89
|
+
function getStored(key, fallback) {
|
|
90
|
+
try {
|
|
91
|
+
return localStorage.getItem(key) || fallback;
|
|
92
|
+
} catch {
|
|
93
|
+
return fallback;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function setStored(key, value) {
|
|
98
|
+
try {
|
|
99
|
+
localStorage.setItem(key, value);
|
|
100
|
+
} catch {
|
|
101
|
+
// ignore - falls back to the default next load
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let state = 'disconnected'; // 'disconnected' | 'connecting' | 'connected'
|
|
106
|
+
let currentChannel = getStored(CHANNEL_STORAGE_KEY, defaultChannel);
|
|
107
|
+
let currentAppLabel = getStored(APP_LABEL_STORAGE_KEY, appName);
|
|
108
|
+
// The live socket's own leave() - set each time connectToChannel opens a
|
|
109
|
+
// new one, so a later switch can tell the OLD socket to leave_channel
|
|
110
|
+
// before this module opens the new one. undefined until the first
|
|
111
|
+
// successful import() below.
|
|
112
|
+
let leaveCurrentSocket;
|
|
113
|
+
let beforeConnectRan = false;
|
|
114
|
+
const stateListeners = new Set();
|
|
115
|
+
|
|
116
|
+
function setState(next) {
|
|
117
|
+
state = next;
|
|
118
|
+
for (const cb of stateListeners) cb(state, currentChannel, currentAppLabel);
|
|
119
|
+
opts.onStateChange?.(state, currentChannel, currentAppLabel);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Lightweight reachability probe via plain HTTP - main.js's own
|
|
123
|
+
// connectStateSocket doesn't expose connect/disconnect events to the
|
|
124
|
+
// importer, so this is the only way to know "is js-bridge-mcp up" before
|
|
125
|
+
// (and independent of) actually importing main.js.
|
|
126
|
+
async function probeJsBridgeMcp() {
|
|
127
|
+
try {
|
|
128
|
+
const res = await fetch(`${JSBRIDGE_HOST}/main.js`, { method: 'HEAD' });
|
|
129
|
+
return res.ok;
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function connectToChannel(channelName, appLabel) {
|
|
136
|
+
// Tell whichever channel we were previously on that we're leaving it
|
|
137
|
+
// BEFORE opening the new socket, so the server can drop that tenant the
|
|
138
|
+
// moment it's empty rather than only after this tab's old socket times
|
|
139
|
+
// out - see leave_channel's own doc comment in mcp-tenant-lib/types.ts.
|
|
140
|
+
// Safe to call unconditionally: a no-op if there's no prior socket, or
|
|
141
|
+
// if other connections remain on that channel.
|
|
142
|
+
leaveCurrentSocket?.();
|
|
143
|
+
leaveCurrentSocket = undefined;
|
|
144
|
+
|
|
145
|
+
setState('connecting');
|
|
146
|
+
currentChannel = channelName;
|
|
147
|
+
currentAppLabel = appLabel ?? appName;
|
|
148
|
+
window.__mcpAppName = currentAppLabel;
|
|
149
|
+
setStored(CHANNEL_STORAGE_KEY, currentChannel);
|
|
150
|
+
setStored(APP_LABEL_STORAGE_KEY, currentAppLabel);
|
|
151
|
+
|
|
152
|
+
const reachable = await probeJsBridgeMcp();
|
|
153
|
+
if (!reachable) {
|
|
154
|
+
setState('disconnected');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!beforeConnectRan) {
|
|
159
|
+
beforeConnectRan = true;
|
|
160
|
+
await opts.beforeConnect?.();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A fresh import (unique URL per channel/tenant, since main.js reads
|
|
164
|
+
// `tenant` once at module-eval time and exposes no way to retarget an
|
|
165
|
+
// existing connection) - main.js has no export, so this is fire-and-
|
|
166
|
+
// forget; connect/disconnect status past this point is inferred from
|
|
167
|
+
// the probe above plus the module having loaded without throwing.
|
|
168
|
+
try {
|
|
169
|
+
const mod = await import(
|
|
170
|
+
/* @vite-ignore */ `${JSBRIDGE_HOST}/main.js?server=${encodeURIComponent(JSBRIDGE_HOST)}&tenant=${encodeURIComponent(channelName)}&_=${Date.now()}`
|
|
171
|
+
);
|
|
172
|
+
// main.js exposes __mcpLeaveChannel (see main.ts) as a best-effort
|
|
173
|
+
// hook for exactly this - a module-scoped function, not a return
|
|
174
|
+
// value, since main.js has no exports of its own (see its own
|
|
175
|
+
// comment) and is imported purely for its side effects.
|
|
176
|
+
leaveCurrentSocket = typeof window.__mcpLeaveChannel === 'function' ? window.__mcpLeaveChannel : undefined;
|
|
177
|
+
setState('connected');
|
|
178
|
+
} catch {
|
|
179
|
+
setState('disconnected');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Click behavior: connect (or reconnect) if not connected; if already
|
|
185
|
+
* connected, prompt to rename - so a user with multiple tabs open can
|
|
186
|
+
* name each one on purpose instead of ending up with an unlabeled
|
|
187
|
+
* auto-suffixed channel they can't identify later. Accepts a bare channel
|
|
188
|
+
* name ("bug123") or "channel:app-name" ("bug123:htmlpaint") to join a
|
|
189
|
+
* shared channel under an explicit app label distinct from the channel
|
|
190
|
+
* name itself - lets several different apps deliberately land on the same
|
|
191
|
+
* channel (like inviting several people into one Slack channel) while
|
|
192
|
+
* keeping each one's tools under its own readable prefix.
|
|
193
|
+
*/
|
|
194
|
+
async function handleConnectClick() {
|
|
195
|
+
const promptCurrent = currentAppLabel === appName ? currentChannel : `${currentChannel}:${currentAppLabel}`;
|
|
196
|
+
if (state === 'connected') {
|
|
197
|
+
let next = prompt('Name this connection (channel, or channel:app-name to share a channel):', promptCurrent);
|
|
198
|
+
if (!next || next === promptCurrent) return;
|
|
199
|
+
let parsed = parseChannelInput(next);
|
|
200
|
+
while (parsed.channel && !VALID_CHANNEL_NAME.test(parsed.channel)) {
|
|
201
|
+
next = prompt(
|
|
202
|
+
`"${parsed.channel}" isn't a valid channel name - only letters, digits, underscore, and hyphen are allowed (no spaces). Try again:`,
|
|
203
|
+
`${sanitizeToValidChannelName(parsed.channel)}${parsed.appLabel ? `:${parsed.appLabel}` : ''}`
|
|
204
|
+
);
|
|
205
|
+
if (!next) return;
|
|
206
|
+
parsed = parseChannelInput(next);
|
|
207
|
+
}
|
|
208
|
+
if (!parsed.channel) return;
|
|
209
|
+
await connectToChannel(parsed.channel, parsed.appLabel);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
await connectToChannel(currentChannel, currentAppLabel);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Connects automatically on page load - no button click required. */
|
|
216
|
+
async function init() {
|
|
217
|
+
await connectToChannel(currentChannel, currentAppLabel);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function onConnectionStateChange(cb) {
|
|
221
|
+
stateListeners.add(cb);
|
|
222
|
+
return () => stateListeners.delete(cb);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function getConnectionState() {
|
|
226
|
+
return { state, channel: currentChannel, appLabel: currentAppLabel };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { init, handleConnectClick, onConnectionStateChange, getConnectionState };
|
|
230
|
+
}
|
package/dist/client/main.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
function _(e) {
|
|
2
|
-
const
|
|
2
|
+
const t = [], o = /* @__PURE__ */ new Map();
|
|
3
3
|
for (const { fn: c, ...s } of e)
|
|
4
|
-
|
|
5
|
-
return { manifest:
|
|
4
|
+
t.push(s), o.set(s.name, c);
|
|
5
|
+
return { manifest: t, fnByName: o };
|
|
6
6
|
}
|
|
7
7
|
const w = 1e4, f = 3600 * 1e3;
|
|
8
|
-
function
|
|
8
|
+
function h(e, t = {}) {
|
|
9
9
|
let o, c = !1, s = 0, p;
|
|
10
10
|
const m = () => {
|
|
11
|
-
const
|
|
11
|
+
const l = t.tenant ?? (location.pathname.startsWith("/t/") ? location.pathname.slice(3).split("/")[0] : ""), y = l ? `/ws?tenant=${encodeURIComponent(l)}` : "/ws", d = `${t.serverUrl ? t.serverUrl.replace(/^http/, "ws") : `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}`}${y}`;
|
|
12
12
|
console.log(`[mcp-ws] connecting (attempt ${s + 1}): ${d}`), o = new WebSocket(d), o.onopen = () => {
|
|
13
13
|
console.log(`[mcp-ws] connected${s > 0 ? ` after ${s} reconnect attempt(s)` : ""}`), s = 0, p = void 0, e.onConnect?.();
|
|
14
|
-
}, o.onclose = (
|
|
15
|
-
if (console.log(`[mcp-ws] disconnected: code=${
|
|
16
|
-
if (
|
|
14
|
+
}, o.onclose = (a) => {
|
|
15
|
+
if (console.log(`[mcp-ws] disconnected: code=${a.code} reason=${a.reason || "(none)"} wasClean=${a.wasClean}`), e.onDisconnect?.(), !c) {
|
|
16
|
+
if (a.code === 4404) {
|
|
17
17
|
console.log("[mcp-ws] invalid tenant id (4404) — not retrying");
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
@@ -25,41 +25,41 @@ function $(e, n = {}) {
|
|
|
25
25
|
}
|
|
26
26
|
}, o.onerror = () => {
|
|
27
27
|
console.log("[mcp-ws] socket error (see close event for details)");
|
|
28
|
-
}, o.onmessage = (
|
|
29
|
-
const
|
|
30
|
-
|
|
28
|
+
}, o.onmessage = (a) => {
|
|
29
|
+
const n = JSON.parse(a.data);
|
|
30
|
+
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), n.type === "identify" && (e.onIdentify ? e.onIdentify(n.label) : alert(`Identify: this is the "${n.label ?? "unlabeled"}" connection`));
|
|
31
31
|
};
|
|
32
32
|
};
|
|
33
33
|
return m(), {
|
|
34
|
-
send(
|
|
35
|
-
o?.send(JSON.stringify(
|
|
34
|
+
send(l) {
|
|
35
|
+
o?.send(JSON.stringify(l));
|
|
36
36
|
},
|
|
37
37
|
close() {
|
|
38
38
|
c = !0, o?.close();
|
|
39
39
|
}
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
-
const
|
|
42
|
+
const $ = window.__mcpTools ?? [], { manifest: C, fnByName: b } = _($), v = window.__mcpSummary ?? void 0, N = typeof window.__mcpAppName == "string" && window.__mcpAppName;
|
|
43
43
|
let i = window.__mcpAppName ?? document.title ?? void 0, g = !1;
|
|
44
44
|
function S() {
|
|
45
|
-
if (g || (g = !0,
|
|
45
|
+
if (g || (g = !0, N)) return i;
|
|
46
46
|
const e = prompt("Name this MCP connection (used to identify it to the agent):", i ?? "");
|
|
47
47
|
return e && (i = e), i;
|
|
48
48
|
}
|
|
49
|
-
const u = new URL(import.meta.url), R = u.searchParams.get("server") ?? void 0, U = u.searchParams.get("tenant") ?? void 0,
|
|
49
|
+
const u = new URL(import.meta.url), R = u.searchParams.get("server") ?? void 0, U = u.searchParams.get("tenant") ?? void 0, r = h(
|
|
50
50
|
{
|
|
51
51
|
onConnect() {
|
|
52
52
|
const e = S();
|
|
53
|
-
console.log(`[js-bridge-mcp] connected as "${e ?? "(unlabeled)"}"`),
|
|
53
|
+
console.log(`[js-bridge-mcp] connected as "${e ?? "(unlabeled)"}"`), r.send({ type: "register_tools", tools: C, summary: v, appLabel: e });
|
|
54
54
|
},
|
|
55
|
-
async onCall(e,
|
|
55
|
+
async onCall(e, t, o) {
|
|
56
56
|
try {
|
|
57
|
-
const c =
|
|
58
|
-
if (!c) throw new Error(`no page tool named "${
|
|
57
|
+
const c = b.get(t);
|
|
58
|
+
if (!c) throw new Error(`no page tool named "${t}" — was it in window.__mcpTools when this script loaded?`);
|
|
59
59
|
const s = await c(o);
|
|
60
|
-
|
|
60
|
+
r.send({ type: "call_result", id: e, result: s });
|
|
61
61
|
} catch (c) {
|
|
62
|
-
|
|
62
|
+
r.send({ type: "call_result", id: e, error: String(c.message) });
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
onDisconnect() {
|
|
@@ -69,6 +69,9 @@ const u = new URL(import.meta.url), R = u.searchParams.get("server") ?? void 0,
|
|
|
69
69
|
{ serverUrl: R, tenant: U }
|
|
70
70
|
);
|
|
71
71
|
window.__mcpRename = (e) => {
|
|
72
|
-
const
|
|
73
|
-
|
|
72
|
+
const t = e ?? prompt("Rename this MCP connection:", i ?? "");
|
|
73
|
+
t && (i = t, r.send({ type: "rename_connection", appLabel: t }), console.log(`[js-bridge-mcp] renamed connection to "${t}"`));
|
|
74
|
+
};
|
|
75
|
+
window.__mcpLeaveChannel = () => {
|
|
76
|
+
r.send({ type: "leave_channel" });
|
|
74
77
|
};
|
package/dist/src/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import {
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import { getOrCreateTenant as getOrCreateTenantFor, tenants, startIdleSweep, startEmptySweep, createHttpServer, attachWebSocketServer } from 'mcp-tenant-lib';
|
|
4
5
|
import { initialHelloState } from './types.js';
|
|
5
6
|
import { registerHelloTools } from './tools/register.js';
|
|
6
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -10,10 +11,42 @@ const packageRoot = __dirname.endsWith(`${path.sep}dist${path.sep}src`)
|
|
|
10
11
|
? path.join(__dirname, '..', '..')
|
|
11
12
|
: path.join(__dirname, '..');
|
|
12
13
|
const PORT = process.env.PORT ? Number(process.env.PORT) : 8766;
|
|
14
|
+
// A stale js-bridge-mcp instance (e.g. left running from a previous editor
|
|
15
|
+
// session/reload) is the overwhelmingly common occupant of this port, and
|
|
16
|
+
// leaving it running would just make this new instance fail to bind. Free
|
|
17
|
+
// the port unconditionally before listening, macOS/Linux only.
|
|
18
|
+
function killWhateverIsOnPort(port) {
|
|
19
|
+
let pids;
|
|
20
|
+
try {
|
|
21
|
+
pids = execSync(`lsof -ti tcp:${port}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return; // lsof exits non-zero when nothing is listening on the port
|
|
25
|
+
}
|
|
26
|
+
if (!pids)
|
|
27
|
+
return;
|
|
28
|
+
for (const pid of pids.split('\n')) {
|
|
29
|
+
if (pid === String(process.pid))
|
|
30
|
+
continue;
|
|
31
|
+
try {
|
|
32
|
+
process.kill(Number(pid), 'SIGKILL');
|
|
33
|
+
console.error(`[js-bridge-mcp] killed process ${pid} that was on port ${port}`);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// already gone
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
killWhateverIsOnPort(PORT);
|
|
13
41
|
const getOrCreateTenant = (id) => getOrCreateTenantFor(id, undefined, { ...initialHelloState });
|
|
14
42
|
// The 'default' tenant backs direct access with no explicit ?tenant= param.
|
|
15
43
|
getOrCreateTenant('default');
|
|
16
44
|
startIdleSweep((id) => console.error(`[mcp] sweeping idle tenant: ${id}`));
|
|
45
|
+
// Separate, much shorter sweep for channels with zero live connections
|
|
46
|
+
// (tab closed, or the page deliberately left via leave_channel when
|
|
47
|
+
// switching channels) - see startEmptySweep's own doc comment for why this
|
|
48
|
+
// is a distinct mechanism from the idle sweep above.
|
|
49
|
+
startEmptySweep((id) => console.error(`[mcp] sweeping empty channel: ${id}`));
|
|
17
50
|
// main.js is fetched from here by absolute path (get_embed_snippet always
|
|
18
51
|
// embeds "<server>/main.js", see hello-tools.ts) regardless of what's
|
|
19
52
|
// mounted at "/" — the legacy page it's injected into is hosted separately
|
|
@@ -30,7 +63,17 @@ const httpServer = createHttpServer({
|
|
|
30
63
|
initialValues: initialHelloState,
|
|
31
64
|
identity: { name: 'js-bridge-mcp', version: '0.1.0' },
|
|
32
65
|
registerFn: registerHelloTools,
|
|
33
|
-
|
|
66
|
+
// Each of these is a fixed-URL asset other pages import cross-origin by
|
|
67
|
+
// absolute path (main.js via get_embed_snippet; tool-bus.js/connect.js
|
|
68
|
+
// per their own header comments: shared infrastructure any host app
|
|
69
|
+
// imports directly, e.g. "<server>/connect.js") - single-file mounts, not
|
|
70
|
+
// a whole-directory one, so dist/client can't accidentally serve
|
|
71
|
+
// anything else placed there later.
|
|
72
|
+
extraStaticMounts: {
|
|
73
|
+
'/main.js': CLIENT_DIR,
|
|
74
|
+
'/tool-bus.js': CLIENT_DIR,
|
|
75
|
+
'/connect.js': CLIENT_DIR,
|
|
76
|
+
},
|
|
34
77
|
// js-bridge-mcp typically bridges a single browser page per server; MCP
|
|
35
78
|
// clients aren't expected to pin ?tenant= themselves (some, like VS Code
|
|
36
79
|
// Copilot, open a fresh MCP session with no ?tenant= on every
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "js-bridge-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
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
6
|
"repository": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"build": "tsc -p tsconfig.build.json && vite build && vite build --config vite.dashboard.config.ts",
|
|
30
30
|
"prestart:mcp": "npm run build",
|
|
31
31
|
"start:mcp": "tsx src/server.ts",
|
|
32
|
+
"start": "npm run start:mcp",
|
|
32
33
|
"start:static": "http-server legacy-page --cors -p 8080",
|
|
33
34
|
"prepublishOnly": "npm run build",
|
|
34
35
|
"typecheck": "tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.client.json",
|
|
@@ -36,7 +37,7 @@
|
|
|
36
37
|
"test": "node --import tsx --test test/**/*.test.ts"
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
|
-
"mcp-tenant-lib": "^0.
|
|
40
|
+
"mcp-tenant-lib": "^0.4.0",
|
|
40
41
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
41
42
|
"avosignals": "^1.0.16",
|
|
42
43
|
"lit": "^3.3.3",
|