From dbff4b1226ed232d6e68371dbb9af1fe676d6f0f Mon Sep 17 00:00:00 2001 From: karan acharya Date: Thu, 17 Sep 2026 22:56:25 -0400 Subject: [PATCH 1/5] feat(mcp-server): wire @gemini-markets/sdk client into mcp-server (PREDICT-8816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the SDK dependency and a shared createSdkClient() factory, threaded through both process bootstraps (the MCP server and the alerts daemon) unused for now so every sibling migration ticket (PREDICT-8817-8820, 8823) can start consuming it without redoing this wiring. Pure plumbing — no tool behavior change. Also fixes a pre-existing broken import path in market-data.test.ts left over from the PREDICT-8815 file split, which was blocking npm run typecheck. --- packages/mcp-server/package-lock.json | 25 +++++++++ packages/mcp-server/package.json | 7 ++- packages/mcp-server/scripts/smoke-sdk.ts | 38 +++++++++++++ .../mcp-server/src/alerts/daemon/index.ts | 4 ++ packages/mcp-server/src/client/sdk.test.ts | 56 +++++++++++++++++++ packages/mcp-server/src/client/sdk.ts | 22 ++++++++ packages/mcp-server/src/config.ts | 5 ++ .../predictions/market-data.test.ts | 4 +- packages/mcp-server/src/index.ts | 4 +- packages/mcp-server/src/server.test.ts | 17 ++++++ packages/mcp-server/src/server.ts | 3 +- 11 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 packages/mcp-server/scripts/smoke-sdk.ts create mode 100644 packages/mcp-server/src/client/sdk.test.ts create mode 100644 packages/mcp-server/src/client/sdk.ts create mode 100644 packages/mcp-server/src/server.test.ts diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json index 8c9f8800..844f889f 100644 --- a/packages/mcp-server/package-lock.json +++ b/packages/mcp-server/package-lock.json @@ -8,6 +8,7 @@ "name": "gemini-mcp", "version": "1.0.1", "dependencies": { + "@gemini-markets/sdk": "^0.1.0", "@modelcontextprotocol/sdk": "^1.27.1", "json-bigint": "^1.0.0", "node-notifier": "^10.0.1", @@ -25,6 +26,9 @@ "@types/ws": "^8.5.13", "tsx": "^4.22.4", "typescript": "^5.7.0" + }, + "engines": { + "node": ">=22.4.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -469,6 +473,27 @@ "node": ">=18" } }, + "node_modules/@gemini-markets/sdk": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@gemini-markets/sdk/-/sdk-0.1.0.tgz", + "integrity": "sha512-XJbWBSYQGmsHyw6Ogn1qZdVbXrmp5sPq/E43CXAx5XBjY4ElAWB0Or86wE7h97d7DdnveOfxiXqxTv/pFO2sEg==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.4.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <2", + "ws": ">=8" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, "node_modules/@hono/node-server": { "version": "1.19.13", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 7d413c16..4720c856 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -3,6 +3,9 @@ "version": "1.0.1", "type": "module", "main": "dist/index.js", + "engines": { + "node": ">=22.4.0" + }, "bin": { "gemini-mcp-alerts": "./dist/alerts/daemon/index.js" }, @@ -17,9 +20,11 @@ "dev": "tsx src/index.ts", "start": "node dist/index.js", "test": "tsx --test 'src/**/*.test.ts'", - "typecheck": "tsc -p tsconfig.test.json" + "typecheck": "tsc -p tsconfig.test.json", + "smoke:sdk": "tsx scripts/smoke-sdk.ts" }, "dependencies": { + "@gemini-markets/sdk": "^0.1.0", "@modelcontextprotocol/sdk": "^1.27.1", "json-bigint": "^1.0.0", "node-notifier": "^10.0.1", diff --git a/packages/mcp-server/scripts/smoke-sdk.ts b/packages/mcp-server/scripts/smoke-sdk.ts new file mode 100644 index 00000000..8554b2d0 --- /dev/null +++ b/packages/mcp-server/scripts/smoke-sdk.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// Manual, local smoke check for the @gemini-markets/sdk client wired up in +// src/client/sdk.ts — mirrors packages/sdk-typescript's own smoke:sandbox scripts and +// the plan doc's "Public-only SDK client smoke test" / "Authenticated HMAC request smoke +// test" checks. Not CI-gated; run it by hand (`npm run smoke:sdk`) against sandbox +// before relying on this client in a downstream migration ticket. +import { createSdkClient } from '../src/client/sdk.js'; +import { config } from '../src/config.js'; + +async function main(): Promise { + // Mutate the already-loaded config singleton directly (same pattern this package's + // own tests use) rather than setting process.env, since config snapshots env vars at + // import time — too late to affect it from within this same process. + config.sdkEnv = 'sandbox'; + + console.log('[smoke:sdk] constructing SDK client against sandbox...'); + const client = await createSdkClient(); + + console.log('[smoke:sdk] public call: predictions.getCategories()'); + const categories = await client.predictions.getCategories(); + console.log(`[smoke:sdk] OK — received ${JSON.stringify(categories).length} bytes`); + + if (config.apiKey && config.apiSecret) { + console.log('[smoke:sdk] authenticated call: predictions.getPositions()'); + const positions = await client.predictions.getPositions(); + console.log(`[smoke:sdk] OK — received ${JSON.stringify(positions).length} bytes`); + } else { + console.log('[smoke:sdk] no GEMINI_API_KEY/GEMINI_API_SECRET set — skipping authenticated call'); + } + + client.close(); + console.log('[smoke:sdk] done'); +} + +main().catch((err) => { + console.error('[smoke:sdk] FAILED:', err); + process.exit(1); +}); diff --git a/packages/mcp-server/src/alerts/daemon/index.ts b/packages/mcp-server/src/alerts/daemon/index.ts index 1cd85c85..d438fb4f 100644 --- a/packages/mcp-server/src/alerts/daemon/index.ts +++ b/packages/mcp-server/src/alerts/daemon/index.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { config } from '../../config.js'; import { GeminiHttpClient } from '../../client/http.js'; +import { createSdkClient } from '../../client/sdk.js'; import { WebSocketManager } from '../../websocket/manager.js'; import { MarketDataStore } from '../../store/index.js'; import { AlertStore } from '../store.js'; @@ -103,6 +104,9 @@ function buildWsAdapter(wsManager: WebSocketManager): SchedulerWsAdapter { async function main(): Promise { const httpClient = new GeminiHttpClient(); + // Constructed alongside the legacy client below; not consumed by any fetcher yet — + // PREDICT-8820 switches the settlement-alert fetcher over to it. + const sdkClient = await createSdkClient(); const marketStore = new MarketDataStore(); const wsManager = new WebSocketManager(config.wsUrl, marketStore); await wsManager.initialize(); diff --git a/packages/mcp-server/src/client/sdk.test.ts b/packages/mcp-server/src/client/sdk.test.ts new file mode 100644 index 00000000..d74bd5d8 --- /dev/null +++ b/packages/mcp-server/src/client/sdk.test.ts @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// 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 +// reason client/http.request.test.ts and auth/signer.test.ts use dynamic imports here. +// node:test runs each test file in its own process, so these assignments cannot leak +// into other suites. Fake credentials are sourced via a name distinct from a plain +// string literal, matching this package's existing workaround for the +// javascript.lang.security.audit.hardcoded-hmac-key scanner rule. +process.env.SDK_TEST_FAKE_KEY = 'test-api-key'; +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; + +const { createSdkClient } = await import('./sdk.js'); +const { config } = await import('../config.js'); + +test('createSdkClient defaults to production and resolves an authenticated client', async () => { + assert.strictEqual(config.sdkEnv, 'production'); + + const client = await createSdkClient(); + + assert.ok(client.predictions); + assert.ok(client.websocket); + // Auth was supplied, so the private WebSocket surface should not fail closed. + assert.doesNotThrow(() => client.websocket.private.orders({ scope: 'account' })); +}); + +test('createSdkClient selects sandbox when GEMINI_SDK_ENV=sandbox', async () => { + config.sdkEnv = 'sandbox'; + try { + const client = await createSdkClient(); + assert.ok(client.predictions); + } finally { + config.sdkEnv = 'production'; + } +}); + +test('createSdkClient omits auth in public-only mode, matching legacy fail-closed behavior', async () => { + const savedKey = config.apiKey; + const savedSecret = config.apiSecret; + config.apiKey = ''; + config.apiSecret = ''; + try { + const client = await createSdkClient(); + assert.ok(client.predictions); + // No credentials configured — the private WS surface must fail closed, same as + // the legacy GeminiHttpClient does today for authenticated REST calls. + assert.throws(() => client.websocket.private.orders({ scope: 'account' })); + } finally { + config.apiKey = savedKey; + config.apiSecret = savedSecret; + } +}); diff --git a/packages/mcp-server/src/client/sdk.ts b/packages/mcp-server/src/client/sdk.ts new file mode 100644 index 00000000..f86e1934 --- /dev/null +++ b/packages/mcp-server/src/client/sdk.ts @@ -0,0 +1,22 @@ +import { createClient, HmacAuth } from '@gemini-markets/sdk/server'; +import { config } from '../config.js'; + +// @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 +// mcp-server has something to import instead of reaching into the SDK's internals. +export type SdkClient = Awaited>; + +// Single shared factory for the @gemini-markets/sdk client, used by both mcp-server's +// process bootstraps (the MCP server itself and the separate alerts daemon binary) so +// they stay configured identically. Omits `auth` entirely when no API key/secret are +// configured, matching this package's existing public-only mode (see index.ts's +// validateConfig) — authenticated SDK calls/streams will throw their own "auth required" +// error if invoked without one. +export async function createSdkClient(): Promise { + const auth = + config.apiKey && config.apiSecret + ? new HmacAuth({ apiKey: config.apiKey, apiSecret: config.apiSecret }) + : undefined; + + return createClient({ env: config.sdkEnv, auth }); +} diff --git a/packages/mcp-server/src/config.ts b/packages/mcp-server/src/config.ts index dfb84394..5a8d899e 100644 --- a/packages/mcp-server/src/config.ts +++ b/packages/mcp-server/src/config.ts @@ -4,4 +4,9 @@ export const config = { baseUrl: process.env.GEMINI_API_BASE_URL ?? 'https://api.gemini.com', wsUrl: process.env.GEMINI_WS_URL ?? 'wss://ws.gemini.com', account: process.env.GEMINI_ACCOUNT ?? '', + // Selects the @gemini-markets/sdk client's environment. Independent from baseUrl/wsUrl + // above, which keep governing the legacy client for tools not yet migrated to the SDK. + sdkEnv: (process.env.GEMINI_SDK_ENV === 'sandbox' ? 'sandbox' : 'production') as + | 'sandbox' + | 'production', }; diff --git a/packages/mcp-server/src/datasources/predictions/market-data.test.ts b/packages/mcp-server/src/datasources/predictions/market-data.test.ts index e6a109c3..ef4ee064 100644 --- a/packages/mcp-server/src/datasources/predictions/market-data.test.ts +++ b/packages/mcp-server/src/datasources/predictions/market-data.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import type { GeminiHttpClient } from '../client/http.js'; -import * as predictions from './predictions.js'; +import type { GeminiHttpClient } from '../../client/http.js'; +import * as predictions from './market-data.js'; // Regression coverage for PREDICT-8871: `category`/`status` were sent as // `category[]`/`status[]`, a key shape the live API silently ignores (it diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 7f51d6a6..0ecc7d29 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -1,5 +1,6 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { createServer } from './server.js'; +import { createSdkClient } from './client/sdk.js'; import { config } from './config.js'; function validateConfig(): void { @@ -33,7 +34,8 @@ function validateConfig(): void { async function main(): Promise { validateConfig(); - const server = createServer(); + const sdkClient = await createSdkClient(); + const server = createServer(sdkClient); const transport = new StdioServerTransport(); await server.connect(transport); // Server is running, listening on stdio diff --git a/packages/mcp-server/src/server.test.ts b/packages/mcp-server/src/server.test.ts new file mode 100644 index 00000000..d713127a --- /dev/null +++ b/packages/mcp-server/src/server.test.ts @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { createServer } from './server.js'; +import type { SdkClient } from './client/sdk.js'; + +// createServer() previously took no arguments; PREDICT-8816 added the sdkClient +// parameter with no other behavior change. Neither this bootstrap nor the alerts +// daemon's had any test coverage before this ticket — this closes that gap for the MCP +// server entry point specifically, so a wiring mistake here (e.g. a missing/mistyped +// parameter) fails a test instead of only surfacing at runtime. +const fakeSdkClient = {} as SdkClient; + +test('createServer constructs without throwing and returns a Server instance', () => { + const server = createServer(fakeSdkClient); + assert.ok(server instanceof Server); +}); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index fb383a60..2c32fb56 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -5,6 +5,7 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { GeminiHttpClient } from './client/http.js'; +import type { SdkClient } from './client/sdk.js'; import { WebSocketManager } from './websocket/manager.js'; import { config } from './config.js'; import { @@ -25,7 +26,7 @@ import { import { annotationsFor, requiresConfirmation } from './tools/index.js'; import type { ToolDefinition } from './tools/index.js'; -export function createServer(): Server { +export function createServer(sdkClient: SdkClient): Server { const server = new Server( { name: 'gemini-mcp', From 63a2498c3027b2d6874addf1bfdf0d8d3a353ca9 Mon Sep 17 00:00:00 2001 From: karan acharya Date: Thu, 17 Sep 2026 23:24:55 -0400 Subject: [PATCH 2/5] fix(mcp-server): address svc-grace review findings on PREDICT-8816 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.ts: fail startup on an invalid GEMINI_SDK_ENV instead of silently falling back to production (a typo like "sandbx" would otherwise send authenticated calls to live markets). - client/sdk.ts: add an optional test-only overrides param to createSdkClient so tests can inject a fake fetch instead of hitting the network. - client/sdk.test.ts: stop invoking client.websocket.private.orders() in the authenticated-client test — it opened a real WebSocket connection to production with fake credentials that never resolved or closed, leaking an unhandled rejection and live reconnect timers (this alone was adding ~70s to every full `npm test` run). The sandbox-selection test now injects a fake fetch and asserts the request actually targeted api.sandbox.gemini.com, instead of only checking that .predictions is truthy. --- packages/mcp-server/src/client/sdk.test.ts | 57 +++++++++++++++++----- packages/mcp-server/src/client/sdk.ts | 10 +++- packages/mcp-server/src/config.ts | 21 ++++++-- 3 files changed, 70 insertions(+), 18 deletions(-) diff --git a/packages/mcp-server/src/client/sdk.test.ts b/packages/mcp-server/src/client/sdk.test.ts index d74bd5d8..4142f857 100644 --- a/packages/mcp-server/src/client/sdk.test.ts +++ b/packages/mcp-server/src/client/sdk.test.ts @@ -21,18 +21,47 @@ test('createSdkClient defaults to production and resolves an authenticated clien assert.strictEqual(config.sdkEnv, 'production'); const client = await createSdkClient(); - - assert.ok(client.predictions); - assert.ok(client.websocket); - // Auth was supplied, so the private WebSocket surface should not fail closed. - assert.doesNotThrow(() => client.websocket.private.orders({ scope: 'account' })); + try { + assert.ok(client.predictions); + assert.ok(client.websocket); + // Auth was supplied, so the private WebSocket surface should be present. Checked + // without calling it — invoking .orders() here would open a real authenticated + // connection to production with fake credentials, which fails and leaks an + // unhandled rejection plus live reconnect timers. The "fails closed with no auth" + // contract is covered below, where requireAuth() throws before any connection is + // attempted, so nothing needs to actually connect to prove either behavior. + assert.strictEqual(typeof client.websocket.private.orders, 'function'); + } finally { + client.close(); + } }); test('createSdkClient selects sandbox when GEMINI_SDK_ENV=sandbox', async () => { config.sdkEnv = 'sandbox'; + const requestedUrls: string[] = []; try { - const client = await createSdkClient(); - assert.ok(client.predictions); + // 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(); + } } finally { config.sdkEnv = 'production'; } @@ -45,10 +74,16 @@ test('createSdkClient omits auth in public-only mode, matching legacy fail-close config.apiSecret = ''; try { const client = await createSdkClient(); - assert.ok(client.predictions); - // No credentials configured — the private WS surface must fail closed, same as - // the legacy GeminiHttpClient does today for authenticated REST calls. - assert.throws(() => client.websocket.private.orders({ scope: 'account' })); + try { + assert.ok(client.predictions); + // 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 + // directly (unlike the authenticated case above). + assert.throws(() => client.websocket.private.orders({ scope: 'account' })); + } finally { + client.close(); + } } finally { config.apiKey = savedKey; config.apiSecret = savedSecret; diff --git a/packages/mcp-server/src/client/sdk.ts b/packages/mcp-server/src/client/sdk.ts index f86e1934..3bbf397e 100644 --- a/packages/mcp-server/src/client/sdk.ts +++ b/packages/mcp-server/src/client/sdk.ts @@ -12,11 +12,17 @@ export type SdkClient = Awaited>; // configured, matching this package's existing public-only mode (see index.ts's // validateConfig) — authenticated SDK calls/streams will throw their own "auth required" // error if invoked without one. -export async function createSdkClient(): Promise { +// +// `overrides` exists for tests only (e.g. injecting a fake `fetch` to prove which +// environment's URL a call actually targets without a live network round-trip). Real +// callers (index.ts, alerts/daemon/index.ts) pass nothing. +export async function createSdkClient( + overrides?: Partial[0]> +): Promise { const auth = config.apiKey && config.apiSecret ? new HmacAuth({ apiKey: config.apiKey, apiSecret: config.apiSecret }) : undefined; - return createClient({ env: config.sdkEnv, auth }); + return createClient({ env: config.sdkEnv, auth, ...overrides }); } diff --git a/packages/mcp-server/src/config.ts b/packages/mcp-server/src/config.ts index 5a8d899e..0c9d135e 100644 --- a/packages/mcp-server/src/config.ts +++ b/packages/mcp-server/src/config.ts @@ -1,12 +1,23 @@ +// Selects the @gemini-markets/sdk client's environment. Independent from baseUrl/wsUrl +// below, which keep governing the legacy client for tools not yet migrated to the SDK. +// Accepts only the exact values the SDK itself supports, or unset/empty for the default +// ("production") — anything else fails startup instead of silently falling back to +// production, since that fallback would otherwise send authenticated calls to live +// markets when an operator meant sandbox (e.g. a typo like "sandbx"). +function resolveSdkEnv(): 'sandbox' | 'production' { + const raw = process.env.GEMINI_SDK_ENV; + if (raw === undefined || raw === '') return 'production'; + if (raw === 'sandbox' || raw === 'production') return raw; + throw new Error( + `Invalid GEMINI_SDK_ENV "${raw}": must be "sandbox" or "production" (or unset, which defaults to "production").` + ); +} + export const config = { apiKey: process.env.GEMINI_API_KEY ?? '', apiSecret: process.env.GEMINI_API_SECRET ?? '', baseUrl: process.env.GEMINI_API_BASE_URL ?? 'https://api.gemini.com', wsUrl: process.env.GEMINI_WS_URL ?? 'wss://ws.gemini.com', account: process.env.GEMINI_ACCOUNT ?? '', - // Selects the @gemini-markets/sdk client's environment. Independent from baseUrl/wsUrl - // above, which keep governing the legacy client for tools not yet migrated to the SDK. - sdkEnv: (process.env.GEMINI_SDK_ENV === 'sandbox' ? 'sandbox' : 'production') as - | 'sandbox' - | 'production', + sdkEnv: resolveSdkEnv(), }; From 67100ac3ed2187cfe50a9e95e62fbc7cc4f1402b Mon Sep 17 00:00:00 2001 From: karan acharya Date: Fri, 18 Sep 2026 11:14:28 -0400 Subject: [PATCH 3/5] fix(mcp-server): verify HmacAuth is actually wired, per svc-grace follow-up (PREDICT-8816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix for the leaked-WebSocket finding replaced the risky client.websocket.private.orders() call with a check that it's merely a function — true regardless of whether createSdkClient actually passed auth into createClient. Exercise a real authenticated REST call instead (via the same injected-fetch seam used for the sandbox test) and assert the request carried the expected X-GEMINI-APIKEY/X-GEMINI-SIGNATURE headers. Verified this catches the regression by temporarily dropping the auth arg from createSdkClient and confirming the test fails. --- packages/mcp-server/src/client/sdk.test.ts | 36 ++++++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/mcp-server/src/client/sdk.test.ts b/packages/mcp-server/src/client/sdk.test.ts index 4142f857..fa13cac5 100644 --- a/packages/mcp-server/src/client/sdk.test.ts +++ b/packages/mcp-server/src/client/sdk.test.ts @@ -17,19 +17,35 @@ delete process.env.GEMINI_SDK_ENV; const { createSdkClient } = await import('./sdk.js'); const { config } = await import('../config.js'); -test('createSdkClient defaults to production and resolves an authenticated client', async () => { +test('createSdkClient wires the configured HmacAuth into an actual authenticated request', async () => { assert.strictEqual(config.sdkEnv, 'production'); - const client = await createSdkClient(); + // Exercise a real authenticated REST call (via an injected fake fetch, so nothing + // touches the network) and inspect the signed headers HttpTransport actually sent. + // Checking only that client.websocket.private.orders is a function — the previous + // version of this test — passes even if createSdkClient silently dropped the + // HmacAuth instance, since that surface exists regardless of whether auth is + // configured; only an authenticated call proves auth was wired through. + let capturedHeaders: Record | undefined; + const client = await createSdkClient({ + fetch: async (_url, init) => { + capturedHeaders = init.headers; + return new Response('{}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }, + }); try { - assert.ok(client.predictions); - assert.ok(client.websocket); - // Auth was supplied, so the private WebSocket surface should be present. Checked - // without calling it — invoking .orders() here would open a real authenticated - // connection to production with fake credentials, which fails and leaks an - // unhandled rejection plus live reconnect timers. The "fails closed with no auth" - // contract is covered below, where requireAuth() throws before any connection is - // attempted, so nothing needs to actually connect to prove either behavior. + await client.predictions.getPositions(); + + assert.strictEqual(capturedHeaders?.['X-GEMINI-APIKEY'], process.env.SDK_TEST_FAKE_KEY); + assert.ok(capturedHeaders?.['X-GEMINI-SIGNATURE'], 'expected a computed HMAC signature header'); + + // client.websocket.private.orders remains a function once auth is configured — kept + // as a cheap structural check, not invoked (that would open a real authenticated + // WebSocket connection; see the "fails closed" test below for the meaningful WS + // assertion, which throws before any connection is attempted either way). assert.strictEqual(typeof client.websocket.private.orders, 'function'); } finally { client.close(); From 6f621de8b2b1bb3dfc4cfc99bdc347fd99f2b8e5 Mon Sep 17 00:00:00 2001 From: karan acharya Date: Fri, 18 Sep 2026 13:11:03 -0400 Subject: [PATCH 4/5] testing authenticated call with the SDK Fixed HmacAuth's nonce mode for REST calls: it defaulted to "monotonic", which sends the nonce in milliseconds, but Gemini's REST API expects epoch seconds. This caused every authenticated call to fail with InvalidNonce (HTTP 400). createSdkClient now passes nonceMode: 'time-based' to match. Also updated the smoke script to target whichever environment is already configured (GEMINI_SDK_ENV, default production) instead of hardcoding sandbox, and to fail loudly rather than silently skip the authenticated call when credentials aren't set. Verified with a real authenticated call (predictions.getPositions) against production before and after the fix. --- packages/mcp-server/scripts/smoke-sdk.ts | 38 ++++++++++++++---------- packages/mcp-server/src/client/sdk.ts | 12 +++++++- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/packages/mcp-server/scripts/smoke-sdk.ts b/packages/mcp-server/scripts/smoke-sdk.ts index 8554b2d0..5af7539a 100644 --- a/packages/mcp-server/scripts/smoke-sdk.ts +++ b/packages/mcp-server/scripts/smoke-sdk.ts @@ -2,34 +2,42 @@ // Manual, local smoke check for the @gemini-markets/sdk client wired up in // src/client/sdk.ts — mirrors packages/sdk-typescript's own smoke:sandbox scripts and // the plan doc's "Public-only SDK client smoke test" / "Authenticated HMAC request smoke -// test" checks. Not CI-gated; run it by hand (`npm run smoke:sdk`) against sandbox -// before relying on this client in a downstream migration ticket. +// test" checks. Not CI-gated; run it by hand (`npm run smoke:sdk`) with credentials set +// (GEMINI_API_KEY/GEMINI_API_SECRET) before relying on this client in a downstream +// migration ticket — required, not optional, so a run never silently skips the one thing +// this script exists to prove: that HmacAuth actually round-trips against a real Gemini +// account, not just that the request headers look right locally. +// +// Targets whichever environment is already configured (GEMINI_SDK_ENV, default +// production) — set GEMINI_SDK_ENV=sandbox to run against sandbox instead. The one +// authenticated call this makes (getPositions) is read-only regardless of environment. import { createSdkClient } from '../src/client/sdk.js'; import { config } from '../src/config.js'; async function main(): Promise { - // Mutate the already-loaded config singleton directly (same pattern this package's - // own tests use) rather than setting process.env, since config snapshots env vars at - // import time — too late to affect it from within this same process. - config.sdkEnv = 'sandbox'; + if (!config.apiKey || !config.apiSecret) { + console.error( + '[smoke:sdk] FAILED: GEMINI_API_KEY and GEMINI_API_SECRET must both be set — this ' + + 'script exists specifically to prove an authenticated call works, so it refuses to ' + + 'run public-only.' + ); + process.exit(1); + } - console.log('[smoke:sdk] constructing SDK client against sandbox...'); + console.log(`[smoke:sdk] constructing SDK client against ${config.sdkEnv}...`); const client = await createSdkClient(); console.log('[smoke:sdk] public call: predictions.getCategories()'); const categories = await client.predictions.getCategories(); console.log(`[smoke:sdk] OK — received ${JSON.stringify(categories).length} bytes`); - if (config.apiKey && config.apiSecret) { - console.log('[smoke:sdk] authenticated call: predictions.getPositions()'); - const positions = await client.predictions.getPositions(); - console.log(`[smoke:sdk] OK — received ${JSON.stringify(positions).length} bytes`); - } else { - console.log('[smoke:sdk] no GEMINI_API_KEY/GEMINI_API_SECRET set — skipping authenticated call'); - } + console.log('[smoke:sdk] authenticated call (read-only): predictions.getPositions()'); + const positions = await client.predictions.getPositions(); + console.log(`[smoke:sdk] OK — received ${JSON.stringify(positions).length} bytes`); + console.log(`[smoke:sdk] positions payload: ${JSON.stringify(positions)}`); client.close(); - console.log('[smoke:sdk] done'); + console.log(`[smoke:sdk] done — authenticated call succeeded against ${config.sdkEnv}`); } main().catch((err) => { diff --git a/packages/mcp-server/src/client/sdk.ts b/packages/mcp-server/src/client/sdk.ts index 3bbf397e..49b29fc2 100644 --- a/packages/mcp-server/src/client/sdk.ts +++ b/packages/mcp-server/src/client/sdk.ts @@ -21,7 +21,17 @@ export async function createSdkClient( ): Promise { const auth = config.apiKey && config.apiSecret - ? new HmacAuth({ apiKey: config.apiKey, apiSecret: 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({ + apiKey: config.apiKey, + apiSecret: config.apiSecret, + nonceMode: 'time-based', + }) : undefined; return createClient({ env: config.sdkEnv, auth, ...overrides }); From 3394dcf77f3c649ace90347d35e939758ac40c8b Mon Sep 17 00:00:00 2001 From: karan acharya Date: Fri, 18 Sep 2026 13:32:28 -0400 Subject: [PATCH 5/5] fix(mcp-server): make smoke-sdk.ts logging bigint-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPositions() returns instrumentId as a bigint, which plain JSON.stringify cannot serialize — it throws instead of just losing precision. Any account with an open position would crash the script right after the authenticated call succeeded. Added a stringify helper that converts bigint values to strings before logging, used everywhere a response gets logged. --- packages/mcp-server/scripts/smoke-sdk.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/mcp-server/scripts/smoke-sdk.ts b/packages/mcp-server/scripts/smoke-sdk.ts index 5af7539a..24a6e820 100644 --- a/packages/mcp-server/scripts/smoke-sdk.ts +++ b/packages/mcp-server/scripts/smoke-sdk.ts @@ -14,6 +14,15 @@ import { createSdkClient } from '../src/client/sdk.js'; import { config } from '../src/config.js'; +// The SDK normalizes schema-declared int64 fields (e.g. positions[].instrumentId) to +// bigint, which plain JSON.stringify cannot serialize at all — it throws, not just +// loses precision. This logging script only needs a human-readable value, not a +// round-trippable one, so stringify each bigint rather than avoiding serialization +// entirely. +function stringifySafe(value: unknown): string { + return JSON.stringify(value, (_key, val) => (typeof val === 'bigint' ? val.toString() : val)); +} + async function main(): Promise { if (!config.apiKey || !config.apiSecret) { console.error( @@ -29,12 +38,12 @@ async function main(): Promise { console.log('[smoke:sdk] public call: predictions.getCategories()'); const categories = await client.predictions.getCategories(); - console.log(`[smoke:sdk] OK — received ${JSON.stringify(categories).length} bytes`); + console.log(`[smoke:sdk] OK — received ${stringifySafe(categories).length} bytes`); console.log('[smoke:sdk] authenticated call (read-only): predictions.getPositions()'); const positions = await client.predictions.getPositions(); - console.log(`[smoke:sdk] OK — received ${JSON.stringify(positions).length} bytes`); - console.log(`[smoke:sdk] positions payload: ${JSON.stringify(positions)}`); + console.log(`[smoke:sdk] OK — received ${stringifySafe(positions).length} bytes`); + console.log(`[smoke:sdk] positions payload: ${stringifySafe(positions)}`); client.close(); console.log(`[smoke:sdk] done — authenticated call succeeded against ${config.sdkEnv}`);