Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 32 additions & 1 deletion packages/mcp-server/src/auth/signer.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, string>): 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);

Expand Down
13 changes: 12 additions & 1 deletion packages/mcp-server/src/auth/signer.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
apiKey: string,
apiSecret: string
): Record<string, string> {
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)
Expand Down
179 changes: 152 additions & 27 deletions packages/mcp-server/src/client/sdk.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<NonNullable<Parameters<typeof createSdkClient>[0]>['fetch']>;
type CapturedRequest = { url: string; init: Parameters<SdkFetch>[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<string, unknown> {
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<string, unknown>;
}

test('createSdkClient wires the configured HmacAuth into an actual authenticated request', async () => {
assert.strictEqual(config.sdkEnv, 'production');
Expand Down Expand Up @@ -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);
}
});

Expand All @@ -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
Expand Down
34 changes: 26 additions & 8 deletions packages/mcp-server/src/client/sdk.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,22 +28,31 @@ export type SdkClient = Awaited<ReturnType<typeof createClient>>;
export async function createSdkClient(
overrides?: Partial<Parameters<typeof createClient>[0]>
): Promise<SdkClient> {
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
Expand Down
32 changes: 32 additions & 0 deletions packages/mcp-server/src/datasources/account.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading