feat: Pro MCP access via Clerk auth with 50/day quota#3646
Conversation
…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).
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
Deployment failed with the following error: |
Greptile SummaryThis 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 →
Confidence Score: 4/5The core security properties (HMAC replay defense, nonce anti-hijack, trusted-marker stripping, refresh-token resilience) are well-implemented and tested. The The grant flow in
Important Files Changed
Reviews (1): Last reviewed commit: "chore(pro-marketing): regenerate /pro bu..." | Re-trigger Greptile |
| getEntitlements: (userId: string) => Promise<{ features: { tier: number }; validUntil: number } | null>; | ||
| /** Issues the Convex mcpProTokens row. Throws ProMcpIssueFailed on failure. */ |
There was a problem hiding this comment.
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.
| */ | ||
| 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>; |
There was a problem hiding this comment.
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.
| getEntitlements: (userId: string) => Promise<{ features: { tier: number }; validUntil: number } | null>; | |
| getEntitlements: (userId: string) => Promise<{ features: { tier: number; mcpAccess?: boolean }; validUntil: number } | null>; |
| */ | ||
| 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>; |
There was a problem hiding this comment.
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.
| getEntitlements: (userId: string) => Promise<{ features: { tier: number }; validUntil: number } | null>; | |
| getEntitlements: (userId: string) => Promise<{ features: { tier: number; mcpAccess?: boolean }; validUntil: number } | null>; |
| if (!ent || ent.features.tier < 1 || ent.validUntil < now) { | ||
| return jsonError('INSUFFICIENT_TIER', 'A WorldMonitor Pro subscription is required.', 403); | ||
| } |
There was a problem hiding this comment.
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.
| 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); | |
| } |
| /** 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>; |
There was a problem hiding this comment.
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.
| getEntitlements: (userId: string) => Promise<{ features: { tier: number }; validUntil: number } | null>; | |
| getEntitlements: (userId: string) => Promise<{ features: { tier: number; mcpAccess?: boolean }; validUntil: number } | null>; |
| 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, | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.
| 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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
… 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
…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`.
…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+)
…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.
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 apexworldmonitor.app/mcp-grantpage 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_KEYSenv-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)
convex/mcpProTokens.tstable + http routes; userApiKeys untouched.server/_shared/pro-mcp-token.tsedge helper; no positive cache (revoke is authoritative on next request); negative-cache for known-bad bearers./mcp-grant(Vite multi-page entry) +mcp-grant-mint+mcp-grant-contextwith anti-phishing real-client-metadata display./oauth/authorize-proHMAC-verify-first → atomic GETDEL of both Redis nonces → strict tuple match → entitlement re-check → mint →oauth:code:{kind:'pro',...}.McpAuthContextdiscriminated union (env_key|pro); legacy bare-stringoauth:tokenshape preserved verbatim.api/oauth/token.js → token.ts.api/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.server/gateway.ts: HMAC verify, sanitised-Request propagation (strip-then-construct-NEW-Request), per-process random nonce onx-wm-mcp-internal-verified(defense-in-depth beyond plan against direct-edge-function spoof).mcpAccessflag;/api/user/mcp-quotareads SAME Redis key the enforcement writes (single source of truth);/api/user/mcp-revokeClerk-derived userId only.productCatalog.ts mcpAccess: truefor Pro+API+Enterprise;MCP_PRO_GRANT_HMAC_SECRET+MCP_INTERNAL_HMAC_SECRETenv docs;vercel.jsonrewrites;docs/mcp-server.mdxPro flow..mcp-clients-*CSS rules mirroring.api-keys-*./probundle regen — U10 description copy cascaded throughscripts/generate-product-config.mjsintopro-test/src/generated/tiers.json; rebuiltpublic/pro/.Test plan
npm run test:convex).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.tsc --noEmitandtsc -p tsconfig.api.json --noEmitboth clean.tools/callfrom a Pro user at counter=49: exactly 1 succeeds, 99 reject with -32029 + 429./api/news/v1/list-feed-digestcannot be replayed against/api/intelligence/v1/deduct-situation(path mismatch in HMAC payload).tools/list→tools/call (get_market_data)→ success → revoke from settings → next call returns -32001.Pre-deploy checklist
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.api.worldmonitor.app:X-WM-MCP-Internal,X-WM-MCP-User-Id. Memorywarmping-origin-trust-cdn-401-poisoningdocuments CF's history of stripping non-allowlisted headers.CLERK_JWT_ISSUER_DOMAIN,CONVEX_SERVER_SHARED_SECRET,WORLDMONITOR_VALID_KEYS.Deploy ordering (CRITICAL)
mcpProTokenstable +internal-{issue,validate,revoke}-pro-mcp-tokenHTTP routes must exist when MCP edge starts calling them. A Vercel-before-Convex deploy would 401 every Pro tool fetch.mcpAccess: truepropagates. Until then, Pro users seemcpAccess === undefinedand 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).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.mcpProTokensrows in Convex (npx convex data mcpProTokens --limit 20) — confirms Pro authorisations are landing.route:'api/mcp', step:'pro-entitlement-recheck'(would indicate ConvexgetEntitlementsfailing).route:'gateway', step:'invalid_internal_mcp_signature'from production traffic (would indicate sign/verify drift)./api/oauth/authorize-prop99 latency < 500 ms.Failure signals → mitigate:
/api/mcpfor Pro bearers → check Convex availability +validateProMcpToken503 path.MCP_INTERNAL_HMAC_SECRETmismatch between MCP edge and gateway. Mitigation: confirm both env vars match. If urgent, rollback U10'smcpAccess: trueflag (sets to false on Pro → settings tab disappears + Pro path 401s closed).invalid_internal_mcp_signaturein Sentry > 0.1% sustained → header tampering OR sign/verify canonicalisation drift. Investigate immediately.mcp:pro-usage:*keys → adv-002 lockout scenario; the bounded DECR-sweep should converge but watch for Redis tail-latency.Rollback plan:
mcpProTokenstable + http routes are inert without code calling them).mcpAccess: falseinproductCatalog.ts→ next Dodo webhook refresh hides the new tab and Pro MCP path returns 401.Dashboards / queries to watch:
route:'api/mcp'filtered to status 5xx.route:'gateway'filtered toinvalid_internal_mcp_signature.KEYS mcp:pro-usage:*count +KEYS mcp-grant:*count.mcpProTokensinsert + revoke rates.route IN ('api/mcp', 'api/oauth/authorize-pro', 'api/internal/mcp-grant-mint', 'api/user/mcp-revoke', 'gateway').Notable security upgrades beyond the plan
x-wm-mcp-internal-verified(U8) replaces the planned literal'1'. Sweep found direct-edge-function consumers ofisCallerPremium(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.mcp-grant-mintnow atomically claims the nonce to a userId via SET NX semantics and rejects mismatched claims with 403NONCE_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.validateProMcpTokenreturns{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.