diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json index 8c9f880..844f889 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 7d413c1..4720c85 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 0000000..24a6e82 --- /dev/null +++ b/packages/mcp-server/scripts/smoke-sdk.ts @@ -0,0 +1,55 @@ +#!/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`) 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'; + +// 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( + '[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 ${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 ${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 ${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}`); +} + +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 1cd85c8..d438fb4 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 0000000..fa13cac --- /dev/null +++ b/packages/mcp-server/src/client/sdk.test.ts @@ -0,0 +1,107 @@ +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 wires the configured HmacAuth into an actual authenticated request', async () => { + assert.strictEqual(config.sdkEnv, 'production'); + + // 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 { + 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(); + } +}); + +test('createSdkClient selects sandbox when GEMINI_SDK_ENV=sandbox', async () => { + config.sdkEnv = 'sandbox'; + const requestedUrls: string[] = []; + 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(); + } + } 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(); + 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 new file mode 100644 index 0000000..49b29fc --- /dev/null +++ b/packages/mcp-server/src/client/sdk.ts @@ -0,0 +1,38 @@ +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. +// +// `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 + ? // 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 }); +} diff --git a/packages/mcp-server/src/config.ts b/packages/mcp-server/src/config.ts index dfb8439..0c9d135 100644 --- a/packages/mcp-server/src/config.ts +++ b/packages/mcp-server/src/config.ts @@ -1,7 +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 ?? '', + sdkEnv: resolveSdkEnv(), }; 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 e6a109c..ef4ee06 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 7f51d6a..0ecc7d2 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 0000000..d713127 --- /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 fb383a6..2c32fb5 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',