Skip to content

Commit 1f18569

Browse files
os-zhuangclaude
andcommitted
fix(mcp): make remote /api/v1/mcp data plane work for external agents
The remote MCP HTTP surface (ADR-0036) was non-functional for external agents: every call 401'd with a valid key, standard MCP clients couldn't connect, and writes threw "ql.insert is not a function". All three are the same family — the MCP route resolves data services differently from REST. - resolveExecutionContext getQl: resolve objectql from the per-request kernel directly (kernel.getServiceAsync) instead of the scoped resolveService, which in the multi-env runtime hands back an instance blind to the env's sys_api_key rows so the api-key never resolved (→ 401). REST already resolves identity this way (rest-server), so REST + MCP no longer drift. - extractApiKey: accept `Authorization: Bearer osk_<key>` (prefix-gated). Remote MCP clients (Claude Desktop / Cursor / Claude Code) send the key as a Bearer per the MCP spec; a better-auth session Bearer never carries the osk_ prefix, so the session path is unaffected. - callData create/update/delete: route through protocol.createData/updateData/ deleteData (mirroring the read paths and REST) instead of calling ql.insert on context.dataDriver, which in the multi-env runtime is a raw db driver with no ORM methods. Validated on a local isolated stack: tools/list 401->200 (X-API-Key AND Bearer), create_record succeeds, sys_* and no-auth stay fail-closed, REST unaffected. Unit: mcp 44/44, core api-key 10/10, runtime mcp/exec-ctx/keys 41/41. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7e223a8 commit 1f18569

4 files changed

Lines changed: 74 additions & 15 deletions

File tree

packages/core/src/security/api-key.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,22 @@ describe('core api-key primitives', () => {
3939
expect(k.raw.startsWith(k.prefix)).toBe(true);
4040
});
4141

42-
it('extractApiKey: x-api-key / ApiKey scheme, not Bearer', () => {
42+
it('extractApiKey: x-api-key / ApiKey, and Bearer only for osk_-prefixed keys', () => {
4343
expect(extractApiKey({ 'x-api-key': 'k' })).toBe('k');
4444
expect(extractApiKey({ authorization: 'ApiKey k' })).toBe('k');
45+
// A bare Bearer (e.g. a better-auth session token) is NOT an api-key.
4546
expect(extractApiKey({ authorization: 'Bearer k' })).toBeUndefined();
47+
// A Bearer carrying the api-key prefix IS accepted — remote MCP clients
48+
// (Claude Desktop / Cursor / Claude Code) send the key this way.
49+
expect(extractApiKey({ authorization: 'Bearer osk_abc123' })).toBe('osk_abc123');
50+
expect(extractApiKey({ authorization: 'bearer osk_abc123' })).toBe('osk_abc123'); // scheme is case-insensitive
51+
});
52+
53+
it('resolveApiKeyPrincipal resolves a Bearer osk_ key (remote MCP client path)', async () => {
54+
const raw = 'osk_' + 'b'.repeat(24);
55+
const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]);
56+
const p = await resolveApiKeyPrincipal(ql, { authorization: `Bearer ${raw}` });
57+
expect(p?.userId).toBe('u1');
4658
});
4759

4860
it('parseScopes + isExpired basics', () => {

packages/core/src/security/api-key.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,18 +69,27 @@ export function generateApiKey(prefix: string = API_KEY_PREFIX): GeneratedApiKey
6969
}
7070

7171
/**
72-
* Extract an API key from request headers. Accepts `X-API-Key: <token>` or
73-
* `Authorization: ApiKey <token>` (case-insensitive scheme). Bearer tokens are
74-
* deliberately NOT treated as API keys — those flow through the session path.
72+
* Extract an API key from request headers. Accepts, in order:
73+
* - `X-API-Key: <token>`
74+
* - `Authorization: ApiKey <token>` (case-insensitive scheme)
75+
* - `Authorization: Bearer <token>` ONLY when `<token>` carries the ObjectStack
76+
* api-key prefix (`osk_`). Remote MCP clients (Claude Desktop / Cursor /
77+
* Claude Code) authenticate to `/api/v1/mcp` with the key as a Bearer per the
78+
* MCP spec, so rejecting Bearer outright made every standard MCP client fail.
79+
* A better-auth *session* token never starts with `osk_`, so a session Bearer
80+
* still falls through to the session path — this can't shadow it.
7581
*/
7682
export function extractApiKey(headers: any): string | undefined {
7783
const x = readHeader(headers, 'x-api-key');
7884
if (x && x.trim()) return x.trim();
7985
const auth = readHeader(headers, 'authorization');
8086
if (!auth) return undefined;
81-
const m = auth.match(/^ApiKey\s+(.+)$/i);
82-
const token = m?.[1]?.trim();
83-
return token || undefined;
87+
const apiKeyScheme = auth.match(/^ApiKey\s+(.+)$/i);
88+
if (apiKeyScheme?.[1]?.trim()) return apiKeyScheme[1].trim();
89+
// Bearer is accepted only for prefixed api-keys (never for session tokens).
90+
const bearer = auth.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
91+
if (bearer && bearer.startsWith(API_KEY_PREFIX)) return bearer;
92+
return undefined;
8493
}
8594

8695
/** Parse a `scopes` value that may be a JSON-string textarea or a real array. */

packages/runtime/src/http-dispatcher.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,16 @@ export class HttpDispatcher {
287287
};
288288

289289
if (action === 'create') {
290-
if (ql) {
290+
// Prefer the protocol service (validations + RLS + audit), mirroring
291+
// the read paths below. The MCP bridge passes `context.dataDriver` as
292+
// `ql`, which in the multi-env runtime is a RAW db driver with no ORM
293+
// `insert` — so going straight to `ql.insert` broke MCP create_record
294+
// ("ql.insert is not a function") while REST (which uses `createData`)
295+
// worked. Routing writes through the protocol keeps them aligned.
296+
if (protocol && typeof protocol.createData === 'function') {
297+
return await protocol.createData({ object: params.object, data: params.data, ...(scopeId ? { environmentId: scopeId } : {}), context: executionContext });
298+
}
299+
if (ql && typeof ql.insert === 'function') {
291300
const res = await ql.insert(params.object, params.data, qlOpts);
292301
const record = { ...params.data, ...res };
293302
return { object: params.object, id: record.id, record };
@@ -310,7 +319,10 @@ export class HttpDispatcher {
310319
}
311320

312321
if (action === 'update') {
313-
if (ql && params.id) {
322+
if (protocol && typeof protocol.updateData === 'function') {
323+
return await protocol.updateData({ object: params.object, id: params.id, data: params.data, ...(scopeId ? { environmentId: scopeId } : {}), context: executionContext });
324+
}
325+
if (ql && params.id && typeof ql.update === 'function') {
314326
let all = await ql.find(params.object, findOpts({ where: { id: params.id }, limit: 1 }));
315327
if (all && (all as any).value) all = (all as any).value;
316328
if (!all) all = [];
@@ -323,7 +335,10 @@ export class HttpDispatcher {
323335
}
324336

325337
if (action === 'delete') {
326-
if (ql) {
338+
if (protocol && typeof protocol.deleteData === 'function') {
339+
return await protocol.deleteData({ object: params.object, id: params.id, ...(scopeId ? { environmentId: scopeId } : {}), context: executionContext });
340+
}
341+
if (ql && typeof ql.delete === 'function') {
327342
await ql.delete(params.object, findOpts({ where: { id: params.id } }));
328343
return { object: params.object, id: params.id, deleted: true };
329344
}
@@ -3192,7 +3207,23 @@ export class HttpDispatcher {
31923207
try {
31933208
context.executionContext = await resolveExecutionContext({
31943209
getService: (n: string) => this.resolveService(n, context.environmentId),
3195-
getQl: () => Promise.resolve(this.getObjectQLService(context.environmentId)),
3210+
// Resolve ObjectQL from the per-request kernel DIRECTLY. The scoped
3211+
// `resolveService('objectql', envId)` factory can return a different
3212+
// instance that doesn't see THIS env's rows (the gotcha
3213+
// `handleActions` works around) — which made the api-key lookup miss
3214+
// `sys_api_key` on the MCP path and reject valid keys with 401, while
3215+
// REST accepted them (rest-server resolves identity via
3216+
// `kernel.getServiceAsync('objectql')`). Resolving off `this.kernel`
3217+
// keeps REST + MCP identity resolution aligned; falls back to the
3218+
// scoped path when the kernel can't hand back an objectql directly.
3219+
getQl: async () => {
3220+
const k: any = this.kernel;
3221+
if (k && typeof k.getServiceAsync === 'function') {
3222+
const ql = await k.getServiceAsync('objectql').catch(() => undefined);
3223+
if (ql && (ql.registry || typeof ql.find === 'function')) return ql;
3224+
}
3225+
return this.getObjectQLService(context.environmentId);
3226+
},
31963227
request: context.request,
31973228
});
31983229
} catch {

packages/runtime/src/security/resolve-execution-context.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,21 @@ describe('resolveExecutionContext — API key verify path', () => {
131131
expect(ctx.permissions).toEqual([]);
132132
});
133133

134-
it('ignores Bearer tokens on the API-key path (no key resolution)', async () => {
134+
it('resolves a Bearer api-key (osk_ prefix) but not a bare session Bearer', async () => {
135135
const raw = 'osk_valid';
136136
const rows = [{ id: 'k1', key: hashApiKey(raw), revoked: false, user_id: 'u1' }];
137-
// Bearer is a session token, not an API key — must not resolve here.
138-
const ctx = await resolveExecutionContext(
137+
// A Bearer carrying the osk_ api-key prefix DOES resolve — remote MCP clients
138+
// (Claude Desktop / Cursor / Claude Code) send the key as a Bearer.
139+
const keyed = await resolveExecutionContext(
139140
makeOpts(rows, { authorization: `Bearer ${raw}` }),
140141
);
141-
expect(ctx.userId).toBeUndefined();
142+
expect(keyed.userId).toBe('u1');
143+
// A bare Bearer (a better-auth session token) is NOT an api-key — no key
144+
// resolution here; it falls through to the session path.
145+
const session = await resolveExecutionContext(
146+
makeOpts(rows, { authorization: 'Bearer some-session-token' }),
147+
);
148+
expect(session.userId).toBeUndefined();
142149
});
143150
});
144151

0 commit comments

Comments
 (0)