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
15 changes: 15 additions & 0 deletions packages/mcp-server/src/client/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,18 @@ export async function createSdkClient(

return createClient({ env: config.sdkEnv, auth, ...overrides });
}

// The legacy GeminiHttpClient.authenticatedPost added config.account (GEMINI_ACCOUNT) to
// every signed request body, so a master API key reads and trades on the configured
// sub-account. The SDK has no account-scope option of its own, but for operations with a
// request body it copies every input key that isn't a path/query/header parameter into
// the signed body, so authenticated body-bearing calls wrap their input with this.
//
// It does NOT work for query-only operations (e.g. predictions.getPositions /
// getSettledPositions): the SDK sends only their declared query fields and signs no body,
// so an added `account` key is silently dropped. Scoping those needs an SDK-level account
// option. Public operations must not use it either — the SDK refuses to send a body on a
// public request.
export function withAccountScope<T extends object>(input: T): T {
return config.account ? { ...input, account: config.account } : input;
}
28 changes: 28 additions & 0 deletions packages/mcp-server/src/datasources/predictions/combos.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { SdkClient } from '../../client/sdk.js';
import { config } from '../../config.js';
import { listCombos, getCombo, createCombo } from './combos.js';

// These tests assert the exact input sent to the SDK; keep a developer's own
// GEMINI_ACCOUNT from leaking in. The account-scope tests below set it explicitly.
config.account = '';

interface Call {
fn: 'listCombos' | 'getComboByInstrumentSymbol' | 'createCombo';
input: unknown;
Expand Down Expand Up @@ -210,3 +215,26 @@ test('createCombo maps a bigint combo.id and instrumentId to exact strings, pres
assert.strictEqual(result.combo.id, '145828833218573125');
assert.strictEqual(result.combo.instrumentId, '999999999999999999');
});

// ----------------------------------------------------------------------------
// Sub-account scope — createCombo is authenticated, so it keeps the legacy client's
// GEMINI_ACCOUNT scoping. listCombos/getCombo are public and must never send one.
// ----------------------------------------------------------------------------

test('createCombo adds config.account when GEMINI_ACCOUNT is set; the public calls do not', async () => {
const { client, calls } = fakeClient();
const legs = [{ contractId: '1', requiredOutcome: 'Yes' as const }];
const saved = config.account;
config.account = 'sub-account-1';
try {
await createCombo(client, legs);
await listCombos(client);
await getCombo(client, 'GEMI-CMB-X');
} finally {
config.account = saved;
}

assert.deepStrictEqual(calls[0]!.input, { legs, account: 'sub-account-1' });
assert.strictEqual('account' in (calls[1]!.input as Record<string, unknown>), false);
assert.deepStrictEqual(calls[2]!.input, { instrumentSymbol: 'GEMI-CMB-X' });
});
3 changes: 2 additions & 1 deletion packages/mcp-server/src/datasources/predictions/combos.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SdkClient } from '../../client/sdk.js';
import { withAccountScope } from '../../client/sdk.js';
import type {
ListCombosResponse,
ComboResponse,
Expand Down Expand Up @@ -116,7 +117,7 @@ export async function createCombo(
client: SdkClient,
legs: Array<{ contractId: string; requiredOutcome: 'Yes' | 'No' }>
): Promise<CreateComboResponse> {
const result = await client.predictions.createCombo({ legs });
const result = await client.predictions.createCombo(withAccountScope({ legs }));
return {
alreadyExisted: result.alreadyExisted,
combo: mapComboSummary(result.combo),
Expand Down
12 changes: 4 additions & 8 deletions packages/mcp-server/src/datasources/predictions/market-data.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { SdkClient } from '../../client/sdk.js';
import { config } from '../../config.js';
import { withAccountScope } from '../../client/sdk.js';
import type {
EventStatus,
EventsResponse,
Expand Down Expand Up @@ -147,14 +147,10 @@ export async function getVolumeMetrics(
const input: Record<string, unknown> = { eventTicker };
if (opts.startTime !== undefined) input['startTime'] = opts.startTime;
if (opts.endTime !== undefined) input['endTime'] = opts.endTime;
// getVolumeMetrics is authenticated (the one exception among these 8 endpoints).
// The legacy GeminiHttpClient.authenticatedPost injected config.account into every
// authenticated body; the SDK has no first-class account-scope field, but its body
// builder forwards any extra input key straight through, so replicate the same
// sub-account scoping here rather than silently dropping it.
if (config.account) input['account'] = config.account;
// getVolumeMetrics is authenticated (the one exception among these 8 endpoints), so it
// keeps the legacy client's sub-account scoping — see withAccountScope.
const response = await client.predictions.getVolumeMetrics(
input as SdkInput<typeof client.predictions.getVolumeMetrics>
withAccountScope(input) as SdkInput<typeof client.predictions.getVolumeMetrics>
);
return response as unknown as VolumeMetrics;
}
Loading
Loading