Skip to content

Commit 470ea64

Browse files
committed
fix(mcp): accept customer wm_ API keys on /mcp + guard pro-token validate (#4859, #4860)
Two P0s from a paying-customer ticket (2026-07-05): 1. #4859 — /mcp validated X-WorldMonitor-Key ONLY against the WORLDMONITOR_VALID_KEYS env allowlist, so every dashboard-issued key (Convex userApiKeys) got 401 -32001 "Invalid API key" while the same keys worked on REST — and the endpoint's own 401 hint told users to send exactly that header. New `user_key` context kind: env-miss falls back to the gateway-shared validateUserApiKey hash lookup; gated methods enforce the OWNER's mcpAccess entitlement via the same fail-closed gate as the pro path (never mapped onto env_key, which skips pre-checks); per-user 60/min limiter + the pro daily quota (cache tools bypass downstream metering, so an unquota'd user_key would be an unmetered loophole); _execute fetches authenticate downstream with the raw key so REST metering attributes to the owner. 2. #4860 — `await deps.validateProMcpToken(...)` in runProPreChecks was the only unguarded await on the gated path with no top-level catch behind it: a rejection escaped as a raw 500 on every tools/call (tools/list unaffected, zero Sentry) — the exact fingerprint of the customer's second symptom. Now caught -> captureSilentError (step: pro-token-validate) -> structured 503 + Retry-After. Makes the documented contract (docs/mcp-overview.mdx auth method 2, mcp-quickstart curl examples) actually true for user-issued keys. Tests: tests/mcp-user-key-auth.test.mjs (10 cases, failing-first); mcp/quota/conformance/transport/telemetry/gateway/oauth suites green (360 tests); typecheck:api + biome clean. Claude-Session: https://claude.ai/code/session_01Ex9zkdxdbxYMogHmEK2YXZ
1 parent f30445b commit 470ea64

7 files changed

Lines changed: 346 additions & 38 deletions

File tree

api/mcp/auth.ts

Lines changed: 129 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
signInternalMcpRequest,
1717
} from '../../server/_shared/mcp-internal-hmac';
1818
import { validateProMcpTokenOrNull } from '../../server/_shared/pro-mcp-token';
19+
import { validateUserApiKey } from '../../server/_shared/user-api-key';
1920
import { rpcError, withMcpNoStore } from './rpc';
2021
import type {
2122
AuthResolution,
@@ -101,7 +102,11 @@ export async function buildAuthHeaders(
101102
url: string,
102103
body: BodyInit | null | undefined,
103104
): Promise<Record<string, string>> {
104-
if (context.kind === 'env_key') {
105+
if (context.kind === 'env_key' || context.kind === 'user_key') {
106+
// user_key (#4859): the downstream REST gateway validates the raw key
107+
// itself (Convex hash lookup + the #4611 apiAccess gate + per-account
108+
// limits), so usage attributes to the key owner exactly like a direct
109+
// REST call — no internal-HMAC identity smuggling needed.
105110
return { 'X-WorldMonitor-Key': context.apiKey };
106111
}
107112
// context.kind === 'pro'
@@ -131,6 +136,7 @@ export const PRODUCTION_DEPS: McpHandlerDeps = {
131136
// to distinguish revoked from transient (F3 of the U7+U8 review pass).
132137
validateProMcpToken: validateProMcpTokenOrNull,
133138
getEntitlements,
139+
validateUserApiKey,
134140
redisPipeline: rawRedisPipeline,
135141
};
136142

@@ -188,16 +194,44 @@ export async function resolveAuthContext(
188194
};
189195
}
190196
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
191-
if (!await timingSafeIncludes(candidateKey, validKeys)) {
192-
return {
193-
ok: false,
194-
response: new Response(
195-
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Invalid API key' } }),
196-
{ status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) },
197-
),
198-
};
197+
if (await timingSafeIncludes(candidateKey, validKeys)) {
198+
return { ok: true, context: { kind: 'env_key', apiKey: candidateKey } };
199199
}
200-
return { ok: true, context: { kind: 'env_key', apiKey: candidateKey } };
200+
201+
// #4859: customer-issued dashboard keys (Convex userApiKeys). The env
202+
// allowlist above holds only legacy operator keys; every key a user mints
203+
// in the dashboard lives in Convex — before this fallback, ALL of them got
204+
// "Invalid API key" here while the same keys worked on the REST gateway.
205+
// Identity resolution only: the owner's mcpAccess entitlement is enforced
206+
// at the gated-method pre-check (runUserKeyPreChecks), symmetric with the
207+
// pro path, so a lapsed owner can still list tools but never call them.
208+
if (candidateKey.startsWith('wm_')) {
209+
let userKey: { userId: string } | null = null;
210+
try {
211+
userKey = await deps.validateUserApiKey(candidateKey);
212+
} catch {
213+
// Production validateUserApiKey fail-softs to null; a throw means the
214+
// auth backend itself is unreachable — 503 mirrors the bearer path.
215+
return {
216+
ok: false,
217+
response: new Response(
218+
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Auth service temporarily unavailable. Try again.' } }),
219+
{ status: 503, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'Retry-After': '5', ...corsHeaders }) },
220+
),
221+
};
222+
}
223+
if (userKey) {
224+
return { ok: true, context: { kind: 'user_key', apiKey: candidateKey, userId: userKey.userId } };
225+
}
226+
}
227+
228+
return {
229+
ok: false,
230+
response: new Response(
231+
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Invalid API key' } }),
232+
{ status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) },
233+
),
234+
};
201235
}
202236

203237
/**
@@ -227,40 +261,111 @@ export async function runProPreChecks(
227261
);
228262
}
229263

230-
const validation = await deps.validateProMcpToken(context.mcpTokenId);
264+
// #4860: this await was the only unguarded step on the gated path — the
265+
// wired helper never rejects today, but a rejection here previously escaped
266+
// mcpHandler (no top-level catch) as a raw 500 with zero Sentry. Fail
267+
// closed with the same retryable 503 shape as the bearer-resolve catch.
268+
let validation: Awaited<ReturnType<typeof deps.validateProMcpToken>> = null;
269+
try {
270+
validation = await deps.validateProMcpToken(context.mcpTokenId);
271+
} catch (err) {
272+
captureSilentError(err, { tags: { route: 'api/mcp', step: 'pro-token-validate' }, ctx });
273+
return new Response(
274+
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Service temporarily unavailable, retry in a moment.' } }),
275+
{ status: 503, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'Retry-After': '5', ...corsHeaders }) },
276+
);
277+
}
231278
if (!validation || validation.userId !== context.userId) {
232279
return new Response(
233280
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'MCP authorization revoked. Re-authorize at https://worldmonitor.app/mcp-grant.' } }),
234281
{ status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) },
235282
);
236283
}
237284

285+
return checkMcpEntitlementGate(context.userId, deps, resourceMetadataUrl, corsHeaders, 'pro-entitlement-recheck', ctx);
286+
}
287+
288+
/**
289+
* Shared mcpAccess entitlement gate for identity-resolved contexts (pro AND
290+
* user_key). Fail-closed per memory `entitlement-signal-server-outlier-sweep`.
291+
* Returns null when the owner has an active tier>=1 + mcpAccess entitlement;
292+
* a 401 Response otherwise.
293+
*/
294+
async function checkMcpEntitlementGate(
295+
userId: string,
296+
deps: McpHandlerDeps,
297+
resourceMetadataUrl: string,
298+
corsHeaders: Record<string, string>,
299+
sentryStep: string,
300+
ctx?: { waitUntil: (p: Promise<unknown>) => void },
301+
): Promise<Response | null> {
302+
const rejected = () => new Response(
303+
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Subscription not active.' } }),
304+
{ status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) },
305+
);
306+
238307
let ent: Awaited<ReturnType<typeof deps.getEntitlements>> = null;
239308
try {
240-
ent = await deps.getEntitlements(context.userId);
309+
ent = await deps.getEntitlements(userId);
241310
} catch (err) {
242-
// Fail-closed per memory `entitlement-signal-server-outlier-sweep`.
243-
captureSilentError(err, { tags: { route: 'api/mcp', step: 'pro-entitlement-recheck' }, ctx });
244-
return new Response(
245-
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Subscription not active.' } }),
246-
{ status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) },
247-
);
311+
captureSilentError(err, { tags: { route: 'api/mcp', step: sentryStep }, ctx });
312+
return rejected();
248313
}
249314
const tier = ent?.features?.tier ?? 0;
250315
const mcpAccess = ent?.features?.mcpAccess === true;
251316
const validUntil = ent?.validUntil ?? 0;
252317
if (!ent || tier < 1 || !mcpAccess || validUntil < Date.now()) {
253-
return new Response(
254-
JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Subscription not active.' } }),
255-
{ status: 401, headers: withMcpNoStore({ 'Content-Type': 'application/json', 'WWW-Authenticate': wwwAuthHeader(resourceMetadataUrl, 'invalid_token'), ...corsHeaders }) },
256-
);
318+
return rejected();
319+
}
320+
return null;
321+
}
322+
323+
/**
324+
* user_key (#4859) pre-check: the key row proved identity at auth-resolution
325+
* time; data methods must additionally verify the OWNER still has an active
326+
* mcpAccess entitlement. Without this, a user_key context would be the one
327+
* credential class that skips the entitlement gate (env_key is operator-owned
328+
* and intentionally ungated; pro re-checks on every gated call).
329+
*/
330+
export async function runUserKeyPreChecks(
331+
context: Extract<McpAuthContext, { kind: 'user_key' }>,
332+
deps: McpHandlerDeps,
333+
resourceMetadataUrl: string,
334+
corsHeaders: Record<string, string>,
335+
ctx?: { waitUntil: (p: Promise<unknown>) => void },
336+
): Promise<Response | null> {
337+
return checkMcpEntitlementGate(context.userId, deps, resourceMetadataUrl, corsHeaders, 'user-key-entitlement', ctx);
338+
}
339+
340+
/**
341+
* Kind-dispatched pre-checks for gated (data/quota) methods. env_key needs
342+
* none; pro and user_key each run their own. Single entry point so a future
343+
* context kind can't silently ship without deciding its gate (the tracer
344+
* finding on #4859: mapping user keys onto env_key would have bypassed
345+
* entitlements entirely).
346+
*/
347+
export async function runContextPreChecks(
348+
context: McpAuthContext,
349+
deps: McpHandlerDeps,
350+
resourceMetadataUrl: string,
351+
corsHeaders: Record<string, string>,
352+
ctx?: { waitUntil: (p: Promise<unknown>) => void },
353+
): Promise<Response | null> {
354+
if (context.kind === 'pro') {
355+
return runProPreChecks(context, deps, resourceMetadataUrl, corsHeaders, ctx);
356+
}
357+
if (context.kind === 'user_key') {
358+
return runUserKeyPreChecks(context, deps, resourceMetadataUrl, corsHeaders, ctx);
257359
}
258360
return null;
259361
}
260362

261363
/** Per-minute rate limit. Both paths fail-OPEN on Upstash error (graceful);
262364
* the daily quota is the hard-cap fail-CLOSED gate. Returns null on success
263-
* or pass-through, a Response on a real 60/min limit hit. */
365+
* or pass-through, a Response on a real 60/min limit hit.
366+
* user_key (#4859) shares the per-USER limiter with pro — the principal is
367+
* the key OWNER, so a user with an OAuth connection and a dashboard key gets
368+
* one combined 60/min budget instead of two stackable ones. */
264369
export async function applyPerMinuteLimit(context: McpAuthContext, headers: Record<string, string> = {}): Promise<Response | null> {
265370
if (context.kind === 'env_key') {
266371
const rl = getMcpRatelimit();
@@ -275,7 +380,7 @@ export async function applyPerMinuteLimit(context: McpAuthContext, headers: Reco
275380
if (!rl) return null;
276381
try {
277382
const { success } = await rl.limit(`pro-user:${context.userId}`);
278-
if (!success) return rpcError(null, -32029, 'Rate limit exceeded. Max 60 requests per minute per Pro user.', headers);
383+
if (!success) return rpcError(null, -32029, 'Rate limit exceeded. Max 60 requests per minute per user.', headers);
279384
} catch { /* graceful degradation */ }
280385
return null;
281386
}

api/mcp/dispatch.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,12 @@ export async function dispatchToolsCall(
134134
// 50/day cap from even seeing tool definitions. Exempt by name; rate-
135135
// limiter (60/min) still applies as the abuse guard.
136136
const isMetadataTool = p.name === 'describe_tool';
137-
if (context.kind === 'pro' && !isMetadataTool) {
137+
// user_key (#4859) consumes the same per-user daily quota as pro: cache
138+
// tools read Upstash directly (no downstream gateway metering), so an
139+
// unquota'd user_key would be an unmetered data loophole bounded only by
140+
// the 60/min limiter. Raising API-plan MCP allowances above the Pro cap is
141+
// a deliberate follow-up, not a default.
142+
if ((context.kind === 'pro' || context.kind === 'user_key') && !isMetadataTool) {
138143
const reservation = await reserveQuota(context.userId, deps.redisPipeline);
139144
if (!reservation.ok) {
140145
if (reservation.reason === 'cap-exceeded') {

api/mcp/handler.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
applyPerMinuteLimit,
66
PRODUCTION_DEPS,
77
resolveAuthContext,
8-
runProPreChecks,
8+
runContextPreChecks,
99
wwwAuthHeader,
1010
} from './auth';
1111
import {
@@ -399,10 +399,8 @@ export async function mcpHandler(
399399
}
400400
const auth = await resolveAuthContext(req, deps, resourceMetadataUrl, corsHeaders);
401401
if (!auth.ok) return auth.response;
402-
if (auth.context.kind === 'pro') {
403-
const proCheck = await runProPreChecks(auth.context, deps, resourceMetadataUrl, corsHeaders, ctx);
404-
if (proCheck) return proCheck;
405-
}
402+
const getPreCheck = await runContextPreChecks(auth.context, deps, resourceMetadataUrl, corsHeaders, ctx);
403+
if (getPreCheck) return getPreCheck;
406404
const getLimited = await applyPerMinuteLimit(auth.context, corsHeaders);
407405
if (getLimited) return getLimited;
408406
return handleSseReplay(req, corsHeaders);
@@ -467,10 +465,8 @@ export async function mcpHandler(
467465
const auth = await resolveAuthContext(req, deps, resourceMetadataUrl, corsHeaders);
468466
if (!auth.ok) return auth.response;
469467
context = auth.context;
470-
if (context.kind === 'pro') {
471-
const proCheck = await runProPreChecks(context, deps, resourceMetadataUrl, corsHeaders, ctx);
472-
if (proCheck) return proCheck;
473-
}
468+
const preCheck = await runContextPreChecks(context, deps, resourceMetadataUrl, corsHeaders, ctx);
469+
if (preCheck) return preCheck;
474470
const limited = await applyPerMinuteLimit(context, corsHeaders);
475471
if (limited) return limited;
476472
}

api/mcp/telemetry.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,12 @@ export const MCP_TOOLS_LIST_TELEMETRY_KEYS = Object.freeze([
7171
] as const);
7272

7373
// Log-safe principal id derived from the resolved auth context:
74-
// - Pro: raw Clerk `userId` (internal ID, not a secret; matches the
75-
// REST gateway's `customer_id` convention).
74+
// - Pro / user_key: raw Clerk `userId` (internal ID, not a secret; matches
75+
// the REST gateway's `customer_id` convention — user_key carries
76+
// the resolved key OWNER, #4859).
7677
// - env_key: FNV-64 hash of the API key (secret — never log raw key
7778
// material; mirrors `principal_id` in
7879
// server/_shared/usage-identity.ts).
7980
export function principalIdForLog(context: McpAuthContext): string {
80-
return context.kind === 'pro' ? context.userId : hashKeySync(context.apiKey);
81+
return context.kind === 'env_key' ? hashKeySync(context.apiKey) : context.userId;
8182
}

api/mcp/types.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@
1111

1212
export type McpAuthContext =
1313
| { kind: 'env_key'; apiKey: string }
14-
| { kind: 'pro'; userId: string; mcpTokenId: string };
14+
| { kind: 'pro'; userId: string; mcpTokenId: string }
15+
// Customer-issued dashboard key (Convex userApiKeys, #4859). Carries BOTH
16+
// the raw key (downstream _execute fetches authenticate as the owner via
17+
// X-WorldMonitor-Key, so REST metering/limits attribute to them) AND the
18+
// resolved owner userId (per-user rate limit + daily quota + the mcpAccess
19+
// entitlement pre-check — a user_key context must NEVER skip that gate the
20+
// way env_key does).
21+
| { kind: 'user_key'; apiKey: string; userId: string };
1522

1623
// ---------------------------------------------------------------------------
1724
// Tool registry types
@@ -229,6 +236,11 @@ export interface McpHandlerDeps {
229236
resolveBearerToContext: (token: string) => Promise<McpAuthContext | null>;
230237
validateProMcpToken: (tokenId: string) => Promise<{ userId: string } | null>;
231238
getEntitlements: (userId: string) => Promise<{ planKey?: string; features: { tier: number; mcpAccess?: boolean }; validUntil: number } | null>;
239+
// #4859: Convex userApiKeys hash lookup (same shared helper as the REST
240+
// gateway). Returns the key owner, or null for unknown/revoked keys. The
241+
// production impl fail-softs to null internally; a THROW from a dep is
242+
// treated as auth-backend-transient (503), mirroring resolveBearerToContext.
243+
validateUserApiKey: (key: string) => Promise<{ userId: string } | null>;
232244
redisPipeline: PipelineFn;
233245
}
234246

tests/helpers/mcp-pro-deps.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ export function makeProDeps(overrides = {}) {
8989
features: { tier: 1, mcpAccess: true },
9090
validUntil: Date.now() + 86_400_000,
9191
})),
92+
// #4859 user-key path. Default rejects every key so pre-existing suites
93+
// keep their exact 401 behaviour; user-key tests override with a
94+
// fixture-matching resolver.
95+
validateUserApiKey: overrides.validateUserApiKey ?? (async () => null),
9296
redisPipeline: pipe.pipeline,
9397
},
9498
pipe,

0 commit comments

Comments
 (0)