From 84ea055645f877f5052e843c5dee0acb67c0fbf3 Mon Sep 17 00:00:00 2001 From: Deepu Thomas Date: Wed, 4 Nov 2026 10:28:12 -0600 Subject: [PATCH 1/3] fix(mcp-server): sign requests with a millisecond nonce The signer used Math.floor(Date.now() / 1000), a seconds-scale value, while the Gemini API's per-key nonce floor is an epoch-millisecond timestamp. Any key that has ever accepted a millisecond nonce rejects every subsequent seconds nonce, so every authenticated tool failed with: InvalidNonce: Nonce '1793808885' has not increased since your last call Nonces are now epoch milliseconds and monotonic within the process, so concurrent tool calls issued in the same millisecond no longer collide. --- packages/mcp-server/src/auth/signer.test.ts | 34 +++++++++++++++++++++ packages/mcp-server/src/auth/signer.ts | 13 +++++++- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 packages/mcp-server/src/auth/signer.test.ts diff --git a/packages/mcp-server/src/auth/signer.test.ts b/packages/mcp-server/src/auth/signer.test.ts new file mode 100644 index 00000000..af5bc082 --- /dev/null +++ b/packages/mcp-server/src/auth/signer.test.ts @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildSignedHeaders, nextNonce } from './signer.js'; + +function payloadNonce(headers: Record): number { + const payload = JSON.parse(Buffer.from(headers['X-GEMINI-PAYLOAD'], 'base64').toString('utf8')); + return payload.nonce; +} + +test('signed payload carries an epoch-millisecond nonce', () => { + const nonce = payloadNonce(buildSignedHeaders('/v1/account', {}, 'key', 'secret')); + // Seconds-scale values (~1.8e9) are rejected by the API once any millisecond + // nonce has been accepted for the key, so the unit is load-bearing. + assert.ok(nonce > 1_000_000_000_000, `expected millisecond nonce, got ${nonce}`); + assert.ok(Math.abs(nonce - Date.now()) < 60_000, `nonce ${nonce} is not near wall clock`); +}); + +test('concurrent requests in the same millisecond get strictly increasing nonces', () => { + const first = payloadNonce(buildSignedHeaders('/v1/account', {}, 'key', 'secret')); + const second = payloadNonce(buildSignedHeaders('/v1/account', {}, 'key', 'secret')); + const third = payloadNonce(buildSignedHeaders('/v1/account', {}, 'key', 'secret')); + assert.ok(second > first); + assert.ok(third > second); +}); + +test('nextNonce stays ahead of a clock that does not advance', () => { + const start = nextNonce(); + assert.strictEqual(nextNonce(start), start + 1); +}); + +test('nextNonce adopts wall clock once it passes the last issued value', () => { + const start = nextNonce(); + assert.strictEqual(nextNonce(start + 5_000), start + 5_000); +}); diff --git a/packages/mcp-server/src/auth/signer.ts b/packages/mcp-server/src/auth/signer.ts index 400f7096..0bfa48b0 100644 --- a/packages/mcp-server/src/auth/signer.ts +++ b/packages/mcp-server/src/auth/signer.ts @@ -1,12 +1,23 @@ import { createHmac } from 'crypto'; +// Gemini rejects any request whose nonce is not strictly greater than the last +// nonce accepted for that API key, so concurrent requests must not share one. +// Nonces are epoch milliseconds; a seconds value is three orders of magnitude +// below an accepted millisecond nonce and is rejected forever. +let lastNonce = 0; + +export function nextNonce(nowMs: number = Date.now()): number { + lastNonce = Math.max(nowMs, lastNonce + 1); + return lastNonce; +} + export function buildSignedHeaders( endpoint: string, body: Record, apiKey: string, apiSecret: string ): Record { - const nonce = Math.floor(Date.now() / 1000); + const nonce = nextNonce(); const payload = JSON.stringify({ request: endpoint, nonce, ...body }); const encodedPayload = Buffer.from(payload).toString('base64'); const signature = createHmac('sha384', apiSecret) From e0a722cca61d5f8bb3ec536e2c816c2ef2b36cc8 Mon Sep 17 00:00:00 2001 From: Deepu Thomas Date: Wed, 4 Nov 2026 13:59:45 -0600 Subject: [PATCH 2/3] fix(mcp-server): explain the master-key requirement on gemini_get_accounts /v1/account/list is a master-key endpoint, so every scoped key gets a bare 400 InvalidApiKey. gemini_get_accounts promised 'list all accounts' while it can only ever fail for scoped keys. Map the rejection to an actionable error pointing at gemini_get_account, and say the scope requirement in the tool description. --- .../src/datasources/account.test.ts | 32 +++++++++++++++++++ .../mcp-server/src/datasources/account.ts | 14 +++++++- packages/mcp-server/src/tools/account.ts | 3 +- 3 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 packages/mcp-server/src/datasources/account.test.ts diff --git a/packages/mcp-server/src/datasources/account.test.ts b/packages/mcp-server/src/datasources/account.test.ts new file mode 100644 index 00000000..352a01d4 --- /dev/null +++ b/packages/mcp-server/src/datasources/account.test.ts @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { GeminiHttpClient } from '../client/http.js'; +import { getAccounts } from './account.js'; + +const MASTER_KEY_ERROR = + 'Gemini API error 400: {"result":"error","reason":"InvalidApiKey","message":"Invalid API key"}'; + +function clientWith(authenticatedPost: GeminiHttpClient['authenticatedPost']): GeminiHttpClient { + return { authenticatedPost } as unknown as GeminiHttpClient; +} + +test('getAccounts maps a master-key rejection to an actionable error', async () => { + const client = clientWith(async () => { + throw new Error(MASTER_KEY_ERROR); + }); + await assert.rejects(getAccounts(client), /requires a master API key.*gemini_get_account/s); +}); + +test('getAccounts passes through unrelated failures unchanged', async () => { + const unrelated = new Error('Gemini API error 503: {"result":"error","reason":"Unavailable"}'); + const client = clientWith(async () => { + throw unrelated; + }); + await assert.rejects(getAccounts(client), (err) => err === unrelated); +}); + +test('getAccounts returns the account list for a master key', async () => { + const accounts = [{ shortName: 'primary' }]; + const client = clientWith(async () => accounts); + assert.deepEqual(await getAccounts(client), accounts); +}); diff --git a/packages/mcp-server/src/datasources/account.ts b/packages/mcp-server/src/datasources/account.ts index f7c77866..3554275c 100644 --- a/packages/mcp-server/src/datasources/account.ts +++ b/packages/mcp-server/src/datasources/account.ts @@ -14,7 +14,19 @@ export async function createAccount( } export async function getAccounts(client: GeminiHttpClient): Promise { - return client.authenticatedPost('/v1/account/list'); + try { + return await client.authenticatedPost('/v1/account/list'); + } catch (err) { + // /v1/account/list is a master-key endpoint; scoped keys always get + // InvalidApiKey. Surface that cause instead of a bare API error. + if (err instanceof Error && err.message.includes('"reason":"InvalidApiKey"')) { + throw new Error( + '/v1/account/list requires a master API key with account-list permission; ' + + 'this key is scoped and cannot list accounts. Use gemini_get_account for the key\'s own account.' + ); + } + throw err; + } } export async function getRoles(client: GeminiHttpClient): Promise> { diff --git a/packages/mcp-server/src/tools/account.ts b/packages/mcp-server/src/tools/account.ts index 5e70911b..e78ce47d 100644 --- a/packages/mcp-server/src/tools/account.ts +++ b/packages/mcp-server/src/tools/account.ts @@ -26,7 +26,8 @@ export function createAccountTools(client: GeminiHttpClient): ToolDefinition[] { }, { name: 'gemini_get_accounts', - description: 'List all accounts', + description: + 'List all accounts. Requires a master API key with account-list permission; scoped keys cannot use this — use gemini_get_account for the key\'s own account.', inputSchema: z.object({}), handler: wrapHandler(async () => (await account.getAccounts(client)).map(redactAccountDetail)), }, From d38deda1fdfff5794fa884e556dbbe5aa53b1f7b Mon Sep 17 00:00:00 2001 From: Deepu Thomas Date: Wed, 4 Nov 2026 23:23:20 -0600 Subject: [PATCH 3/3] fix(predict-10991): honor custom REST targets in MCP SDK clients --- packages/mcp-server/README.md | 7 +- packages/mcp-server/src/client/sdk.test.ts | 179 +++++++++++++++++---- packages/mcp-server/src/client/sdk.ts | 34 +++- 3 files changed, 183 insertions(+), 37 deletions(-) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 969bd99c..4f38f9be 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -84,8 +84,11 @@ The `-s user` flag registers the server globally across all your projects. Omit - `GEMINI_API_KEY` - Your API key (required for trading/account operations) - `GEMINI_API_SECRET` - Your API secret (required for trading/account operations) - `GEMINI_ACCOUNT` - Account name for Master API keys (e.g., "primary") -- `GEMINI_API_BASE_URL` - API base URL (default: `https://api.gemini.com`, sandbox: `https://api.sandbox.gemini.com`) -- `GEMINI_WS_URL` - WebSocket URL (default: `wss://ws.gemini.com`, sandbox: `wss://ws.sandbox.gemini.com`) +- `GEMINI_API_BASE_URL` - REST API base URL, including all prediction-market SDK requests (default: `https://api.gemini.com`, sandbox: `https://api.sandbox.gemini.com`). Custom targets such as `https://api.qa100.aurora7.net` use that exact destination, not the public production or sandbox host. +- `GEMINI_SDK_ENV` - SDK environment (`production` or `sandbox`). Defaults to `sandbox` when the REST base URL hostname contains `sandbox`, otherwise `production`. For the two standard REST base URLs, an explicit value overrides SDK environment selection. For custom REST targets, it does not replace `GEMINI_API_BASE_URL`. +- `GEMINI_WS_URL` - Legacy WebSocket client URL (default: `wss://ws.gemini.com`, sandbox: `wss://ws.sandbox.gemini.com`). SDK-backed WebSocket streams still use the SDK environment; custom REST routing does not redirect them. + +Use credentials issued for the selected REST destination. Custom REST targets share the legacy client's strictly increasing millisecond nonce sequence; SDK calls to standard Gemini REST environments retain epoch-second nonces. WebSocket authentication remains independent. ## Quick Start diff --git a/packages/mcp-server/src/client/sdk.test.ts b/packages/mcp-server/src/client/sdk.test.ts index fa13cac5..ac519820 100644 --- a/packages/mcp-server/src/client/sdk.test.ts +++ b/packages/mcp-server/src/client/sdk.test.ts @@ -1,5 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import type { SdkClient } from './sdk.js'; // config.ts snapshots process.env when the module is first imported, so the // credentials/env selector have to be in place before the module graph loads — same @@ -13,9 +15,36 @@ process.env.SDK_TEST_FAKE_SECRET = 'test-api-secret'; process.env.GEMINI_API_KEY = process.env.SDK_TEST_FAKE_KEY; process.env.GEMINI_API_SECRET = process.env.SDK_TEST_FAKE_SECRET; delete process.env.GEMINI_SDK_ENV; +process.env.GEMINI_API_BASE_URL = 'https://api.gemini.com'; +delete process.env.GEMINI_ACCOUNT; const { createSdkClient } = await import('./sdk.js'); const { config } = await import('../config.js'); +// This client also imports config; static loading would snapshot it before env setup. +const { GeminiHttpClient } = await import('./http.js'); + +type SdkFetch = NonNullable[0]>['fetch']>; +type CapturedRequest = { url: string; init: Parameters[1] }; + +function captureFetch(calls: CapturedRequest[]): SdkFetch { + return async (url, init) => { + calls.push({ url, init }); + return new Response(url.endsWith('/order') ? '{"orderId":1}' : '{}', { + status: url.endsWith('/order') ? 201 : 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; +} + +function signedPayload(call: CapturedRequest): Record { + const encoded = call.init.headers['X-GEMINI-PAYLOAD']; + assert.ok(encoded, 'expected a signed payload'); + assert.strictEqual( + call.init.headers['X-GEMINI-SIGNATURE'], + createHmac('sha384', process.env.SDK_TEST_FAKE_SECRET!).update(encoded).digest('hex') + ); + return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as Record; +} test('createSdkClient wires the configured HmacAuth into an actual authenticated request', async () => { assert.strictEqual(config.sdkEnv, 'production'); @@ -52,34 +81,125 @@ test('createSdkClient wires the configured HmacAuth into an actual authenticated } }); -test('createSdkClient selects sandbox when GEMINI_SDK_ENV=sandbox', async () => { - config.sdkEnv = 'sandbox'; - const requestedUrls: string[] = []; +test('standard REST targets retain SDK environment selection and epoch-second nonces', async (t) => { + const saved = { baseUrl: config.baseUrl, sdkEnv: config.sdkEnv }; + const now = 2_000_000_000_123; + t.mock.method(Date, 'now', () => now); try { - // Inject a fake fetch instead of hitting the network, so the assertion is about - // which host createSdkClient actually targets — a hardcoded env in createClient - // would still make client.predictions truthy, so that alone doesn't prove anything. - const client = await createSdkClient({ - fetch: async (url) => { - requestedUrls.push(url); - return new Response('[]', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - }, - }); - try { - await client.predictions.getCategories(); - assert.strictEqual(requestedUrls.length, 1); - assert.ok( - requestedUrls[0]!.startsWith('https://api.sandbox.gemini.com'), - `expected a sandbox URL, got ${requestedUrls[0]}` - ); - } finally { - client.close(); + for (const baseUrl of ['https://api.gemini.com', 'https://api.sandbox.gemini.com/']) { + for (const sdkEnv of ['production', 'sandbox'] as const) { + config.baseUrl = baseUrl; + config.sdkEnv = sdkEnv; + const calls: CapturedRequest[] = []; + const client = await createSdkClient({ fetch: captureFetch(calls) }); + try { + await client.predictions.getCategories(); + await client.predictions.getPositions(); + const origin = sdkEnv === 'sandbox' + ? 'https://api.sandbox.gemini.com' + : 'https://api.gemini.com'; + assert.deepStrictEqual(calls.map(({ url }) => url), [ + `${origin}/v1/prediction-markets/categories`, + `${origin}/v1/prediction-markets/positions`, + ]); + assert.strictEqual(calls[0]!.init.headers['X-GEMINI-APIKEY'], undefined); + assert.strictEqual(String(signedPayload(calls[1]!)['nonce']), String(Math.floor(now / 1000))); + } finally { + client.close(); + } + } + } + } finally { + Object.assign(config, saved); + } +}); + +test('custom REST routing composes with injected fetch for public reads, signed queries and orders', async () => { + const saved = { baseUrl: config.baseUrl, sdkEnv: config.sdkEnv }; + try { + for (const baseUrl of ['https://api.qa100.aurora7.net', 'http://sandbox.example.invalid:8080/']) { + config.baseUrl = baseUrl; + config.sdkEnv = 'sandbox'; + const calls: CapturedRequest[] = []; + const client = await createSdkClient({ fetch: captureFetch(calls) }); + const controller = new AbortController(); + const order = { + symbol: 'GEMI-TEST', + side: 'buy', + outcome: 'yes', + quantity: '1', + price: '0.50', + orderType: 'limit', + makerOrCancel: false, + } as const; + try { + await client.predictions.getCategories(); + await client.predictions.getPositions( + { eventTicker: 'GEMI-TEST', limit: 7 }, + { signal: controller.signal, headers: { 'X-Test-Routing': 'preserved' } } + ); + // This is an SDK request intercepted by fake fetch, never a real order. + await client.predictions.placeOrder(order); + + const target = baseUrl.replace(/\/+$/, ''); + assert.strictEqual(calls.length, 3); + assert.strictEqual(calls[0]!.url, `${target}/v1/prediction-markets/categories`); + assert.strictEqual(calls[0]!.init.headers['X-GEMINI-APIKEY'], undefined); + const read = calls[1]!; + const url = new URL(read.url); + assert.strictEqual(`${url.origin}${url.pathname}`, `${target}/v1/prediction-markets/positions`); + assert.deepStrictEqual([...url.searchParams.entries()].sort(), [ + ['eventTicker', 'GEMI-TEST'], ['limit', '7'], + ]); + assert.strictEqual(read.init.method, 'POST'); + assert.strictEqual(read.init.headers['X-Test-Routing'], 'preserved'); + assert.ok(read.init.signal instanceof AbortSignal); + assert.strictEqual(read.init.signal.aborted, false); + assert.strictEqual(read.init.redirect, 'manual'); + assert.strictEqual(signedPayload(read)['request'], '/v1/prediction-markets/positions'); + const placed = calls[2]!; + assert.strictEqual(placed.url, `${target}/v1/prediction-markets/order`); + assert.strictEqual(placed.init.method, 'POST'); + assert.deepStrictEqual(JSON.parse(placed.init.body!), order); + assert.strictEqual(placed.init.headers['X-GEMINI-APIKEY'], process.env.SDK_TEST_FAKE_KEY); + assert.strictEqual(placed.init.redirect, 'manual'); + const payload = signedPayload(placed); + assert.strictEqual(payload['request'], '/v1/prediction-markets/order'); + for (const [key, value] of Object.entries(order)) assert.strictEqual(payload[key], value); + } finally { + client.close(); + } } } finally { - config.sdkEnv = 'production'; + Object.assign(config, saved); + } +}); + +test('custom SDK and legacy REST requests share increasing nonces in the same millisecond', async (t) => { + const saved = { baseUrl: config.baseUrl, sdkEnv: config.sdkEnv }; + const now = 2_000_000_000_123; + t.mock.method(Date, 'now', () => now); + config.baseUrl = 'https://api.qa100.aurora7.net'; + const calls: CapturedRequest[] = []; + const fakeFetch = captureFetch(calls); + t.mock.method(globalThis, 'fetch', fakeFetch); + const clients: SdkClient[] = []; + try { + const legacy = new GeminiHttpClient(); + // One uses the runtime fetch fallback, the other the explicit test override. + clients.push(await createSdkClient(), await createSdkClient({ fetch: fakeFetch })); + await legacy.authenticatedGet('/v1/account'); + await clients[0]!.predictions.getPositions(); + await legacy.authenticatedGet('/v1/account'); + await clients[1]!.predictions.getPositions(); + const nonces = calls.map((call) => Number(signedPayload(call)['nonce'])); + assert.strictEqual(nonces.length, 4); + assert.ok(nonces[0]! >= now, 'custom REST nonces must be epoch milliseconds'); + assert.deepStrictEqual(nonces, nonces.map((_, index) => nonces[0]! + index)); + assert.ok(calls.every(({ url }) => url.startsWith(`${config.baseUrl}/v1/`))); + } finally { + for (const client of clients) client.close(); + Object.assign(config, saved); } }); @@ -89,9 +209,14 @@ test('createSdkClient omits auth in public-only mode, matching legacy fail-close config.apiKey = ''; config.apiSecret = ''; try { - const client = await createSdkClient(); + const calls: CapturedRequest[] = []; + const client = await createSdkClient({ fetch: captureFetch(calls) }); try { - assert.ok(client.predictions); + await client.predictions.getCategories(); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0]!.init.headers['X-GEMINI-APIKEY'], undefined); + await assert.rejects(client.predictions.getPositions(), /AuthStrategy/); + assert.strictEqual(calls.length, 1, 'unauthenticated private REST must not reach fetch'); // No credentials configured — the private WS surface must fail closed, same as // the legacy GeminiHttpClient does today for authenticated REST calls. This // throws synchronously before any connection is attempted, so it's safe to call diff --git a/packages/mcp-server/src/client/sdk.ts b/packages/mcp-server/src/client/sdk.ts index dfc2f249..7b7c7dbe 100644 --- a/packages/mcp-server/src/client/sdk.ts +++ b/packages/mcp-server/src/client/sdk.ts @@ -1,5 +1,14 @@ import { createClient, HmacAuth } from '@gemini-markets/sdk/server'; import { config } from '../config.js'; +import { nextNonce } from '../auth/signer.js'; + +// Custom REST targets share the legacy client's millisecond nonce sequence. +// Keep HmacAuth's independent epoch-second WebSocket auth contract intact. +class CustomTargetHmacAuth extends HmacAuth { + override nextNonce(): string { + return String(nextNonce()); + } +} // @gemini-markets/sdk/server doesn't export the GeminiMarkets class itself (only // createClient's return type uses it) — derive the type from createClient so the rest of @@ -19,22 +28,31 @@ export type SdkClient = Awaited>; export async function createSdkClient( overrides?: Partial[0]> ): Promise { + const baseUrl = config.baseUrl.replace(/\/+$/, ''); + const customTarget = + baseUrl !== 'https://api.gemini.com' && baseUrl !== 'https://api.sandbox.gemini.com'; + const Auth = customTarget ? CustomTargetHmacAuth : HmacAuth; const auth = config.apiKey && config.apiSecret - ? // nonceMode defaults to "monotonic", which sends the nonce in milliseconds. - // Gemini's real REST API expects epoch-second nonces — the legacy signer this - // SDK replaces already sends Math.floor(Date.now() / 1000), and the SDK's own - // WebSocket auth hardcodes the same second-scale nonce for the same reason. - // Confirmed against production: the millisecond default fails every - // authenticated REST call with InvalidNonce (HTTP 400). - new HmacAuth({ + ? new Auth({ apiKey: config.apiKey, apiSecret: config.apiSecret, + // Standard Gemini REST endpoints retain their epoch-second nonce policy. nonceMode: 'time-based', }) : undefined; - return createClient({ env: config.sdkEnv, auth, ...overrides }); + const options = { env: config.sdkEnv, auth, ...overrides }; + if (customTarget) { + // The SDK exposes routing through fetch, not a client-level baseUrl option. + // Compose with injected fetch so tests and runtime use the same routing path. + const fetchImpl = options.fetch ?? globalThis.fetch; + options.fetch = (url, init) => { + const { pathname, search } = new URL(url); + return fetchImpl(`${baseUrl}${pathname}${search}`, init); + }; + } + return createClient(options); } // The legacy GeminiHttpClient.authenticatedPost added config.account (GEMINI_ACCOUNT) to