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 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
+ }
@@ -1,19 +1,19 @@
1
1
  function _(e) {
2
- const n = [], o = /* @__PURE__ */ new Map();
2
+ const t = [], o = /* @__PURE__ */ new Map();
3
3
  for (const { fn: c, ...s } of e)
4
- n.push(s), o.set(s.name, c);
5
- return { manifest: n, fnByName: o };
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 $(e, n = {}) {
8
+ function h(e, t = {}) {
9
9
  let o, c = !1, s = 0, p;
10
10
  const m = () => {
11
- const a = n.tenant ?? (location.pathname.startsWith("/t/") ? location.pathname.slice(3).split("/")[0] : ""), y = a ? `/ws?tenant=${encodeURIComponent(a)}` : "/ws", d = `${n.serverUrl ? n.serverUrl.replace(/^http/, "ws") : `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}`}${y}`;
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 = (r) => {
15
- if (console.log(`[mcp-ws] disconnected: code=${r.code} reason=${r.reason || "(none)"} wasClean=${r.wasClean}`), e.onDisconnect?.(), !c) {
16
- if (r.code === 4404) {
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 = (r) => {
29
- const t = JSON.parse(r.data);
30
- t.type === "init" && e.onInit?.(t.schema, t.state), t.type === "reinit" && e.onReinit?.(t.schema, t.state), t.type === "update" && e.onUpdate?.(t.field, t.value), t.type === "call" && e.onCall?.(t.id, t.name, t.args), t.type === "identify" && (e.onIdentify ? e.onIdentify(t.label) : alert(`Identify: this is the "${t.label ?? "unlabeled"}" connection`));
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(a) {
35
- o?.send(JSON.stringify(a));
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 h = window.__mcpTools ?? [], { manifest: b, fnByName: C } = _(h), N = window.__mcpSummary ?? void 0, v = typeof window.__mcpAppName == "string" && window.__mcpAppName;
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, v)) return i;
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, l = $(
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)"}"`), l.send({ type: "register_tools", tools: b, summary: N, appLabel: e });
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, n, o) {
55
+ async onCall(e, t, o) {
56
56
  try {
57
- const c = C.get(n);
58
- if (!c) throw new Error(`no page tool named "${n}" — was it in window.__mcpTools when this script loaded?`);
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
- l.send({ type: "call_result", id: e, result: s });
60
+ r.send({ type: "call_result", id: e, result: s });
61
61
  } catch (c) {
62
- l.send({ type: "call_result", id: e, error: String(c.message) });
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 n = e ?? prompt("Rename this MCP connection:", i ?? "");
73
- n && (i = n, l.send({ type: "rename_connection", appLabel: n }), console.log(`[js-bridge-mcp] renamed connection to "${n}"`));
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
  };
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { getOrCreateTenant as getOrCreateTenantFor, tenants, startIdleSweep, createHttpServer, attachWebSocketServer } from 'mcp-tenant-lib';
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
- extraStaticMounts: { '/main.js': CLIENT_DIR },
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.1.9",
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.3.3",
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",