Skip to content

feat(extension): per-identity pairing + non-blocking scope growth + connection dot - #97

Merged
chrischall merged 8 commits into
mainfrom
feat/multi-instance-pairing-and-scope-growth
Jun 3, 2026
Merged

chrischall merged 8 commits into
mainfrom
feat/multi-instance-pairing-and-scope-growth

Conversation

@chrischall

Copy link
Copy Markdown
Owner

Three cohesive changes to @fetchproxy/extension-core's pairing/trust flow, motivated by a real outage: musescore-mcp 0.3.0 added a capability, which force-blocked a re-pair across two concurrent instances and hung the whole bridge. Spec + plan in docs/superpowers/.

🔒 Invariant preserved throughout: granted scope ≤ approved scope; a capability is never granted without an explicit [Grant].

Part A — scope helpers (src/lib/scope.ts)

scopeHash (order-independent), intersectScope, isScopeSubset, plus the same* equality helpers moved out of background.ts (DRY). Pure + unit-tested.

Part 1 — per-identity pairing

pendingPair is now keyed by (identityHash + scopeHash) with an mcpIds[] set (+ sessionNonces map) instead of per-mcpId. Concurrent instances of the same identity+scope collapse into one approval; approving trusts the identity once and replays a session to every waiting mcpId. Legacy pending records migrate on read.

Part 2 — non-blocking scope growth ("update or remain paired")

The hello decision now computes granted = approved ∩ declared and auto-trusts on the intersection instead of blocking when an MCP declares new capabilities. A dismissible scope-update offer ([Grant] / [Keep as is]) is queued; the request handler enforces the granted capability set, so an ungranted verb errors cleanly instead of hanging. [Keep as is] is suppressed (per-identity dismissed scopeHash) until the declared scope changes again. This would have prevented the musescore outage.

Part 3 — connection-status dot (independent)

Each trusted MCP in the popup shows a green/grey presence dot, driven by the set of connected identityHashes (≥1 live session), with live updates via a connections-changed broadcast + get-connected-identities query.

Tests / status

extension-core: 156 passing, tsc -b clean. Branch touches only docs/ + packages/extension-core (no packages/server changes).

Note: packages/server has pre-existing test failures in this environment unrelated to this branch (it doesn't touch server); flagging in case CI surfaces them.

🤖 Generated with Claude Code

chrischall and others added 8 commits June 3, 2026 14:09
Part 1: key the pending-pair queue by (identityHash + scopeHash) not mcpId, so
concurrent instances of the same MCP identity produce one approval covering all.
Part 2: granted scope = approved ∩ declared, with a non-blocking "scope-update"
offer when an MCP gains capabilities — keeps serving the approved scope instead
of force-blocking a re-pair. Both preserve granted ≤ approved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-entry presence dot (green=connected/grey=offline), driven by the set of
connected identityHashes (>=1 live session) with live updates via a
connections-changed event. Same popup as Parts 1-2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… connection dot

Part A: pure scope hashing/intersection helpers. Part 1: composite-keyed
pending-pair (one approval per identity+scope). Part 2: granted = approved ∩
declared + non-blocking scope-update offer. Part 3: connection-status dot from
live sessions. TDD task-by-task; security invariant granted ≤ approved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pproval

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n an MCP gains capabilities

When an already-trusted MCP declares more than its approved scope, instead
of blocking (needs-pair), the decision now:
- returns auto-trust with granted = intersect(approved, declared)
- signals pendingScopeUpdate when declared ⊃ approved so onServerHello
  can queue a dismissible scope-update offer in the popup

The request handler enforces granted (not declared) capabilities via
mcpCapabilities, which is set from result.capabilities (the intersection).
PendingPairRecord split into PendingPairRecord|PendingScopeUpdateRecord union;
onApproval handles both; onScopeUpdateDismiss records dismissed scopeHashes
to suppress re-queuing until the declared scope changes again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ression

Add scope-update mode to PopupState with [Grant] and [Keep as is] buttons.
[Grant] writes approvedPair → background onApproval → trust.put with declared
scope (no ECDH needed, MCP already connected). [Keep as is] writes
dismissedScopeUpdate → background onScopeUpdateDismiss → stores dismissed
scopeHash so the same declared scope is not re-queued for that identity
until the declared scope changes. A new capability set after dismiss IS
queued again (different scopeHash). Bootstrap wires both pair and scope-update
pending record kinds with removePendingAndContinue shared helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… live sessions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added the auto-review Auto-review pipeline is handling this PR label Jun 3, 2026
@claude

claude Bot commented Jun 3, 2026 •

Copy link
Copy Markdown

Claude finished @chrischall's task in 5m 36s —— View job


Claude PR Review — complete

  • Load tools and read CLAUDE.md
  • Read PR diff and changed files
  • Review scope helpers (scope.ts + tests)
  • Review pending-pair changes (per-identity keying)
  • Review background.ts hello/scope-growth/connection-dot logic
  • Review popup changes
  • Review tests for coverage
  • Post inline findings (3 nits) + summary verdict

Summary — verdict: warn

Solid, well-scoped work that wires three cohesive changes (per-identity pairing dedup, non-blocking scope growth, connection-status dot) onto the existing pair flow. The central security invariant — granted = approved ∩ declared, request handler enforces granted — is preserved cleanly: intersectScope returns a deep-copied subset, auto-trust's capabilities field carries only the intersection, and mcpCapabilities.set(...) at background.ts:819 feeds the existing capability gate in handleRequest. Concurrency around pendingPair storage stays correct via withPendingPairLock. Legacy 0.5.1/0.5.2 records migrate cleanly through normalisePendingPair. Test coverage for the new branches looks thorough (Part 2 scope-growth, dismiss suppression, multi-instance collapse, connection-status dot all have dedicated cases).

No 🔴 Important findings — three 🟡 nits posted inline:

  • Dead same* helper imports in background.ts:58-68 (residue from refactor; tsc doesn't catch because noUnusedLocals is off).
  • A narrow kind: 'pair' vs kind: 'scope-update' key collision at background.ts:960-992 where a needs-pair can be silently dropped if it lands on the same ${identityHash}:${scopeHash} as a queued scope-update (reachable by revoking trust mid scope-update, then a reconnect). The MCP then hangs.
  • pendingScopeUpdate.sessionNonce (type at line 173, populated at 361) is never consumed — scope-update is non-blocking and doesn't need a fresh ECDH/ReadyFrame.

Couple of observations for follow-up (not findings):

  • dismissedScopeHashes[identityHash] is append-only; over many MCP versions the per-identity list grows unboundedly. Probably fine in practice (small strings, few identities), but worth noting.
  • PendingPair interface still lacks localStoragePointers / sessionStoragePointers fields — this is pre-existing, not introduced by this PR, but it does mean pointer adds aren't surfaced in the diff. Worth tackling when extending the scope-update UI.
    • Branch: feat/multi-instance-pairing-and-scope-growth

Comment on lines +58 to 68
import {
sameCapabilitySet,
sameScopeArrays,
sameCaptureHeaders,
sameIndexedDbScopes,
sameStoragePointers,
scopeHash,
intersectScope,
isScopeSubset,
} from './lib/scope.js';
import { loadOrCreateExtensionIdentity, type ExtensionIdentity } from './extension-identity.js';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit — unused imports. The five same* helpers (sameCapabilitySet, sameScopeArrays, sameCaptureHeaders, sameIndexedDbScopes, sameStoragePointers) are imported here but never referenced anywhere else in background.ts after the Part 2 refactor — they were used by the old "tighten / widen → needs-pair" path, which is now replaced by intersectScope + isScopeSubset. They're still used inside scope.ts itself (by isScopeSubset), so the export is justified, but the import in this file is dead.

tsc doesn't catch this because the workspace doesn't enable noUnusedLocals. Dropping these from the import list keeps the surface tight.

Suggested change
import {
sameCapabilitySet,
sameScopeArrays,
sameCaptureHeaders,
sameIndexedDbScopes,
sameStoragePointers,
scopeHash,
intersectScope,
isScopeSubset,
} from './lib/scope.js';
import { loadOrCreateExtensionIdentity, type ExtensionIdentity } from './extension-identity.js';
import {
scopeHash,
intersectScope,
isScopeSubset,
} from './lib/scope.js';

Comment on lines +960 to +992
const currentEntry = existing[pendingKey];
if (currentEntry && currentEntry.kind === 'pair') {
// Collapse: add this mcpId to the waiting set (dedup).
if (!currentEntry.mcpIds.includes(result.mcpId)) {
currentEntry.mcpIds.push(result.mcpId);
}
currentEntry.sessionNonces[result.mcpId] = sessionNonceB64;
} else if (!currentEntry) {
// New entry.
const pending: PendingPairRecord = {
key: pendingKey,
kind: 'pair',
identityHash: result.identityHash,
serverName: result.serverName,
version: result.version,
mcpIds: [result.mcpId],
sessionNonces: { [result.mcpId]: sessionNonceB64 },
domains: [...result.domains],
capabilities: [...result.capabilities],
cookieKeys: [...result.cookieKeys],
localStorageKeys: [...result.localStorageKeys],
sessionStorageKeys: [...result.sessionStorageKeys],
captureHeaders: [...result.captureHeaders],
indexedDbScopes: [...result.indexedDbScopes],
localStoragePointers: [...result.localStoragePointers],
sessionStoragePointers: [...result.sessionStoragePointers],
...(result.previousScope ? { previousScope: result.previousScope } : {}),
pairCode: result.pairCode,
identityX25519Pub: result.identityX25519Pub,
identityEd25519Pub: result.identityEd25519Pub,
};
existing[pendingKey] = pending;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit — needs-pair silently dropped when colliding with a scope-update entry at the same key.

The branch only handles two cases — currentEntry.kind === 'pair' and !currentEntry. If currentEntry exists with kind === 'scope-update', neither branch fires and the new needs-pair record is silently lost (no entry written, no pair-pending sent at line 1013 since that's also gated on entry.kind === 'pair'). The MCP then hangs waiting for a ReadyFrame that will never arrive.

Repro sequence (narrow but reachable):

  1. Trust exists for identity I with scope S₀.
  2. MCP A connects, scope grew → auto-trust with intersection + scope-update queued at key I:hash(S_declared).
  3. User revokes trust for I via the popup before deciding the scope-update.
  4. MCP A's WS drops and reconnects. Hello → no trust record → needs-pair with key I:hash(S_declared) — same key.
  5. Neither branch fires; needs-pair drops.

Suggested fix: if currentEntry.kind === 'scope-update', replace it with the new pair record (the auto-trust path it represented is no longer valid — there's no trust record any more).

approvedIndexedDbScopes: IndexedDbScopeDecl[];
approvedLocalStoragePointers: StoragePointerDecl[];
approvedSessionStoragePointers: StoragePointerDecl[];
sessionNonce: Uint8Array;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit — sessionNonce field is dead. pendingScopeUpdate.sessionNonce is populated at line 361 but never read anywhere in the consumer at lines 851-929 (scope-update is a non-blocking offer — no ECDH / ReadyFrame is derived from it, since the session is already live). Safe to drop both the field on the type and the assignment in the result.

@chrischall chrischall added the ready-to-merge Arms auto-merge — added by the pipeline on a pass/warn verdict, never by hand label Jun 3, 2026
@chrischall
chrischall merged commit daf4046 into main Jun 3, 2026
13 checks passed
@chrischall
chrischall deleted the feat/multi-instance-pairing-and-scope-growth branch June 3, 2026 20:05
chrischall added a commit that referenced this pull request Jun 3, 2026
…d code) (#99)

Follow-up to #97 — those review nits were committed *after* #97
auto-merged, so they were orphaned. Cherry-picked onto fresh `main`.

- **🟡→bug: needs-pair dropped on key collision with a queued
`scope-update`.** The pending-queue write only handled `kind:'pair'` and
no-entry; a `scope-update` at the same `${identityHash}:${scopeHash}`
caused silent fallthrough (record dropped, no `pair-pending` → MCP
hangs; reachable by revoking trust mid scope-update then reconnecting).
Extracted `applyNeedsPairRecord` so a needs-pair **supersedes** a queued
scope-update (trust is gone, the offer is moot), unioning `mcpIds`. New
TDD test covers it.
- **🟡 dead `same*` imports** removed from `background.ts` (still
exported from `scope.ts`).
- **🟡 dead `pendingScopeUpdate.sessionNonce`** field + assignment
removed (never read).

extension-core: **157 tests** (incl. the new supersede test), `tsc -b`
clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
chrischall added a commit that referenced this pull request Jun 3, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.12.0](v0.11.1...v0.12.0)
(2026-06-03)


### Features

* **extension:** per-identity pairing + non-blocking scope growth +
connection dot
([#97](#97))
([daf4046](daf4046))


### Bug Fixes

* **extension:** address
[#97](#97) review nits
(supersede-on-collision + dead code)
([#99](#99))
([1a98d25](1a98d25))

---
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>
chrischall added a commit to chrischall/opentable-mcp that referenced this pull request Jun 4, 2026
Bumps `@fetchproxy/server` 0.11.1 → 0.13.0.

## What this delivers

- **v0.13.0 — host failover.** A connected peer re-elects itself to host
when the current host tab dies
([fetchproxy#100](chrischall/fetchproxy#100)).
The bridge no longer goes dark when the hosting tab is closed or
crashes.
- **v0.12.0 — per-identity pairing + connection dot.** Per-identity
pairing, non-blocking scope growth, and a connection-status indicator in
the extension
([fetchproxy#97](chrischall/fetchproxy#97)).

## Why `enhancement` and not `dependencies`

`@fetchproxy/server` is first-party (chrischall/fetchproxy) — per the
repo's release-notes convention, bumps to packages we own that ship real
product improvements get `enhancement`/`feat:` so they drive a release
and land under Features, not hidden under Dependencies.

## Verification

- `npm install` → resolves `@fetchproxy/server@0.13.0`
- `npm run build` → typecheck + bundle clean
- `npm test` → 142/142 pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-review Auto-review pipeline is handling this PR ready-to-merge Arms auto-merge — added by the pipeline on a pass/warn verdict, never by hand

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant