Skip to content
Merged
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
25 changes: 25 additions & 0 deletions packages/mcp-server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion packages/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions packages/mcp-server/scripts/smoke-sdk.ts
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);
});
4 changes: 4 additions & 0 deletions packages/mcp-server/src/alerts/daemon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -103,6 +104,9 @@ function buildWsAdapter(wsManager: WebSocketManager): SchedulerWsAdapter {

async function main(): Promise<void> {
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();
Expand Down
107 changes: 107 additions & 0 deletions packages/mcp-server/src/client/sdk.test.ts
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');
Comment thread
karanach319 marked this conversation as resolved.
} 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;
}
});
38 changes: 38 additions & 0 deletions packages/mcp-server/src/client/sdk.ts
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 });
}
16 changes: 16 additions & 0 deletions packages/mcp-server/src/config.ts
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(),
};
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 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
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -33,7 +34,8 @@ function validateConfig(): void {

async function main(): Promise<void> {
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
Expand Down
17 changes: 17 additions & 0 deletions packages/mcp-server/src/server.test.ts
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);
});
3 changes: 2 additions & 1 deletion packages/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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',
Expand Down
Loading