From e54cefc5c9ab56e361d98590aa1a27c2e009deab Mon Sep 17 00:00:00 2001 From: Jermaine Smith Date: Sat, 15 Aug 2026 12:08:13 -0500 Subject: [PATCH] fix(mcp/oauth): coerce client_id_issued_at to number for Postgres ai_mcp_oauth_clients.client_id_issued_at is `bigint` in the Postgres schema, and the driver returns int8 as a string to avoid precision loss. OAuthClientRow typed the field as `number`, so rowToClient passed the raw string straight through to the dynamic client registration response. RFC 7591 requires client_id_issued_at to be a number, so strict clients reject the payload. Claude Code fails to add the connector with: SDK auth failed: expected number, received string path: ["client_id_issued_at"] SQLite declares the column as `integer` and returns a real number, so this only affects Postgres deployments. Matches the existing convention for bigint columns elsewhere in the codebase, e.g. mediaAssetMapping.ts and ai/conversations/store.ts, which type the row field as `number | string` and coerce in the mapper. --- server/ai/mcp/oauth/store.test.ts | 33 +++++++++++++++++++++++++++++++ server/ai/mcp/oauth/store.ts | 6 ++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/server/ai/mcp/oauth/store.test.ts b/server/ai/mcp/oauth/store.test.ts index fe6cc8830..af162e3fc 100644 --- a/server/ai/mcp/oauth/store.test.ts +++ b/server/ai/mcp/oauth/store.test.ts @@ -10,6 +10,7 @@ import { createOAuthAuthorizationGrant, exchangeAuthorizationCode, findOAuthAccessGrant, + findOAuthClient, registerOAuthClient, rotateRefreshToken, } from './store' @@ -28,6 +29,22 @@ async function freshDb(): Promise { return db } +/** + * Minimal DbClient stub that yields one fixed row. + * + * Postgres returns `bigint` columns as strings to avoid precision loss. The + * in-memory SQLite harness above cannot reproduce that — SQLite's `integer` + * affinity always hands back a number — so the Postgres row shape is stubbed. + */ +function stubDbReturningRow(row: Record): DbClient { + const query = (async () => ({ rows: [row], rowCount: 1 })) as unknown as DbClient + return Object.assign(query, { + unsafe: async () => ({ rows: [row], rowCount: 1 }), + transaction: async (fn: (tx: DbClient) => Promise) => fn(query), + dialect: 'postgres', + }) as DbClient +} + let db: DbClient let request: McpOAuthAuthorizationRequest @@ -178,4 +195,20 @@ describe('MCP OAuth grant store', () => { expect(await revokeConnector(db, connection.id, 'u1')).toBe(true) expect(await findOAuthAccessGrant(db, tokens!.accessToken, RESOURCE)).toBeNull() }) + + it('reads a string-valued bigint client_id_issued_at back as a number', async () => { + const pgDb = stubDbReturningRow({ + client_id: 'imcp_client_test', + client_name: 'Claude', + redirect_uris_json: [REDIRECT_URI], + client_id_issued_at: '1786813381', + }) + + const client = await findOAuthClient(pgDb, 'imcp_client_test') + + // RFC 7591 types client_id_issued_at as a number; clients that validate the + // registration response reject a quoted timestamp. + expect(client?.clientIdIssuedAt).toBe(1786813381) + expect(typeof client?.clientIdIssuedAt).toBe('number') + }) }) diff --git a/server/ai/mcp/oauth/store.ts b/server/ai/mcp/oauth/store.ts index 986894539..2cfca6345 100644 --- a/server/ai/mcp/oauth/store.ts +++ b/server/ai/mcp/oauth/store.ts @@ -20,7 +20,9 @@ interface OAuthClientRow { client_id: string client_name: string redirect_uris_json: string[] - client_id_issued_at: number + // `bigint` in Postgres — the driver returns int8 as a string to avoid + // precision loss, while SQLite hands back a number. Normalized in rowToClient. + client_id_issued_at: number | string } export interface OAuthClientRecord { @@ -80,7 +82,7 @@ function rowToClient(row: OAuthClientRow): OAuthClientRecord { clientId: row.client_id, clientName: row.client_name, redirectUris: Array.isArray(row.redirect_uris_json) ? row.redirect_uris_json : [], - clientIdIssuedAt: row.client_id_issued_at, + clientIdIssuedAt: Number(row.client_id_issued_at), } }