-
Notifications
You must be signed in to change notification settings - Fork 1
feat(mcp-server): wire @gemini-markets/sdk client into mcp-server (PREDICT-8816) #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
karanach319
merged 5 commits into
main
from
predict-8816-wire-gemini-marketssdk-client-into-mcp-server
Sep 18, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
dbff4b1
feat(mcp-server): wire @gemini-markets/sdk client into mcp-server (PR…
karanach319 63a2498
fix(mcp-server): address svc-grace review findings on PREDICT-8816
karanach319 67100ac
fix(mcp-server): verify HmacAuth is actually wired, per svc-grace fol…
karanach319 6f621de
testing authenticated call with the SDK
karanach319 3394dcf
fix(mcp-server): make smoke-sdk.ts logging bigint-safe
karanach319 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| 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); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string> | 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; | ||
| } | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ReturnType<typeof createClient>>; | ||
|
|
||
| // 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<Parameters<typeof createClient>[0]> | ||
| ): Promise<SdkClient> { | ||
| 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 }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(), | ||
| }; |
4 changes: 2 additions & 2 deletions
4
packages/mcp-server/src/datasources/predictions/market-data.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.