Skip to content

feat: Pro MCP access via Clerk auth with 50/day quota#3646

Merged
koala73 merged 16 commits into
mainfrom
feat/pro-mcp-clerk-auth
May 10, 2026
Merged

feat: Pro MCP access via Clerk auth with 50/day quota#3646
koala73 merged 16 commits into
mainfrom
feat/pro-mcp-clerk-auth

Conversation

@koala73

@koala73 koala73 commented May 10, 2026

Copy link
Copy Markdown
Owner

Summary

Extends MCP access to Pro tier (Clerk-authenticated, Dodo-subscribed) without manual API keys. Pro users click "Sign in with WorldMonitor Pro" on api.worldmonitor.app/oauth/authorize; an apex worldmonitor.app/mcp-grant page reads their Clerk session and HMAC-signs a one-shot grant; the api-domain endpoint mints a non-key Pro identity row in Convex (mcpProTokens) and writes the OAuth code with {kind:'pro', userId, mcpTokenId}. The MCP edge re-validates the row + entitlement on every request, enforces a hard 50-tool-call/UTC-day cap via atomic INCR-first reservation with DECR-rollback, and signs request-bound HMAC headers (${ts}:${method}:${pathname}:${queryHash}:${bodyHash}:${userId}) on internal tool fetches so a leaked signature can't be replayed against a different endpoint.

API Starter+ flow is unchanged; WORLDMONITOR_VALID_KEYS env-key path is byte-identical to before.

Plan (Codex-approved over 4 review rounds): docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md.

What changed (10 plan units + 2 follow-ons + 1 review-fix layer)

  • U1convex/mcpProTokens.ts table + http routes; userApiKeys untouched.
  • U2server/_shared/pro-mcp-token.ts edge helper; no positive cache (revoke is authoritative on next request); negative-cache for known-bad bearers.
  • U3 — Apex /mcp-grant (Vite multi-page entry) + mcp-grant-mint + mcp-grant-context with anti-phishing real-client-metadata display.
  • U4 — Consent page Pro-CTA-default + "Use API key instead" disclosure; existing form behaviour preserved on error.
  • U5/oauth/authorize-pro HMAC-verify-first → atomic GETDEL of both Redis nonces → strict tuple match → entitlement re-check → mint → oauth:code:{kind:'pro',...}.
  • U6McpAuthContext discriminated union (env_key | pro); legacy bare-string oauth:token shape preserved verbatim. api/oauth/token.js → token.ts.
  • U7api/mcp.ts: scope-aware identity, atomic INCR-first quota (synchronous, never waitUntil), DECR-rollback on cap-exceeded and dispatch failure, request-bound internal HMAC tool fetches, fail-closed entitlement re-check, fail-closed Redis-transient on reservation.
  • U8server/gateway.ts: HMAC verify, sanitised-Request propagation (strip-then-construct-NEW-Request), per-process random nonce on x-wm-mcp-internal-verified (defense-in-depth beyond plan against direct-edge-function spoof).
  • U9 — Settings UI "Connected MCP clients" tab gated on new mcpAccess flag; /api/user/mcp-quota reads SAME Redis key the enforcement writes (single source of truth); /api/user/mcp-revoke Clerk-derived userId only.
  • U10productCatalog.ts mcpAccess: true for Pro+API+Enterprise; MCP_PRO_GRANT_HMAC_SECRET + MCP_INTERNAL_HMAC_SECRET env docs; vercel.json rewrites; docs/mcp-server.mdx Pro flow.
  • U9 polish.mcp-clients-* CSS rules mirroring .api-keys-*.
  • Review-fix layer — Tier-2 review (3 reviewers: code-reviewer + ce-security-reviewer + ce-adversarial-reviewer): all 1 BLOCKING + 1 HIGH (cross-user nonce hijack via SET NX claim) + 3 MEDIUM (refresh-token loss → discriminated union with transient-503-restore; 5-row-cap race → revoke-beyond-MAX-1; counter overshoot lockout → bounded DECR-sweep) + 5 LOW addressed.
  • /pro bundle regen — U10 description copy cascaded through scripts/generate-product-config.mjs into pro-test/src/generated/tiers.json; rebuilt public/pro/.

Test plan

  • Convex test suite — 247/247 pass (npm run test:convex).
  • Edge / server suites — 254/254 pass across mcp.test.mjs, oauth-token.test.mjs, oauth-authorize-pro.test.mjs, oauth-authorize.test.mjs, mcp-grant-mint.test.mjs, pro-mcp-token.test.mjs, gateway-internal-mcp.test.mjs, mcp-quota.test.mjs, mcp-revoke.test.mjs.
  • Type checktsc --noEmit and tsc -p tsconfig.api.json --noEmit both clean.
  • Pre-push hooks — Sentry-coverage, boundary-lint, edge-bundle, version-sync, proto-freshness, pro-test bundle freshness all pass.
  • Concurrency hard-cap test — 100 concurrent tools/call from a Pro user at counter=49: exactly 1 succeeds, 99 reject with -32029 + 429.
  • HMAC replay defense — captured signature for /api/news/v1/list-feed-digest cannot be replayed against /api/intelligence/v1/deduct-situation (path mismatch in HMAC payload).
  • Cross-user nonce hijack (review fix verified) — user A starts OAuth → user B mints grant for user A's nonce → 403 NONCE_CLAIMED_BY_OTHER_USER.
  • Refresh-token transient resilience — Convex 5xx during refresh → 503 + Retry-After + refresh token preserved in Redis (no permanent session loss on a Convex blip).
  • Manual smoke (recommended on staging before prod merge) — Pro user E2E: claude.ai → consent → apex grant → bounce → token → tools/listtools/call (get_market_data) → success → revoke from settings → next call returns -32001.

Pre-deploy checklist

  1. Set the two new env vars in Vercel production + preview:
    • MCP_PRO_GRANT_HMAC_SECRET=$(openssl rand -base64 32) — apex grant ↔ api-domain bridge.
    • MCP_INTERNAL_HMAC_SECRET=$(openssl rand -base64 32) — MCP edge ↔ gateway service-auth.
  2. Confirm CF preserves headers end-to-end on api.worldmonitor.app: X-WM-MCP-Internal, X-WM-MCP-User-Id. Memory warmping-origin-trust-cdn-401-poisoning documents CF's history of stripping non-allowlisted headers.
  3. Existing envs unchanged: CLERK_JWT_ISSUER_DOMAIN, CONVEX_SERVER_SHARED_SECRET, WORLDMONITOR_VALID_KEYS.

Deploy ordering (CRITICAL)

  1. Convex schema + functions deploy FIRSTmcpProTokens table + internal-{issue,validate,revoke}-pro-mcp-token HTTP routes must exist when MCP edge starts calling them. A Vercel-before-Convex deploy would 401 every Pro tool fetch.
  2. Wait for Dodo webhook batch to refresh existing Pro entitlements so mcpAccess: true propagates. Until then, Pro users see mcpAccess === undefined and the new settings tab is hidden + the gateway HMAC re-check fails closed (intentional — until a Pro user's entitlement row has the field, they can't use MCP).
  3. Then deploy Vercel (this PR).

Post-Deploy Monitoring & Validation

Validation window: 24 hours after deploy.
Owner: Assign on merge

Healthy signals (expected within first 4 hours):

  • mcp:pro-usage:* Redis keys begin appearing (KEYS mcp:pro-usage:* in Upstash) — confirms Pro tool calls are reserving quota.
  • New mcpProTokens rows in Convex (npx convex data mcpProTokens --limit 20) — confirms Pro authorisations are landing.
  • Sentry: zero events tagged route:'api/mcp', step:'pro-entitlement-recheck' (would indicate Convex getEntitlements failing).
  • Sentry: zero events tagged route:'gateway', step:'invalid_internal_mcp_signature' from production traffic (would indicate sign/verify drift).
  • Vercel function metrics: /api/oauth/authorize-pro p99 latency < 500 ms.

Failure signals → mitigate:

  • 5xx rate > 0.5% on /api/mcp for Pro bearers → check Convex availability + validateProMcpToken 503 path.
  • 401 rate > 5% of Pro tool fetches at the gateway → likely MCP_INTERNAL_HMAC_SECRET mismatch between MCP edge and gateway. Mitigation: confirm both env vars match. If urgent, rollback U10's mcpAccess: true flag (sets to false on Pro → settings tab disappears + Pro path 401s closed).
  • invalid_internal_mcp_signature in Sentry > 0.1% sustained → header tampering OR sign/verify canonicalisation drift. Investigate immediately.
  • Counter overshoot >> 51 on mcp:pro-usage:* keys → adv-002 lockout scenario; the bounded DECR-sweep should converge but watch for Redis tail-latency.

Rollback plan:

  • Quick: revert this PR's merge commit. Convex schema rollback is unnecessary (additive mcpProTokens table + http routes are inert without code calling them).
  • Hot: feature-flag via mcpAccess: false in productCatalog.ts → next Dodo webhook refresh hides the new tab and Pro MCP path returns 401.

Dashboards / queries to watch:

  • Vercel function logs: route:'api/mcp' filtered to status 5xx.
  • Vercel function logs: route:'gateway' filtered to invalid_internal_mcp_signature.
  • Upstash: KEYS mcp:pro-usage:* count + KEYS mcp-grant:* count.
  • Convex Dashboard: mcpProTokens insert + revoke rates.
  • Sentry: tag filter route IN ('api/mcp', 'api/oauth/authorize-pro', 'api/internal/mcp-grant-mint', 'api/user/mcp-revoke', 'gateway').

Notable security upgrades beyond the plan

  • Per-process random nonce on x-wm-mcp-internal-verified (U8) replaces the planned literal '1'. Sweep found direct-edge-function consumers of isCallerPremium (api/widget-agent.ts, chat-analyst.ts, me/entitlement.ts, v2/shipping/webhooks/...) outside the gateway strip path. With a constant marker those endpoints would be spoofable from outside; with a per-process random they cannot be.
  • Cross-user OAuth nonce hijack defense (review fix) — mcp-grant-mint now atomically claims the nonce to a userId via SET NX semantics and rejects mismatched claims with 403 NONCE_CLAIMED_BY_OTHER_USER. Closes the attack class where any logged-in Pro user could mint a grant for someone else's pending OAuth flow.
  • Refresh-token transient resilience (review fix) — validateProMcpToken returns {ok:'valid'|'revoked'|'transient'}; on transient during refresh, the consumed refresh token is restored to Redis with original TTL and the client gets 503 + Retry-After. Prior behaviour permanently destroyed legitimate sessions on a Convex blip.

Plan ↔ implementation traceability

Every plan unit (U1-U10) and every Risks-table accepted-risk item is preserved verbatim in commit messages. Origin trace: docs/brainstorms/2026-03-27-pro-mcp-server-requirements.md.

koala73 added 13 commits May 10, 2026 13:33
…outes (U1)

Non-key Pro MCP identity layer for the Pro-tier MCP access plan. Mirrors
apiKeys.ts shape but stores no key material — the row's _id IS the
bearer identifier (referenced from OAuth code/token records as
mcpTokenId).

- mcpProTokens table: {userId, clientId?, name?, createdAt, lastUsedAt?,
  revokedAt?} indexed by_userId; userApiKeys untouched.
- Internal mutations: issueProMcpToken (tier ≥ 1 gate, 5-row cap with
  silent oldest-revoke rotation, audit-preserving), validateProMcpToken,
  internalRevokeProMcpToken (server-side rollback path for U5 — no Clerk
  context needed), touchProMcpTokenLastUsed (debounced 5min).
- Public mutations: revokeProMcpToken + listProMcpTokens (Clerk-auth).
- HTTP internal routes: /api/internal-{issue,validate,revoke}-pro-mcp-token
  with x-convex-shared-secret guard. Validate route schedules touch via
  ctx.scheduler.runAfter, matching apiKeys pattern (http.ts:839).

28 new tests; full convex suite 245/245.

Plan unit U1: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
Edge-runtime-safe wrappers around U1's Convex internal HTTP routes. No
positive cache on validate (revoke takes effect on next request);
negative-cache only at pro-mcp-token-neg:<tokenId> (60s TTL) for
known-bad bearers.

- issueProMcpTokenForUser: typed ProMcpIssueFailed (pro-required /
  invalid-user-id / config / network), 3s timeout.
- validateProMcpToken: neg-cache short-circuit BEFORE Convex hit; fail-soft
  on 5xx/timeout (returns null but does NOT write neg-cache sentinel —
  prevents transient-blip poisoning of legitimate tokens). Convex's
  validate route schedules touchProMcpTokenLastUsed internally; no
  edge-side waitUntil needed.
- revokeProMcpToken: returns result object (no throw) so rollback callers
  don't have their original error masked. Sets neg-cache sentinel after
  successful revoke.
- invalidateProMcpTokenCache: public sentinel writer for U9.

25 tests including a load-bearing integration test for revoke → next
validate short-circuits without Convex round-trip.

Plan unit U2.
Bridge between the apex Clerk session and the api-subdomain Pro MCP
consent flow. Apex /mcp-grant SPA reads the OAuth nonce + registered
client metadata so users see the real client_name + redirect host
(anti-phishing). On Authorize the page POSTs to mcp-grant-mint, gets
back a fixed redirect URL with a HMAC-signed grant token, and navigates.

- mcp-grant.html + src/mcp-grant-main.ts: Vite multi-page entry (matches
  existing settings.html / live-channels.html convention).
- api/internal/mcp-grant-mint.ts: Clerk-auth POST. Re-checks tier ≥ 1,
  re-validates redirect_uri allowlist (defense-in-depth), HMAC-signs
  {userId, nonce, exp+5min}, SETEX mcp-grant:<nonce>, returns FIXED
  redirect URL (https://api.worldmonitor.app/oauth/authorize-pro).
- api/internal/mcp-grant-context.ts: GET companion for the SPA to fetch
  the real client_name + redirect_host. Same Pro-tier gate as mint.
- api/_mcp-grant-hmac.ts: shared sign/verify helper. Signature is over
  exact post-base64url-decode bytes (key-order/whitespace independent).
  U5 imports verifyGrant from this module.
- vercel.json: /mcp-grant → /mcp-grant.html rewrite + no-store header
  rule. Catch-all SPA fallback adjusted to exclude the new page.
- api/oauth/register.js: export isAllowedRedirectUri so DCR allowlist is
  reused (no parallel impl).
- 35 tests; full deploy-config + edge-functions + mcp + pro-mcp-token
  suites green.

Plan unit U3.
R1 of the plan: a logged-in Pro user authorising MCP from claude.ai
never sees the API-key input field by default. The consent page now
leads with a brand-green "Sign in with WorldMonitor Pro" CTA pointing
at the apex /mcp-grant page; the existing API-key form is hidden
behind a "Use API key instead" disclosure for Starter+ holders.

- Default state: form display:none; Pro CTA dominant.
- Error state: existing errorMsg path still works — when ke.textContent
  is non-empty (or XHR-retry returns invalid_key), inline script auto-
  reveals the form so the user doesn't lose context after a bad key.
- Deep-link: /oauth/authorize?...#api-key shows the form on initial
  load, so Starter+ users can bookmark.
- Pro CTA href: https://worldmonitor.app/mcp-grant?nonce=<URL-encoded n>.
  Apex page reads the OAuth nonce server-side (no client_name forwarded
  via URL — already covered by U3's mcp-grant-context).
- nonce shared with U5: the same oauth:nonce:<n> row that U5's
  /oauth/authorize-pro will GETDEL. Handler still mints exactly one
  nonce per GET — no double-issue.
- XSS-escape preserved on client_name + redirect_host + nonce + errorMsg.
- 18 new tests; existing handler logic untouched.

Plan unit U4.
Receives the apex grant bounce-back, validates the HMAC-signed grant,
atomically consumes both Redis nonces (mcp-grant + oauth:nonce), issues
a non-key Pro identity row in Convex via U2, and writes the OAuth code
with {kind:'pro', userId, mcpTokenId, ...} for U6 to read at exchange
time.

Security ordering (HMAC-first to avoid burning nonces on forged tokens):
  1. verifyGrant (U3's _mcp-grant-hmac, no Redis)
  2. URL-nonce vs payload-nonce match
  3. GETDEL mcp-grant:<n>
  4. strict {userId, exp} tuple match vs grant payload (forge defense)
  5. GETDEL oauth:nonce:<n>
  6. GET oauth:client:<client_id> + redirect_uri allowlist re-check
  7. getEntitlements(userId) tier ≥ 1 re-check (lapse defense)
  8. issueProMcpTokenForUser (U2)
  9. SETEX oauth:code:<code> (kind:'pro', 600s)
  10. 302 redirect_uri?code=<code>[&state=...] with Cache-Control:no-store

oauth:code shape (load-bearing for U6):
  {kind:'pro', userId, mcpTokenId, client_id, redirect_uri,
   code_challenge, scope:'mcp_pro'}

Best-effort revoke when SETEX fails after issueProMcpTokenForUser
succeeds — calls U2's no-throw revokeProMcpToken; logs orphan-row id
on revoke failure but never masks the original 500.

35 tests; vercel rewrite + api-route-exceptions registered as
external-protocol.

Plan unit U5.
Token endpoint and bearer resolver learn about Pro-shape tokens without
breaking Starter+ legacy paths.

api/_oauth-token.js:
- New resolveBearerToContext(token) returns a discriminated union:
    {kind:'env_key', apiKey}    -- legacy WORLDMONITOR_VALID_KEYS resolution
    {kind:'pro', userId, mcpTokenId}    -- new Pro identity
- resolveApiKeyFromBearer kept as backward-compat wrapper. Returns
  apiKey for env_key kind, null for pro kind (so legacy callers can't
  mis-handle a Pro bearer).

api/oauth/token.js → api/oauth/token.ts:
- Converted to TypeScript so it can import validateProMcpToken from
  server/_shared/pro-mcp-token cleanly. Vercel routes by basename;
  /oauth/token rewrite unaffected.
- Default handler wires injected deps via tokenHandler(req, deps) for
  testability (mirrors U3/U5 pattern).
- authorization_code branch: dispatches on codeData.kind. Pro path
  validates client_id + redirect_uri bind, PKCE-verifies, mints tokens
  via storeProTokens (object shape). Legacy path unchanged.
- refresh_token branch: Pro refreshes call validateProMcpToken
  (per-request Convex hit, no positive cache); revoked row → invalid_grant.
  Cross-user defensive guard: Convex-returned userId must match bearer's.
  family_id, mcpTokenId, scope ('mcp_pro') preserved across rotation.
- client_credentials grant untouched.

oauth:token:<uuid> shapes:
- Legacy (preserved): JSON.stringify("<sha256-hex-64>") or "<fingerprint-16>"
- Pro (new):          JSON.stringify({kind:'pro', userId, mcpTokenId})
- oauth:refresh:<uuid> Pro shape includes client_id, scope, family_id
  for rotation discipline (matches legacy semantics).

26 new tests; sibling suites (oauth-authorize, oauth-authorize-pro, mcp,
pro-mcp-token — 101 tests) regress green. tsc -p tsconfig.api.json clean.

Plan unit U6.
… (U7)

Behavioral heart of the Pro MCP plan. api/mcp.ts now resolves bearers
as McpAuthContext (env_key | pro), runs Pro-specific pre-checks, and
enforces a hard 50/UTC-day cap via INCR-first reservation with
DECR-rollback on cap-exceed and on tool-dispatch failure.

Pre-dispatch chain for Pro context (synchronous, in order):
  1. validateProMcpToken(mcpTokenId)         — null = 401 revoked
  2. defensive userId match                  — 401 cross-user
  3. getEntitlements(userId) tier ≥ 1, mcpAccess: true, validUntil — fail-closed
  4. per-minute slidingWindow(60, 60s) keyed pro-user:<userId>  — fail-open
  5. for tools/call ONLY: pipeline INCR + EXPIRE 172800
       newCount > 50 → DECR rollback + -32029 + 429 + Retry-After
       INCR Redis transient → -32603 + 503 + Retry-After:5 (hard-cap correctness)
  6. dispatch tool; on throw → DECR rollback + -32603

Internal-HMAC tool fetches (replaces X-WorldMonitor-Key for Pro):
  payload   = ${ts}:${METHOD}:${pathname}:${queryHash}:${bodyHash}:${userId}
  queryHash = SHA-256(canonicalQueryString)   sorted keys, URL-encoded
  bodyHash  = SHA-256(bodyBytes)              SHA-256("") for empty
  sig       = HMAC-SHA-256(MCP_INTERNAL_HMAC_SECRET, payload)
  headers   = X-WM-MCP-Internal: ${ts}.${b64url(sig)}, X-WM-MCP-User-Id

Replay defense: payload binds method+path+queryHash+bodyHash, so a
captured signature for /api/news/v1/list-feed-digest cannot be reused
on /api/intelligence/v1/deduct-situation. ±30s window.

server/_shared/mcp-internal-hmac.ts: single source of truth for sign
helpers. U8 verify imports the same canonicalisation primitives.

server/_shared/pro-mcp-token.ts: dailyCounterKey(userId), 50/172800s
constants exported for U9 to read the SAME Redis key the enforcement
writes.

_execute signatures changed (params, base, context) — option A from
the plan. Cache-only tools unchanged.

waitUntil unused: Convex internal-validate-pro-mcp-token schedules
touchProMcpTokenLastUsed itself (verified at convex/http.ts:1035-1040
from U1's commit 4530bdf).

22 new tests + 23 Starter+ regression tests in tests/mcp.test.mjs;
sibling chain (oauth-token, pro-mcp-token, oauth-authorize-pro,
mcp-grant-mint, mcp-proxy) all green. tsc + biome clean.

Plan unit U7.
…ion (U8)

Verifier counterpart to U7's signer. Gateway accepts X-WM-MCP-Internal
HMAC headers in lieu of an API key for Pro internal-MCP traffic, then
hands a freshly-constructed Request with sanitised trusted markers to
the downstream handler. isCallerPremium learns to honour those markers
so Pro framework/systemAppend semantics survive in summarize-article,
get-country-intel-brief, deduct-situation.

Gateway flow at the top of createDomainGateway's handler:
  1. STRIP inbound x-wm-mcp-internal-verified + x-user-id (anti-injection)
  2. If X-WM-MCP-Internal present:
       verifyInternalMcpRequest re-canonicalises and HMAC-verifies the
       request shape (method+pathname+queryHash+bodyHash+userId), ±30s
       window, timing-safe compare. Failure → 401 invalid_internal_mcp_
       signature (no fall-through to validateApiKey — present-but-bad is
       a deliberate forge attempt).
       getEntitlements(userId): tier ≥ 1 + mcpAccess === true (fail-closed).
       Construct NEW Request via new Request(url, {method, body, headers})
       with x-wm-mcp-internal-verified=<per-process nonce> +
       x-user-id=<verified userId>. Skip validateApiKey + IP rate limit.

isCallerPremium adds a NEW first branch: timing-safe compare of
x-wm-mcp-internal-verified against the per-process nonce + non-empty
x-user-id + defensive getEntitlements re-fetch. Catches direct-edge-
function consumers (api/widget-agent.ts, chat-analyst.ts, me/entitlement.ts,
v2/shipping/webhooks/...) that don't run the gateway strip step.

DEFENSE-IN-DEPTH BEYOND PLAN: trusted marker is a per-process random
16-byte nonce, NOT the literal '1'. Sweep found direct edge functions
calling isCallerPremium without gateway-strip protection — a constant
marker would be spoofable from outside. The nonce is born once at
process startup, only the gateway knows it, comparison is timing-safe.

verifyInternalMcpRequest lives in server/_shared/mcp-internal-hmac.ts
next to U7's sign helpers — single source of truth for canonical form
+ payload + ±30s window. Drift between sign and verify is structurally
impossible.

26 new tests + 100 sibling regression tests (gateway-cdn-origin-policy,
premium-stock-gateway, premium-fetch, pro-mcp-token, mcp). tsc clean.

Plan unit U8.
Catalog, schema, type, env, and docs scaffolding to make the Pro MCP
flow deployable end-to-end. U9 unblocks on this — settings UI gates on
hasFeature('mcpAccess') (distinct from existing apiAccess paywall).

- convex/config/productCatalog.ts: PlanFeatures.mcpAccess on every tier.
  Free=false. Pro_monthly/Pro_annual=true. API_starter / API_starter_annual
  / API_business=true. Enterprise=true. Pro plan marketing copy mentions
  "MCP access for Claude Desktop & other AI clients (50 calls/day)".
- convex/schema.ts + convex/payments/cacheActions.ts: mcpAccess optional
  field on the entitlements validator; legacy rows pass.
- server/_shared/entitlement-check.ts: CachedEntitlements.features
  mcpAccess?: boolean (consumed by U7 + U8).
- src/services/entitlements.ts: EntitlementState.features.mcpAccess?:
  boolean — unblocks hasFeature('mcpAccess') for U9's settings tab gate.
- .env.example: new "Pro MCP" section with MCP_PRO_GRANT_HMAC_SECRET
  (apex grant bridge, U3/U5) and MCP_INTERNAL_HMAC_SECRET (gateway
  service-auth, U7/U8). Both 32-byte base64; both required.
- docs/mcp-server.mdx: "Pro sign-in flow" section + 50/day quota
  callout + "Connected MCP clients" management pointer.

Bundled fixups for U7/U8 strict-mode TS errors:
- mcp-internal-hmac.ts: bufferToBase64Url uses for-of (avoids
  noUncheckedIndexedAccess error on bytes[i]).
- gateway.ts: drop unused INTERNAL_MCP_USER_ID_HEADER import.

tsc -p tsconfig.api.json + tsc -p tsconfig.json clean.
9 entitlements tests + 71 gateway-internal-mcp + mcp tests pass.

Plan unit U10.
User-facing surface that makes the Pro MCP plan visible. Settings UI
gets a new "Connected MCP clients" tab gated on hasFeature('mcpAccess')
(distinct from the existing apiAccess-gated 'API Keys' tab — Pro users
without apiAccess see only the new tab).

Endpoints:
- GET /api/user/mcp-quota → {used, limit:50, resetsAt}. Reads the
  SAME Redis key (dailyCounterKey from U2) that U7 enforcement writes.
  Single source of truth.
- POST /api/user/mcp-revoke {tokenId} → forwards Clerk-derived userId
  (NOT client-supplied) to Convex internal-revoke-pro-mcp-token, then
  calls invalidateProMcpTokenCache so any in-flight bearer with this
  tokenId 401s within 60s. 404 on NOT_FOUND, 409 on ALREADY_REVOKED.
  Tenancy enforced inside Convex (row.userId === userId).

UI (in src/components/UnifiedSettings.ts, no child component split —
matches existing API Keys tab pattern):
- Tab gated on hasFeature('mcpAccess') so Pro users see it without
  needing apiAccess.
- Quota header auto-refreshes every 30s; interval cleared on tab-
  switch / close / destroy.
- Each row: name, createdAt, lastUsedAt (relative), revokedAt
  (struck-through + badge); active rows show Revoke button with
  confirm() dialog (matches existing pattern).
- Empty state: click-to-copy https://api.worldmonitor.app/mcp.

Frontend service in src/services/mcp-clients.ts: listMcpClients (Convex
query), revokeMcpClient (POST /api/user/mcp-revoke), fetchMcpQuota.

11 + 14 = 25 new tests; api-route-exceptions registered as
internal-helper.

Plan unit U9.

---

PLAN COMPLETE — 10/10 units shipped. Follow-up polish noted in U9
report: CSS rules for .mcp-clients-* classes (suggest copy from
.api-keys-* rules); empty-state URL is hardcoded to
api.worldmonitor.app/mcp.
…ness

Review pass after U10 (3 reviewers: code-reviewer, ce-security-reviewer,
ce-adversarial-reviewer). Findings BLOCKING + HIGH + MEDIUM + LOW
applied per user direction.

BLOCKING (gateway):
- Add validUntil check to gateway's HMAC-verify entitlement re-check.
  Defense-in-depth against Convex-fallback paths returning a stale row
  past its expiry.

HIGH — adv-001: cross-user OAuth nonce hijack:
- mcp-grant-mint now claims oauth nonces atomically via Upstash SET NX
  semantics. If the nonce is already claimed by a DIFFERENT userId,
  return 403 NONCE_CLAIMED_BY_OTHER_USER. Same userId multi-tab retry
  is idempotent: re-sign the grant using the existing claim's exp so
  authorize-pro's strict tuple equality still matches.
- mcp-grant-context performs the same cross-user check so the apex SPA
  refuses to render consent context for a hijacked nonce.

MEDIUM — adv-008: refresh-token loss during Convex transient:
- validateProMcpToken returns a discriminated union {ok:'valid',userId}
  | {ok:'revoked'} | {ok:'transient'}. Negative-cache writes only on
  'revoked'. validateProMcpTokenOrNull wrapper preserves backward
  compat for callers that don't need the distinction (per-request MCP
  edge stays fail-closed).
- token.ts refresh path: on 'transient', best-effort restore the
  consumed refresh token to Redis with the original TTL and return
  503 server_error + Retry-After:5. Client retries; if Convex recovers,
  refresh succeeds. No more permanent session loss on a Convex blip.

MEDIUM — adv-002: counter overshoot lockout:
- mcp.ts: on cap-exceeded after DECR rollback, if newCount is still
  far above PRO_DAILY_QUOTA_LIMIT (overshoot from prior transient),
  pipelined INCR+DECR probe + bounded DECR-sweep to converge the
  counter back near the limit. Prevents one Redis hiccup from locking
  out a paying Pro user for the rest of the UTC day.

MEDIUM — code-reviewer #4: 5-row cap race in Convex:
- mcpProTokens.issueProMcpToken now revokes ALL active rows beyond
  MAX-1 (sorted by createdAt) on each issue, so concurrent inserts
  that briefly produce 6 active rows converge back to 5 on the next
  call.

LOW:
- adv-003: executeTool throws cache_all_null when every cache read
  returns null AND there were keys configured — triggers DECR rollback
  in dispatchToolsCall instead of silently burning quota on a degenerate
  empty result.
- code-reviewer #10: gateway strips X-WM-MCP-Internal + X-WM-MCP-User-Id
  from the trusted Request before forwarding to handler (no info leak).
- adv-004: 256 KB body cap (Content-Length + post-buffer count) on
  both gateway strip and HMAC-verify paths — bounds memory amplification.
- adv-006: dailyCounterKey now env-prefixes (preview deploys can't
  collide with production traffic on the same Upstash instance).
  Reader (mcp-quota.ts) and writer (mcp.ts) share byte-identical keys
  via the helper.
- adv-007 / code-reviewer #5: signInternalMcpRequest throws on
  Blob/FormData/ReadableStream/plain-object bodies instead of silently
  JSON.stringify'ing them (which would produce a hash that can't match
  the wire bytes).
- code-reviewer #9: timestamp regex tightened to ^[0-9]{1,15}$ so
  future ms-precision timestamps don't silently truncate through Number().
- code-reviewer #8: MCP_INTERNAL_HMAC_SECRET preflight in runProPreChecks
  surfaces config errors at auth-resolution rather than mid-tool-fetch.

NITPICK:
- code-reviewer #6: rewritten readNegCache comment.
- code-reviewer #7: malformed-body error now reports reason
  'malformed_request' instead of misleading 'auth_401'. New
  RequestReason value added to usage.ts enum.

30 new tests + ~14 adapted; full convex suite 247/247, edge/server
suites 254/254. tsc clean across both tsconfig.json and tsconfig.api.json.

Plan: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
U10's productCatalog.ts added an mcpAccess feature flag and updated the
Pro plan description copy to "MCP access for Claude Desktop & other AI
clients (50 calls/day)". scripts/generate-product-config.mjs cascades
that copy into pro-test/src/generated/tiers.json, which the /pro
marketing app builds into public/pro/assets/.

Vercel does NOT rebuild pro-test on deploy — public/pro/ ships whatever
is committed. Pre-push hook auto-ran the build and produced a new bundle
hash; this commit lands the regenerated artifacts so deploy picks them up.

Plan unit U10 (follow-on artifact).
@koala73
koala73 requested a review from SebastienMelki as a code owner May 10, 2026 12:35
@mintlify

mintlify Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview May 10, 2026, 12:36 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@vercel

vercel Bot commented May 10, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

Header at index 14 has invalid `source` pattern "/mcp-grant(?:\.html)?".

Learn More: https://vercel.link/invalid-route-source-pattern

@greptile-apps

greptile-apps Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds Pro MCP access via Clerk authentication with a 50/day quota. Pro users authorize through a cross-subdomain Clerk-session grant flow (apex → mcp-grant-mint → HMAC-signed token → authorize-pro → Convex mcpProTokens row → OAuth code/token), while the MCP edge enforces an atomic INCR-first daily quota with DECR rollback, and the gateway validates request-bound HMAC signatures on every internal tool fetch.

  • Auth flow: Clerk session → HMAC-signed grant → one-shot Redis nonces consumed atomically → Convex row issued → bearer token with {kind:'pro', userId, mcpTokenId} discriminator. F2 anti-hijack (SET NX claim), F3 refresh-token transient resilience, and per-process random verified-nonce are well-implemented.
  • Quota enforcement: INCR-first reservation before dispatch, DECR rollback on cap-exceeded or tool failure, bounded DECR-sweep clamp to prevent overshoot lockout. All paths fail-closed on Redis unavailability.
  • HMAC service-auth: ${ts}:${method}:${pathname}:${queryHash}:${bodyHash}:${userId} payload binds every internal tool fetch to a specific endpoint, defeating replay across paths. Sign and verify share a single-source canonicalisation module.

Confidence Score: 4/5

The core security properties (HMAC replay defense, nonce anti-hijack, trusted-marker stripping, refresh-token resilience) are well-implemented and tested. The authorize-pro and mcp-grant-mint handlers omit the mcpAccess flag check that both the MCP edge and gateway enforce, meaning the OAuth flow succeeds for users who would then be blocked at every tool call — a confusing UX break, not a security bypass.

The grant flow in authorize-pro.ts and mcp-grant-mint.ts gates only on tier >= 1, while all downstream enforcement requires mcpAccess === true. A user who somehow has tier >= 1 but no mcpAccess flag would successfully complete the OAuth flow, receive a Convex mcpProTokens row, and hold a bearer token that is rejected on every tools/call. The AuthorizeProDeps.getEntitlements type actively hides mcpAccess from the handler. In current production the two flags are co-extensive, so no user is actually stuck, but the type-level inconsistency is a latent defect in newly introduced code that the product catalog could expose.

api/oauth/authorize-pro.ts and api/internal/mcp-grant-mint.ts — both omit the mcpAccess check in their entitlement gates and narrow the getEntitlements return type to exclude the field.

Important Files Changed

Filename Overview
api/mcp.ts Core MCP handler rewritten to support Pro discriminated-union auth context with atomic INCR-first 50/day quota, DECR rollback on failure, per-minute rate limiting for both paths, Pro pre-checks (Convex validate + entitlement re-check), and request-bound HMAC headers for internal tool fetches. Logic is sound; quota math and rollback paths are well-handled.
api/oauth/authorize-pro.ts New endpoint that finishes the Pro OAuth grant: HMAC-verify → nonce equality → GETDEL both Redis records → redirect_uri re-check → entitlement → Convex issue → SETEX OAuth code → 302. The entitlement gate only checks tier >= 1, omitting mcpAccess === true that both the MCP edge and gateway require, and the AuthorizeProDeps.getEntitlements type actively hides the field.
api/internal/mcp-grant-mint.ts New apex-domain endpoint that mints an HMAC-signed grant token. F2 anti-hijack (SET NX + GET + userId compare) and idempotent same-user re-sign path are well-implemented. Same mcpAccess omission as authorize-pro.ts: the MintDeps.getEntitlements type and line-238 guard only check tier >= 1.
server/_shared/pro-mcp-token.ts New edge helper implementing the discriminated-union validate (valid/revoked/transient), negative-cache, issue, revoke, and daily counter key. Carefully distinguishes revoked vs transient to protect refresh tokens from Convex blips (F3). Counter key and TTL semantics are well-documented. No issues found.
server/_shared/mcp-internal-hmac.ts Sign/verify HMAC module binding ts:method:pathname:queryHash:bodyHash:userId. Single source of truth for canonicalisation. Per-process random nonce for x-wm-mcp-internal-verified, constant-time comparison, timestamp-window rejection, body-shape guards (Blob/FormData/ReadableStream throw). Well-hardened.
server/gateway.ts Gateway updated with trusted-marker strip, body-size cap (256 KB), HMAC pre-check path, entitlement re-check, and sanitised Request reconstruction. Correctly short-circuits to 401 on HMAC present-but-invalid, strips raw sig headers before setting nonce-protected verified markers.
api/oauth/token.ts JS→TS rewrite with discriminated-union branching on kind:'pro' for both authorization_code and refresh_token grants. Refresh-token transient resilience (F3) correctly distinguishes revoked vs transient, restoring consumed refresh token to Redis on Convex blip. Legacy shapes preserved verbatim.
convex/mcpProTokens.ts New Convex table with per-user 5-row cap (eventually-MAX rotation for race safety), debounced lastUsedAt touch, and internal HTTP routes for issue/validate/revoke. Entitlement gate only checks tier >= 1, consistent with the edge gap, but Convex's own check is the final authoritative insert gate.
server/_shared/premium-check.ts Updated isCallerPremium to handle the internal-MCP nonce path: compares the per-process verified nonce with timing-safe loop, then does a defensive getEntitlements check (including mcpAccess === true) before returning true. Correctly falls through to legacy auth on nonce mismatch.

Reviews (1): Last reviewed commit: "chore(pro-marketing): regenerate /pro bu..." | Re-trigger Greptile

Comment thread api/oauth/authorize-pro.ts Outdated
Comment on lines +210 to +211
getEntitlements: (userId: string) => Promise<{ features: { tier: number }; validUntil: number } | null>;
/** Issues the Convex mcpProTokens row. Throws ProMcpIssueFailed on failure. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 mcpAccess flag not checked during Pro OAuth grant

The AuthorizeProDeps.getEntitlements interface at line 210 is typed as Promise<{ features: { tier: number }; validUntil: number } | null>, omitting mcpAccess. As a result, the entitlement check at line 358 only validates tier >= 1, while every downstream enforcement point (runProPreChecks in api/mcp.ts:1196-1198 and the gateway at gateway.ts:615) requires mcpAccess === true. A user with tier >= 1 but mcpAccess === false would successfully complete the OAuth flow and get a Convex mcpProTokens row and bearer token issued, but every subsequent tools/call would be rejected with "Subscription not active." The same gap exists in api/internal/mcp-grant-mint.ts:238 and in the Convex issueProMcpToken mutation, which also only gates on tier >= 1. The current product catalog makes mcpAccess: true co-extensive with tier >= 1, so no user is currently blocked — but the narrowed interface type will silently prevent a future inconsistency from being caught.

Comment thread api/internal/mcp-grant-mint.ts Outdated
*/
redisSetNxEx: (key: string, value: unknown, ttlSeconds: number) => Promise<boolean>;
/** Returns Pro entitlement info or null. */
getEntitlements: (userId: string) => Promise<{ features: { tier: number }; validUntil: number } | null>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Same missing mcpAccess check as authorize-pro.ts: the handler only validates tier >= 1, but both the MCP edge (api/mcp.ts:1196) and the gateway (gateway.ts:615) reject requests unless mcpAccess === true. Widening the MintDeps.getEntitlements return type and adding the flag check here makes the grant flow consistent with enforcement.

Suggested change
getEntitlements: (userId: string) => Promise<{ features: { tier: number }; validUntil: number } | null>;
getEntitlements: (userId: string) => Promise<{ features: { tier: number; mcpAccess?: boolean }; validUntil: number } | null>;

Comment thread api/internal/mcp-grant-mint.ts Outdated
*/
redisSetNxEx: (key: string, value: unknown, ttlSeconds: number) => Promise<boolean>;
/** Returns Pro entitlement info or null. */
getEntitlements: (userId: string) => Promise<{ features: { tier: number }; validUntil: number } | null>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Same missing mcpAccess check as authorize-pro.ts: the handler only validates tier >= 1, but both the MCP edge (api/mcp.ts:1196) and the gateway (gateway.ts:615) reject requests unless mcpAccess === true. Widening the MintDeps.getEntitlements return type and adding the flag check here makes the grant flow consistent with enforcement.

Suggested change
getEntitlements: (userId: string) => Promise<{ features: { tier: number }; validUntil: number } | null>;
getEntitlements: (userId: string) => Promise<{ features: { tier: number; mcpAccess?: boolean }; validUntil: number } | null>;

Comment thread api/internal/mcp-grant-mint.ts Outdated
Comment on lines +238 to +240
if (!ent || ent.features.tier < 1 || ent.validUntil < now) {
return jsonError('INSUFFICIENT_TIER', 'A WorldMonitor Pro subscription is required.', 403);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 The mcpAccess flag is absent from this entitlement gate. Adding it here makes the mint step consistent with the MCP edge and gateway — and prevents issuing a grant for a user whose plan includes tier >= 1 but explicitly excludes mcpAccess.

Suggested change
if (!ent || ent.features.tier < 1 || ent.validUntil < now) {
return jsonError('INSUFFICIENT_TIER', 'A WorldMonitor Pro subscription is required.', 403);
}
if (!ent || ent.features.tier < 1 || ent.features.mcpAccess !== true || ent.validUntil < now) {
return jsonError('INSUFFICIENT_TIER', 'A WorldMonitor Pro subscription is required.', 403);
}

Comment thread api/oauth/authorize-pro.ts Outdated
/** Verifies the wire-format HMAC grant. */
verifyGrant: typeof verifyGrant;
/** Returns Pro entitlement info or null. */
getEntitlements: (userId: string) => Promise<{ features: { tier: number }; validUntil: number } | null>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 The type interface narrows out mcpAccess, and the gate only checks tier >= 1. Widening the type and adding the flag check here makes the grant step consistent with downstream enforcement in runProPreChecks and the gateway.

Suggested change
getEntitlements: (userId: string) => Promise<{ features: { tier: number }; validUntil: number } | null>;
getEntitlements: (userId: string) => Promise<{ features: { tier: number; mcpAccess?: boolean }; validUntil: number } | null>;

Comment thread api/oauth/authorize-pro.ts Outdated
Comment on lines +358 to +364
if (!ent || ent.features.tier < 1 || ent.validUntil < now) {
return htmlError(
'Pro Subscription Required',
'A WorldMonitor Pro subscription is required for this connection. Please subscribe and try again.',
403,
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Once mcpAccess is exposed in the interface, add it to the gate condition. A user who reaches this point with tier >= 1 but mcpAccess === false would get a Convex row and bearer token, then be rejected on every tool call.

Suggested change
if (!ent || ent.features.tier < 1 || ent.validUntil < now) {
return htmlError(
'Pro Subscription Required',
'A WorldMonitor Pro subscription is required for this connection. Please subscribe and try again.',
403,
);
}
if (!ent || ent.features.tier < 1 || ent.features.mcpAccess !== true || ent.validUntil < now) {
return htmlError(
'Pro Subscription Required',
'A WorldMonitor Pro subscription is required for this connection. Please subscribe and try again.',
403,
);
}

Round-2 review on PR #3646 surfaced 5 must-fix issues:

P1 — vercel.json `(?:...)` rejected by Vercel CI:
- Replace `mcp-grant(?:\\.html)?` with explicit `mcp-grant\\.html|mcp-grant`
  inside the SPA catch-all negative-lookahead (lines 18 + 134).
- Split the headers `/mcp-grant(?:\\.html)?` source rule into two explicit
  entries — Vercel's path-to-regexp `source` field doesn't accept `(?:...)`
  with `?` quantifier as a standalone source.
- Update tests/deploy-config.test.mjs expectations to match.

P1 — api/api-route-exceptions.json points at deleted file:
- Update the entry path from `api/oauth/token.js` (deleted in U6) to
  `api/oauth/token.ts`. `node scripts/enforce-sebuf-api-contract.mjs`
  was failing pre-push CI.

P1 — mcpAccess migration gap on existing entitlement rows:
- convex/entitlements.ts now read-time merges `getFeaturesForPlan(planKey)`
  defaults under stored features. Pre-U10 rows lacking `mcpAccess` see the
  catalog default surfaced immediately, with NO wait for the next Dodo
  webhook to rewrite the row. Stored features still win on conflict
  (preserves per-user overrides). Mirrored explicitly in
  convex/mcpProTokens.ts:55 (issueProMcpToken's direct ctx.db.query path
  computes the same merge inline since it bypasses the query handler).

P2 — grant/issue path missing mcpAccess gate (let tier-1-without-mcpAccess
users complete OAuth then fail every tools/call at the gateway):
- mcp-grant-context.ts:95, mcp-grant-mint.ts:236, authorize-pro.ts:356,
  convex/mcpProTokens.ts:55 now ALL gate on `tier ≥ 1 && mcpAccess === true`,
  matching the downstream MCP-edge runProPreChecks check. Widens the
  `getEntitlements` deps return type at all three handlers.

P3 — inline theme script blocked by global CSP:
- mcp-grant.html:34 inline `<script>` is removed (global CSP at vercel.json:92
  is hash-allowlist-based and we don't allowlist this page's hashes).
- Theme application moved into the bundled module at src/mcp-grant-main.ts
  (top of file, runs on module evaluation). Brief default-theme flash on
  light-preference users is acceptable for a transient consent UI.

10 new tests covering: mcpAccess: false at each of the 4 gates,
mcpAccess: undefined (legacy row) at each of the 4 gates, and the
read-time catalog merge in both directions (legacy gets default,
override preserved).

Full gate post-fix:
- Convex 249/249 (+2 new merge tests).
- Edge/server 280/280 (+10 new gate tests).
- tsc both configs clean.
- sebuf contract clean (was failing before P1.2).
- Sentry coverage clean.
- deploy-config tests assert new explicit-source rules.

Plan: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
@vercel

vercel Bot commented May 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 10, 2026 3:56pm

Request Review

… as stale

Reviewer round-2 P2 (cache layer): _getEntitlementsImpl returned hot
Redis cache entries as-is, bypassing the read-time catalog merge that
convex/entitlements.ts:50 applies on the Convex read path. Paying users
with cache entries written before plan 2026-05-10-001 U10 deployed see
mcpAccess !== true at the grant/MCP gates and are blocked for up to
the 15-min cache TTL.

Cache predicate now also requires `typeof features.mcpAccess === 'boolean'`.
Legacy entries (mcpAccess: undefined) fall through to Convex, which
returns the merged shape and the cache is rewritten with the post-U10
layout. Self-healing, bounded to one extra Convex round-trip per
affected user during the migration window.

Targeted choice over alternatives:
- Importing convex/config/productCatalog into server/_shared to apply
  the merge at the cache layer would pull non-edge-safe deps. Rejected.
- Treating ALL cached entries as stale would defeat the cache layer.
  Rejected.
- typeof check on the new field is the minimum delta that fixes the
  migration window without restructuring.

Test fixture in server/__tests__/entitlement-check.test.ts updated:
makeEntitlements now includes mcpAccess (tier ≥ 1 → true). Two new
focused tests:
1. Legacy cache entry without mcpAccess → cache rejected → Convex
   round-trip → result returned (verifies fetch called once).
2. Post-U10 cache entry WITH mcpAccess → cache honored → no Convex
   round-trip (verifies fetch called zero times).

Convex 251/251 (+2 new cache tests).

Plan: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
# Conflicts:
#	public/pro/assets/index-9aEWTEJv.js
#	public/pro/assets/index-C-qy2Yt9.js
#	public/pro/assets/index-Co-dW3DV.js
#	public/pro/index.html
#	server/_shared/usage.ts
@koala73
koala73 merged commit b68ae92 into main May 10, 2026
12 checks passed
@koala73
koala73 deleted the feat/pro-mcp-clerk-auth branch May 10, 2026 16:20
koala73 added a commit that referenced this pull request May 10, 2026
…3653)

TEMPORARY DIAGNOSTIC. Production reports "MCP authorization is
temporarily unavailable" on every Pro authorize click after PR #3646
merged. The mint endpoint is throwing GrantConfigError, which means
process.env.MCP_PRO_GRANT_HMAC_SECRET is empty/unset at the apex
function — but the dashboard's Shared tab shows the variable linked
to Production+Preview, and `vercel env ls production | grep MCP_`
returns no match.

Two unknowns to disambiguate:
  (a) Is the variable actually reaching the function process? Or
      is the Shared link not really applied?
  (b) If reaching, is the name correct (no typo / wrong key)?

This logs the NAMES (not values) of every process.env key starting
with `MCP_` next to the GrantConfigError throw so the next failed
authorize click in production shows conclusively:
  - <NONE>: link didn't apply / wrong project / scope mismatch
  - only MCP_INTERNAL_HMAC_SECRET: typo or partial link
  - includes MCP_PRO_GRANT_HMAC_SECRET: linked but empty (also
    surfaced as the existing GrantConfigError)

Removed in the immediately-following commit once root cause is
confirmed. Names only — no values.

After deploy, reproduce the failure once and search Vercel function
logs for the literal string `[mcp-grant-hmac] DIAGNOSTIC missing-secret`.
koala73 added a commit that referenced this pull request May 10, 2026
…3654)

* docs(mcp-server): tier-aware lede + Plans & limits comparison table

Two improvements requested after PR #3646 landed in production:

1. Lede emphasises that BOTH Pro and API tiers can OAuth-sign-in (the
   prior copy implied OAuth was Pro-only). The functional difference
   between tiers is rate limits, not auth surface.

2. New "Plans & limits" table replacing the previous bullet list. One
   row per plan (Free / Pro / API Starter / API Business / Enterprise)
   with MCP access, auth modes, daily cap, per-minute cap, and notes.
   Highlights:
   - Free: no MCP (401 at OAuth)
   - Pro: 50 tool calls/UTC day (the only hard daily quota)
   - API Starter / Business / Enterprise: no daily cap, 60/min/key
   - Pro's 60/min is per-USER (shared across installs);
     API tiers' 60/min is per-KEY (multiple keys = higher throughput)

Plus a clarifying paragraph on what doesn't count: `tools/list` and
`initialize` are never charged against either limit.

No code changes. MDX lint + markdown lint clean.

* docs(mcp-server): correct daily quotas — API Starter 1k, Business 10k, Enterprise unlimited

Round-2 user feedback on PR #3654: my prior commit said "no daily cap"
for API tiers, which is wrong. Marketed product policy is:
  - API Starter:   1,000 requests / day
  - API Business: 10,000 requests / day
  - Enterprise:   unlimited

These daily caps are advertised to customers but NOT yet enforced in
code (per-minute caps are; daily caps are on the implementation
backlog). Documenting the marketed numbers — internal reviewers can see
the gap from grep'ing apiRateLimit consumers.

Other adjustments:
- Lede: "differ only in daily quotas" instead of "Pro is the only one
  with a daily cap" (no longer accurate).
- "Daily limit (Pro tier)" callout: add a "Need a higher allowance?"
  pointer to API Starter/Business/Enterprise rather than the prior
  "API tiers have no daily cap" line.
- Plans & limits table: collapsed Daily/Per-minute into a single Daily
  quota column (per-minute is uniform 60/min across paid tiers, called
  out separately below the table to avoid table noise).

* docs(mcp-server): align lede tool count with body — 32 (not 36+)
koala73 added a commit that referenced this pull request May 11, 2026
…3658)

* feat(mcp): add get_displacement_data tool — Tier 1 brainstorm-regression

Restores the UNHCR refugee/IDP cache-tool that was in the 2026-03-27
brainstorm v1 inventory but missing from PR #3646's TOOL_REGISTRY.

- New cache-tool entry in api/mcp.ts::TOOL_REGISTRY (after
  get_sanctions_data). Single cache key displacement:summary:v1:<UTC year>
  resolved at module-load via new Date().getUTCFullYear() — mirrors
  api/health.js:147's STANDALONE_KEYS pattern.
- _seedMetaKey: seed-meta:displacement:summary (writer at
  scripts/seed-displacement-summary.mjs:232).
- _maxStaleMin: 3600 (mirrors seeder line 238).
- Current-year only — including displacementPrev would collide on the
  'summary' label produced by executeTool's numeric-suffix-stripping
  label-walk at api/mcp.ts:796.

Tests: 32→33 count + registration check + happy/cache-empty smoke tests.
53/53 mcp.test.mjs pass.

Plan unit U1: docs/plans/2026-05-11-001-feat-mcp-coverage-tier1-tier2-plan.md

* feat(mcp): add get_health_signals tool — disease outbreaks + health air-quality (U2)

* feat(mcp): add get_energy_intelligence tool — 9-key energy bundle (U3)

* feat(mcp): add get_consumer_prices hybrid tool with country_code param (U4)

* feat(mcp): add get_tariff_trends tool — US tariffs + BigMac + FAO + national debt (U5)

* feat(mcp): add get_chokepoint_status tool — 6-key bundle, payload-verified (U6)

* test(mcp): Tier 3 BOOTSTRAP_KEYS↔TOOL_REGISTRY parity test + EXCLUDED_FROM_MCP (U7)

* docs(mcp-server): 32→38 count + 6 catalog rows for new tools (U8)

After U1-U6 added get_displacement_data, get_health_signals,
get_energy_intelligence, get_consumer_prices, get_tariff_trends, and
get_chokepoint_status to TOOL_REGISTRY, public docs need to match.

- Count flips at lines 12 and 164: "32 tools" → "38 tools" (2 sites).
- 6 new catalog rows added in domain sections:
  - Markets & economy: get_consumer_prices, get_tariff_trends,
    get_chokepoint_status (sibling of get_supply_chain_data)
  - New `### Energy` subsection: get_energy_intelligence
  - New `### Health` subsection: get_health_signals
  - New `### Humanitarian & displacement` subsection: get_displacement_data

Descriptions copied from each tool's TOOL_REGISTRY description field
(lightly trimmed for table-cell readability). MDX lint clean (126/126).

Plan unit U8.

* test+fix(mcp): residual Tier-2 review fixes for Tier-1+2 coverage PR

Three findings from the senior + correctness + testing review of the 8-commit
Tier-1+2 MCP coverage diff. All address real divergences from the F6
quota-correctness contract or coverage gaps the live registry would not
catch.

### U4 — `cache_all_null` parity guard (correctness)
Hybrid `_execute` for `get_consumer_prices` skipped the F6 cache_all_null
contract that every CacheToolDef path enforces in executeTool:1139-1144.
Result: on degenerate-empty Redis responses (transient/stampede/pre-seed),
the Pro quota counter incremented for a useless 5-null result while every
sibling cache-tool refunded via the throw → DECR rollback path. Added the
same guard at the end of `_execute` to restore symmetric contract behavior.

### U4 — strict ISO 3166-1 alpha-2 country_code validation (correctness)
`.toLowerCase().slice(0,2)` silently coerced "aexxx" / "AE-DXB" / "1AE" to
"ae" → served AE data with no error, masking client-side bugs. Tightened to
`/^[a-z]{2}$/` after lowercase; oversized/non-alpha now returns a
result-level error with a usable message.

### U3 — `get_energy_intelligence` behavioral tests (coverage gap)
Broadest 9-key bundle in the diff had zero `tools/call` coverage AND was
missing from the registration-name assert block (only the `tools.length ===
38` count check would have caught a deregistration). Added 3 behavioral
tests mirroring the U6 pattern: 9-fresh happy path with label-walked slice
assertions, one-key-stale → aggregate stale=true, all-9-null → -32603.

### U7 — meta-tests for parity predicate logic (coverage gap)
Parity test's 4 live assertions only fire when real drift exists; nothing
verified the predicates THEMSELVES would catch regressions (predicate
inversion, off-by-one, early return). Refactored predicate logic into 6
pure exported helpers and added 7 fixture-based meta-tests asserting each
helper fires on synthetic invalid inputs (uncovered key, empty/whitespace
reason, dead exclusion, redundant exclusion, _cacheKeys+_coverageKeys
aggregation, BOOTSTRAP+STANDALONE dedupe).

### Test counts
- tests/mcp.test.mjs: 73 → 77 (+3 U3 + flipped 1 U4 all-null + 1 new U4 strict-shape)
- tests/mcp-bootstrap-parity.test.mjs: 4 → 11 (+7 meta-tests)
- All 84 pass; TypeScript clean.

### Findings explicitly accepted (not patched)
- Tariffs `data['all']` label (plan-asserted)
- U1 year-boundary module-load pinning (plan-documented design choice)
- `_countries`/`ref` label brittleness (no collision today, speculative)
- Tool count 38 not cross-locked to docs (drift not present)
- Producer-side key parity for `:v1` shapes (out of scope)

* style(mcp-tests): satisfy biome — drop noExportsInTest + reduce U3 cognitive complexity

CI biome lint surfaced two violations from the residual review-fix commit:

1. `lint/suspicious/noExportsInTest` × 6 in tests/mcp-bootstrap-parity.test.mjs —
   the U7 meta-test refactor exported 6 predicate helpers so they could be
   meta-tested. Test files must not export. Helpers are only called from
   describe blocks in the same file, so the `export` keyword was always
   gratuitous — dropped it.

2. `lint/complexity/noExcessiveCognitiveComplexity` in U3 happy-path test —
   the 18-branch if-chain in the fetch mock (9 data keys + 9 meta keys)
   exceeded biome's cognitive-complexity ceiling. Replaced with a single
   Map<cache-key, payload> + for-of loop. Functionally identical, much lower
   cyclomatic weight.

Pre-existing biome violations in unrelated files (api/mcp-proxy.js,
tests/webmcp.test.mjs, api/health.js:678) are NOT in this PR's diff and
remain for a separate cleanup.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant