Repository navigation
feat(server): lazy bridge connect — listen() loads identity only - #51
Conversation
…b calls bind on demand Before: `FetchproxyServer.listen()` did role election + startHost/startPeer synchronously at boot. Every configured-but-unused MCP under Claude Desktop claimed bridge resources at MCP-client startup, and multiple MCPs starting in parallel raced to bind port 37149 — producing a flurry of `ERR_CONNECTION_REFUSED` reconnect errors in the extension while the losers gave up the port and dialed as peers. MCPs that never had a tool called still held a peer WS open to the host for the whole session. After: `listen()` is a cheap identity load + mcpId generation. The first `fetch` / `readCookies` / `readLocalStorage` / `readSessionStorage` / `captureRequestHeader` / `readIndexedDb` call triggers `ensureConnected`, which runs the role election and brings up the host/peer handle exactly once. Concurrent first-callers share an in-flight `connectingPromise` mutex so only one election runs (no port-bind race within a single MCP instance). For callers that DO want eager-bind semantics — test harnesses that dial a mock extension immediately, MCPs that want to log the role at boot — there's a new public `connect()` method that's an idempotent wrapper around `ensureConnected`. Production MCPs that just answer tool calls should NOT call it; the lazy path is the supported one. Implementation notes: - `connect()` returns void on success, throws if `listen()` was never called. Calling it twice is a no-op (existing handle reused). - `ensureConnected()` clears `connectingPromise` in `finally` so a transient connect failure can be retried by the next verb call rather than poisoning the cache. - All four verb methods (`fetch`, `readCookies`, `readLocalStorage`, `readSessionStorage`, `captureRequestHeader`, `readIndexedDb`) now call `await this.ensureConnected()` at the top, replacing the old `if (!this.hostHandle && !this.peerHandle) throw ...` listen-check. - The pre-existing `throwIfPendingPair` / `currentPendingPairCode` paths work unchanged once the handle is present. Tests: - `0.5.3+: listen() does not bind the port — connection is deferred` — asserts the role-null gap, then proves the port is free for another instance to take the host slot, then `connect()`s the first instance and watches it become a peer. - `0.5.3+: concurrent first verb calls share one connection election` — fires two `connect()` calls in parallel; the mutex must prevent a double election. - All existing tests pass: integration tests gained an explicit `await X.connect()` after each `await X.listen()` so the mock extensions in their harness can dial a bound port. Verifies the new eager path is functionally equivalent to the pre-0.5.3 implicit eager path. Total: 486 tests passing (was 484, +2 new for lazy behavior). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Claude finished @chrischall's task in 4m 10s —— View job PR Review: feat(server): lazy bridge connect
SummaryThe lazy-connect design is sound and the implementation is clean. The Two 🟡 nits, no 🔴 important findings. Findings🟡 Stale docstrings (flagged inline on line 314) — the class-level JSDoc still says "On 🟡 Verdict: |
| // Always clear so a transient connect failure can be retried by | ||
| // the next verb call. Successful path: the handle is now set, so | ||
| // the next `ensureConnected` short-circuits on the first branch. | ||
| this.connectingPromise = null; |
There was a problem hiding this comment.
🟡 Nit — close() called while doConnect() is in flight can leak a handle
The finally correctly clears connectingPromise so a failed connection is retryable, but close() doesn't set connectingPromise = null before it exits. If a verb call triggers doConnect() and close() races it, close() will see hostHandle == null (the handle hasn't been set yet), so hostHandle.close() never runs. doConnect() then finishes and writes this.hostHandle = <live handle> after close() has already returned — the handle leaks.
One simple fix: have close() also clear connectingPromise (and optionally await it first so any in-flight election gets shut down cleanly):
async close(): Promise<void> {
this.rejectAllPending();
// Wait for any in-flight election before tearing down handles.
if (this.connectingPromise) {
await this.connectingPromise.catch(() => {});
}
if (this.hostHandle) await this.hostHandle.close();
if (this.peerHandle) this.peerHandle.close();
this.hostHandle = null;
this.peerHandle = null;
this.role = null;
this.connectingPromise = null;
}This is a narrow race (requires concurrent close() + verb call) so not blocking, but worth a follow-up.
| // callers, cleared once the connection is up. Single source of truth | ||
| // for "we're connecting right now" so two parallel first-calls don't | ||
| // race the port bind. | ||
| private connectingPromise: Promise<void> | null = null; |
There was a problem hiding this comment.
🟡 Nit — two stale docstrings elsewhere in the class
Not in the diff but worth flagging here since this PR introduced the semantic change that makes them wrong:
- Class-level JSDoc (line ~260): "On
listen(), the server races the configured port…" — this is the old eager behavior. Should say the port race happens lazily on the first verb call (or explicitconnect()). rolefield JSDoc (line ~274): "Set afterlisten()succeeds. Null while not listening." — role is nownullafterlisten()and only set afterconnect()/ first verb call.
…ings 🟡 1. close() called mid-doConnect could leak the handle The pre-existing close() ran `if (this.hostHandle) await this.hostHandle.close()` then nulled the handle. If a verb call's `doConnect()` was in flight when close() ran, close() observed `hostHandle == null` (the assignment hadn't happened yet), skipped the teardown, and exited. doConnect() then wrote `this.hostHandle = <live handle>` after close() had already returned — the WSS state and any listener registrations survived close(). Fix: close() now awaits `connectingPromise` (with `.catch(() => undefined)` so a failed connection doesn't propagate) before doing teardown. If doConnect succeeded, its handle assignment is visible by the time we hit the host/peer .close() lines. close() also clears `connectingPromise` at the end so a subsequent listen() + connect() after close() doesn't observe a stale promise reference. New test `close() awaits an in-flight connect so no handle leaks` exercises the race and asserts post-close that hostHandle / peerHandle / connectingPromise are all null. Touches private fields because there's no public way to observe the leak otherwise — but the invariant is the right one to assert and a public surface would be overkill. 🟡 2. Stale docstrings Class-level JSDoc and the `role` field doc both still described the pre-0.5.3 eager behavior: - Class: "On `listen()`, the server races the configured port…" → now: lazy on first verb call (or explicit connect()). - role field: "Set after `listen()` succeeds. Null while not listening." → now: null until first verb call / connect(); reset on close(). All 487 tests pass (was 486, +1 new for the race fix). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Claude finished @chrischall's task in 2m 34s —— View job Claude finished re-reviewing PR #51 nit fixes — Re-review: address PR #51 nits
SummaryThe One new 🟡 nit: the docstring fix accidentally inserted a bare Findings🟡 Nit — stray Verdict: |
| error: string; | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
🟡 Nit — stray /** strips the class of its JSDoc
This line was added by the docstring fix but shouldn't be here. JavaScript block comments don't nest: the /** on line 257 opens a block comment, and the */ on line 274 closes it — which means the content from lines 258–274 (the entire updated class description) is inside that outer comment, not attached to FetchproxyServer. The class ends up with no JSDoc, so IDE hover docs (VS Code, etc.) won't show the description.
| /** | |
| /** | |
| * The MCP-facing handle for the fetchproxy bridge. |
Simply remove the extra /** on line 257 (i.e., collapse the two back into one /** on line 258).
PR #51's re-review caught that the docstring fix in f6f8582 accidentally left the original opener `/**` on the line above the new docstring's opener — two block-comment starts back-to-back. JavaScript block comments don't nest, so the outer one swallowed the class description and FetchproxyServer ended up with no JSDoc at all (IDE hover docs gone). One-line fix: drop the orphan opener. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Claude finished @chrischall's task in 2m 29s —— View job Re-review: lazy bridge connect (post-nit-fix sync)
SummaryAll three findings from the previous review cycles are fully resolved in this sync: 🟡 Stray 🟡 Stale docstrings (f6f8582) — Both updated correctly. The class-level JSDoc now reads " 🟡 No new issues found. Verdict: pass. |
🤖 I have created a release *beep* *boop* --- ## [0.6.0](v0.5.1...v0.6.0) (2026-05-26) ### Features * **server:** lazy bridge connect — listen() loads identity only ([#51](#51)) ([8309c2b](8309c2b)) ### Bug Fixes * 3 MCPs can work concurrently (peer session renegotiation + pendingPair dict) ([#49](#49)) ([4272e98](4272e98)) * **ci:** prevent labeled event from cancelling auto-review ([#47](#47)) ([40bc4db](40bc4db)) * **extension:** handlers iterate ALL matching tabs instead of just the first ([#50](#50)) ([8bd437b](8bd437b)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Summary
Defers the fetchproxy bridge connection (role election + WS bind/dial) from
FetchproxyServer.listen()to the first verb call.listen()is now just a cheap identity load + mcpId generation.Why
Today: every configured-but-unused MCP under Claude Desktop claims bridge resources at MCP-client startup. When several MCPs start in parallel they all race to bind port 37149 — only one wins host, the rest dial as peers. While the race is in flight, the browser extension's reconnect cycle produces a stream of
ERR_CONNECTION_REFUSEDerrors in DevTools because it may reach the port before any MCP has bound it. And MCPs that never have a tool called still hold a peer WebSocket open to the host for the whole session.After this PR: nothing touches the network until something actually needs it. Boot is quiet, idle MCPs cost zero socket descriptors, and the first-call latency in exchange is a one-time elect + handshake (sub-second on a warm extension).
API
listen()— unchanged signature, behavior reduced to identity load. MCP authors don't need to change anything to get the new behavior.connect()— new public method. Idempotent wrapper around the internalensureConnected. Useful for callers that want to surface the role / connection outcome at boot (most production MCPs won't need this; test harnesses do).fetch,readCookies,readLocalStorage,readSessionStorage,captureRequestHeader,readIndexedDb) nowawaitsensureConnected()at the top. The existingthrowIfPendingPair/ pair-code error path runs unchanged once the handle is up.connectingPromisemutex so two parallel verb calls don't race the port bind (mutex test included).Tests
Two new in
ws-server.test.ts:listen() does not bind the port — connection is deferred— proves the role-null gap afterlisten()and that another instance can take the host slot in that window.concurrent first verb calls share one connection election— fires twoconnect()calls in parallel and asserts the mutex prevents a double election.Existing tests adapted: integration tests added
await X.connect()after eachawait X.listen()so their mock extensions can dial a bound port. This verifies the new explicit eager path is functionally equivalent to the pre-0.5.3 implicit one.Total: 486 tests passing (was 484, +2 new).
Test plan
npm test— 486/486 passnpm run typecheck— cleannpm run build --workspaces --if-present— cleanlsof -iTCP:37149). Then call one tool and confirm exactly that MCP is now bound/connected.🤖 Generated with Claude Code