Skip to content

Extract the patron identity + MCP client stack into a shared @dpyc/mcp-client package #215

Description

@lonniev

Proposal

Extract the patron identity + MCP client stack into a shared @dpyc/mcp-client
package, the way tollbooth-dpyc already owns crypto/vault/pricing for the Python
side. Today it exists as five hand-maintained forks, and the hardening in them was
paid for with real incidents that did not propagate evenly.

This is filed here rather than in a service repo because it is a fleet decision.

Verified duplication

Measured with md5 and diff on 2026-08-30 across the five frontends
(taxsort-mcp is excluded — it predates this stack and has no lib/mcp.ts).

File excalibur roastify cypher goodearth optionality
components/NpubGate.tsx 323 L 323 L 306 L 323 L 1028 L
lib/inlineProof.ts 30 L 30 L 30 L 30 L 37 L
lib/sessionNsec.ts 65 L 65 L 65 L 65 L 83 L
lib/nostrProfile.ts 77 L 77 L 77 L 77 L 63 L
lib/mcp.ts (generic head) ~645 L ~645 L ~645 L ~645 L ~645 L

Differences against the excalibur baseline, in changed lines (diff | grep -cE '^[<>]'):

File vs roastify vs cypher vs goodearth
NpubGate.tsx 4 35 4
inlineProof.ts 4 4 4
sessionNsec.ts 2 2 2
nostrProfile.ts 0 0 0
mcp.ts first 645 L 23 394 79

And the differences are exactly the trivia the claim predicted:

  • NpubGate.tsx, excalibur vs roastify — 2 changed lines, both brand strings
    (Sign in to eXcalibur / Sign in to Roastify).
  • sessionNsec.ts1 changed line, the localStorage prefix
    (excalibur:session_nsec:v1 vs cypher:session_nsec:v1).
  • inlineProof.tscomments only (an example tool name in a doc comment).
  • nostrProfile.tsbyte-identical md5 11d6e5ad3f0541e7d90a44f8b97cc461 in
    four of five repos
    (excalibur, roastify, cypher, goodearth).

Roughly 6,400 lines of duplicated surface across the five repos.

The hardening this would centralize

Each of these was bought with an incident, and each currently lives in 4-5 copies:

  1. Lowercase error_code comparison. The wheel emits lowercase snake_case
    (proof_required, proof_refresh_needed); an UPPERCASE compare never matched,
    so a lapsed proof silently failed every paid call instead of re-presenting the
    gate.
  2. Evicting the recent-login entry alongside the bounced proof token. Otherwise
    the returning-user one-tap immediately replays the same dead token and re-bounces.
  3. A bestEffort flag so a diagnostic/background call cannot log the user out.

Why this is not hypothetical: the same bug was fixed twice, differently

Item 1 regressed independently in at least two copies, and the two repos resolved
it in incompatible shapes:

  • excalibur / roastify / cypher / goodearth — fixed inline in mcp.ts, with
    a comment recording the incident.
  • optionality — fixed by extracting lib/proofExpiry.ts, whose own header
    says: "This exact check has regressed as a case-sensitivity bug ... which
    silently failed every paid call ... The dedicated test in
    verify/proofExpiry.smoke.ts guards against that recurring."

That regression test exists in exactly one of five repos:

excalibur-mcp    (none)
roastify-mcp     (none)
cypher-mcp       (none)
optionality-mcp  frontend/verify/proofExpiry.smoke.ts
goodearth-mcp    (none)

Item 3 has actually diverged in behavior, not just in spelling. excalibur added
bestEffort (11 call sites) precisely so a background read cannot drop the gate.
optionality has no such flag and deliberately went the opposite way — it
broadcasts PROOF_EXPIRED_EVENT so the gate drops even when the failing call is a
background hydration read. Same concern, two copies, opposite resolutions, neither
aware of the other.

A live example of the cost

While building the sixth copy (goodearth-mcp), CheckPriceResult was found to
declare base_cost / effective_cost / cost — field names that
tollbooth-dpyc's check_price has never returned in its entire history
(the wheel returns base_cost_api_sats / effective_cost_api_sats; verified with
git log --all -S '"effective_cost"' -- '*.py' → no commits). The bad type was
copied into all five frontends and had to be filed five times:

One shared package would have made that one fix in one place. Instead the copies
drifted: optionality's call sites had already grown as unknown casts to reach the
real field names, while the other three carry a type nothing has exercised yet.

Migration cost

  • 5 repos, 5 files each: components/NpubGate.tsx, lib/inlineProof.ts,
    lib/sessionNsec.ts, lib/nostrProfile.ts, and the generic head of lib/mcp.ts.
  • The per-repo variation is genuinely tiny and already isolated: a SLUG
    constant
    (used for the storage-key prefix and the MCP tool namespace) and a
    brand string for NpubGate. Both fit a single config object passed at init.
  • lib/mcp.ts splits cleanly at the existing section marker: everything above
    // ─── Posts CRUD (paid) ─── (line 644 in excalibur) is generic; below it is
    per-service domain tools that stay in the repo.
  • optionality-mcp is the outlier (a 1028-line NpubGate.tsx and a 1227-line
    mcp.ts) and should migrate last; its lib/proofExpiry.ts +
    verify/proofExpiry.smoke.ts are the best starting point for the shared
    package's proof logic and test.
  • taxsort-mcp is out of scope — it predates this stack (hooks/useMCP.ts, no
    lib/mcp.ts) and shares only a divergent nostrProfile.ts.

Risk of not doing it

Every hardening fix must be applied five times by hand and silently isn't:
the case-sensitivity regression test is in 1 of 5, bestEffort is in 4 of 5 with
optionality behaving oppositely, and the CheckPriceResult defect needed four
separate issues. A seventh frontend inherits today's fixes and none of tomorrow's.


Filed by Scout. All numbers above were measured, not estimated. No source files
were modified.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent/retriageReplay marker: re-fire Porter after a funding outagearea/authIdentity proof, ACL, OAutharea/uiFrontend / UItype/featureA new capability or enhancement

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions