Stateless MCP endpoint with an OAuth 2.1 front door - #357
Merged
Conversation
The unit suite failed intermittently, roughly one run in eight:
FAIL src/__tests__/walletIds.bot.test.ts
node_modules/superjson/dist/index.js:1
SyntaxError: Cannot use import statement outside a module
superjson v2 is ESM-only ("type": "module", a single ESM export, no
CommonJS build) and the CJS jest project has no transform matching .js —
both patterns are TypeScript-only. Loading it there throws every time; a
probe test importing it directly failed 3/3. So the transform was never
the variable. The question was why the test loaded superjson at all.
walletIds.bot.test.ts mocked @/server/api/root — a real module that pulls
tRPC, and superjson with it — with `{ virtual: true }`. That flag is for
modules with no file on disk; on a real module the mock registers under
the bare specifier rather than the resolved path, so whether an importer
receives the mock depends on resolution order, which shifts with test
count and load. When the mock missed, the real tRPC root loaded and the
failure was then certain.
Removes the flag everywhere. An audit found none that needed it: every
mocked specifier resolves to a real module.
Also gives each jest project its own cacheDirectory — they shared jest's
default and could clobber each other's entries, which is wrong regardless
of this bug — and adds jestMockHygiene.test.ts, which fails the build if
the pattern returns.
Verified with 22 consecutive `npm test` runs after --clearCache, zero
failures, against a ~1/8 base rate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POST /api/mcp, so an LLM agent can read multisig wallets, UTxOs, pending transactions, proxies and governance proposals, and draft ballot rationales — instead of needing a hand-written client and a pasted bot secret. Built on @modelcontextprotocol/server v2, which implements the 2026-07-28 spec and also serves 2025-era clients through its legacy path, from the same tool registry. Stateless is that entry point's only mode: one POST is one complete exchange, a fresh server and single-use transport per request, GET/DELETE answer 405. A reused transport would fail on the *second* request while the first still looked healthy, so mcpRoute.test.ts asserts two sequential POSTs explicitly. Nine tools: read-only plus ballot drafts. Nothing here can sign, spend or broadcast, and that boundary is enforced by a test rather than left to convention — tool results carry user-authored strings (wallet names, transaction descriptions, ballot rationales), so pairing them with a write tool turns prompt injection into a funds-movement path. tools/list is filtered by the caller's scopes, so a client never sees a tool that would come back 403. Tools invoke the existing /api/v1 handlers in-process through a synthetic req/res pair rather than over loopback HTTP, so every authorization check, validation branch and error code stays defined exactly once. Handler imports are lazy: several v1 handlers import @meshsdk/core at module top level, and a static import would drag the whisky WASM into this route's cold path for every request, including a bare tools/list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The MCP surface acts as the signed-in user, so these two need a human path. Bot behaviour is unchanged in both. governanceActiveProposals was bot-only. It is a pure Blockfrost passthrough over public chain data — no wallet, no walletId, no signersAddresses — and payload.botId was used only for the bot rate limit and the governance:read scope lookup. Those now sit behind an isBotJwt guard; humans fall through and are metered per address instead, so an authenticated caller cannot use it as an unbounded Blockfrost proxy. botBallotsUpsert was bot-only. A human caller is now authorized by assertWalletAccess from @/server/api/auth — the same signer-or-owner predicate every ballot procedure in the tRPC router already applies — so the REST path is no more permissive than the app's own UI. The bot branch keeps its existing non-mutating (observer-is-enough) access check. Adds applyAddressRateLimit as the per-principal counterpart to applyBotRateLimit, keyed on the JWT address so it survives IP rotation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lets a user approve an AI client through a consent screen instead of
pasting a long-lived bot secret into a config file.
Implements what the MCP 2026-07-28 authorization spec requires of a
resource server and its AS: RFC 9728 protected-resource metadata (both
the path-aware and root forms, since clients probe path-first), RFC 8414
AS metadata, PKCE S256 only, RFC 8707 audience binding, rotating refresh
tokens, and RFC 9207 iss. Client ID Metadata Documents are the primary
registration path with RFC 7591 DCR as the deprecated fallback — Claude
Code selects CIMD only when the AS advertises both
client_id_metadata_document_supported and "none" auth, so both are
advertised.
Identity is the human's Cardano address from the existing wallet session;
NextAuth has no providers configured here, so wallet sign-in is the login.
The consent page invents no new flow: nonce, CIP-30 signature, existing
mesh_wallet_session cookie. Consent is bound to a signed handle minted by
/authorize, because the cookie alone proves only that a wallet is present
in this browser, not what is being approved.
Access tokens and v1 bearer tokens share JWT_SECRET but cannot be
substituted for one another: OAuth tokens carry the subject in `sub` (so
verifyJwt rejects them) and are typed mcp_at (so a v1 token fails OAuth
verification). Both directions are asserted in oauthTokens.test.ts.
Security properties, each with a test:
- no open redirect; an unregistered redirect_uri is rendered, never
redirected to
- redirect URIs must be https or loopback http, on *every* registration
path. The check previously existed only in the DCR handler, which
left CIMD accepting any string — a javascript: URI registered that
way reaches window.location.href on the consent page, and the app
ships no CSP
- authorization codes are consumed atomically, and a replayed code
revokes every refresh token under the grant
- refresh rotation claims the row conditionally before issuing, so two
concurrent redemptions cannot both mint
- scope may narrow on refresh, never widen, and a zero-scope result is
refused rather than minting a token that registers no tools
Codes, refresh tokens and client secrets are stored SHA-256 hashed. The
migration enables RLS on all four tables at creation — every table added
since 20251215090000 was silently left out of that policy.
Extracts the SSRF guard from api/v1/og.ts into src/lib/security/ssrf.ts;
the CIMD fetch needs it, and two copies of an address blocklist is how
one goes stale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
READMEs for both surfaces, an /api/mcp entry in the OpenAPI spec, an MCP card in the landing "Developers & Bots" section, and the sync points this project requires in .agents/README.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
POST /api/mcp, a stateless Model Context Protocol server, so Claude Code / claude.ai and other MCP clients can read multisig wallets directly — with the user approving access through a consent screen instead of pasting a bot secret into a config file.What's here
MCP endpoint — built on
@modelcontextprotocol/serverv2, which implements the 2026-07-28 spec and also serves 2025-era clients from the same tool registry. Stateless is that entry point's only mode: one POST is one complete exchange, fresh server and single-use transport per request,GET/DELETE→ 405.Nine tools, read-only plus ballot drafts, filtered by the caller's scopes so a client never sees a tool it can't call:
multisig_whoami,multisig_list_wallets,multisig_list_pending_transactions,multisig_list_free_utxos,multisig_list_proxies,multisig_proxy_drep_info,multisig_lookup_walletwallets:readgovernance_list_active_proposalsgovernance:readballot_upsertballots:writeNothing can sign, spend or broadcast, and that boundary is enforced by a test rather than convention — tool results carry user-authored strings (wallet names, transaction descriptions, ballot rationales), so pairing them with a write tool turns prompt injection into a funds-movement path.
Tools invoke the existing
/api/v1handlers in-process, so authorization and validation stay defined once. Handler imports are lazy to keep the Mesh/whisky WASM out of the route's cold path.OAuth 2.1 authorization server — RFC 9728 + RFC 8414 discovery, PKCE S256 only, audience-bound tokens, rotating refresh tokens, CIMD with DCR fallback, wallet-signed consent screen. Access tokens and v1 bearer tokens share
JWT_SECRETbut are non-interchangeable in both directions (subvsaddress,typ: mcp_at), asserted by tests.Two v1 handlers gained a human path (
governanceActiveProposals,botBallotsUpsert); bot behaviour is unchanged in both.A flaky-test fix, independent of the rest: the suite failed ~1 run in 8 on a
superjsontransform error. Root cause was{ virtual: true }onjest.mockof real modules, which made mock application depend on resolution order. Removed everywhere, with a guard test. Verified over 22 consecutive runs.Review notes
prestartrunsprisma migrate deploy, so preprod migrates itself on deploy. RLS is enabled at creation — every table added since20251215090000was silently left out of that policy./api/oauth/authorize|token|register|decision, the two.well-knowndocuments, and/oauth/consent.registeris open by spec (rate-limited hard).OAUTH_ISSUER_URL, falls back toNEXT_PUBLIC_SITE_URL.Verification
tsc --noEmitclean,npm run buildgreen, 801 + 67 tests passing. Driven end to end against a local production build: discovery chain resolves,tools/listand tool calls work over HTTP, a real Blockfrost round trip succeeds through the shim, and wrong-audience / wrong-issuer tokens are rejected 401 while a correct one gets 200.Not verified locally: the DB-backed tools and the OAuth code/token exchange, which need a real Postgres (no Docker in the dev environment). Those are unit-tested but unproven against a live database — worth exercising on preprod.
src/__tests__/txScriptRecovery.test.tsfailstsc --noEmiton preprod already, byte-identical here; untouched by this PR.🤖 Generated with Claude Code