diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 969bd99..4f38f9b 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/auth/signer.test.ts b/packages/mcp-server/src/auth/signer.test.ts index b84c591..5076a65 100644 --- a/packages/mcp-server/src/auth/signer.test.ts +++ b/packages/mcp-server/src/auth/signer.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createHmac } from 'crypto'; -import { buildWsAuthHeaders } from './signer.js'; +import { buildSignedHeaders, buildWsAuthHeaders, nextNonce } from './signer.js'; // Sourced from an environment variable, not a local string literal — a // literal handed to createHmac() (even through one local const) trips the @@ -20,6 +20,37 @@ process.env.SIGNER_TEST_FAKE_SECRET = 'test-secret'; // on that continuing to hold across edits. const fakeApiSecret = process.env.SIGNER_TEST_FAKE_SECRET!; +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', fakeApiSecret)); + // 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 REST requests in the same millisecond get strictly increasing nonces', () => { + const first = payloadNonce(buildSignedHeaders('/v1/account', {}, 'key', fakeApiSecret)); + const second = payloadNonce(buildSignedHeaders('/v1/account', {}, 'key', fakeApiSecret)); + const third = payloadNonce(buildSignedHeaders('/v1/account', {}, 'key', fakeApiSecret)); + 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); +}); + test('buildWsAuthHeaders signs base64(nonce) — not the JSON-wrapped REST payload shape', () => { const headers = buildWsAuthHeaders('test-key', fakeApiSecret); diff --git a/packages/mcp-server/src/auth/signer.ts b/packages/mcp-server/src/auth/signer.ts index d2ae1e6..2fd256c 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) diff --git a/packages/mcp-server/src/client/sdk.test.ts b/packages/mcp-server/src/client/sdk.test.ts index fa13cac..ac51982 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 dfc2f24..7b7c7db 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 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 0000000..ad3bf95 --- /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) as GeminiHttpClient['authenticatedPost']); + 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 f7c7786..3554275 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 5e70911..e78ce47 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)), },