diff --git a/packages/mcp-server/src/client/sdk.ts b/packages/mcp-server/src/client/sdk.ts index 49b29fc..dfc2f24 100644 --- a/packages/mcp-server/src/client/sdk.ts +++ b/packages/mcp-server/src/client/sdk.ts @@ -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(input: T): T { + return config.account ? { ...input, account: config.account } : input; +} diff --git a/packages/mcp-server/src/datasources/predictions/combos.test.ts b/packages/mcp-server/src/datasources/predictions/combos.test.ts index 2cf8933..7f93eef 100644 --- a/packages/mcp-server/src/datasources/predictions/combos.test.ts +++ b/packages/mcp-server/src/datasources/predictions/combos.test.ts @@ -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; @@ -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), false); + assert.deepStrictEqual(calls[2]!.input, { instrumentSymbol: 'GEMI-CMB-X' }); +}); diff --git a/packages/mcp-server/src/datasources/predictions/combos.ts b/packages/mcp-server/src/datasources/predictions/combos.ts index ec3592e..9871116 100644 --- a/packages/mcp-server/src/datasources/predictions/combos.ts +++ b/packages/mcp-server/src/datasources/predictions/combos.ts @@ -1,4 +1,5 @@ import type { SdkClient } from '../../client/sdk.js'; +import { withAccountScope } from '../../client/sdk.js'; import type { ListCombosResponse, ComboResponse, @@ -116,7 +117,7 @@ export async function createCombo( client: SdkClient, legs: Array<{ contractId: string; requiredOutcome: 'Yes' | 'No' }> ): Promise { - const result = await client.predictions.createCombo({ legs }); + const result = await client.predictions.createCombo(withAccountScope({ legs })); return { alreadyExisted: result.alreadyExisted, combo: mapComboSummary(result.combo), diff --git a/packages/mcp-server/src/datasources/predictions/market-data.ts b/packages/mcp-server/src/datasources/predictions/market-data.ts index dbaf018..d90fd78 100644 --- a/packages/mcp-server/src/datasources/predictions/market-data.ts +++ b/packages/mcp-server/src/datasources/predictions/market-data.ts @@ -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, @@ -147,14 +147,10 @@ export async function getVolumeMetrics( const input: Record = { 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 + withAccountScope(input) as SdkInput ); return response as unknown as VolumeMetrics; } diff --git a/packages/mcp-server/src/datasources/predictions/orders.test.ts b/packages/mcp-server/src/datasources/predictions/orders.test.ts index 7ba9a7a..12c06f5 100644 --- a/packages/mcp-server/src/datasources/predictions/orders.test.ts +++ b/packages/mcp-server/src/datasources/predictions/orders.test.ts @@ -1,127 +1,381 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import type { GeminiHttpClient } from '../../client/http.js'; +import { HmacAuth } from '@gemini-markets/sdk/server'; +import { config } from '../../config.js'; +import { createSdkClient, type SdkClient } from '../../client/sdk.js'; import * as predictions from './orders.js'; -// A fake client that records exactly how each datasource function called it, -// without touching the network. This is the layer that would catch a wrong -// path or a param sent in the body instead of the query — the two most -// likely defects when wiring a new REST endpoint. +type Fn = + | 'placeOrder' + | 'cancelOrder' + | 'placeOrderBatch' + | 'cancelOrderBatch' + | 'getActiveOrders' + | 'getOrderHistory'; + interface Call { - kind: 'publicGet' | 'authenticatedGet' | 'authenticatedPost'; - endpoint: string; - body?: unknown; - params?: unknown; + fn: Fn; + input: unknown; } -function fakeClient(response: unknown = {}) { +// A fake SDK client that records exactly what each datasource function passed to it. +// Responses are what the SDK itself returns: int64 orderIds already decoded to bigint. +function fakeClient(responses: Partial> = {}) { const calls: Call[] = []; + const method = (fn: Fn, fallback: unknown) => async (input?: unknown) => { + calls.push({ fn, input }); + return responses[fn] ?? fallback; + }; const client = { - publicGet: async (endpoint: string, params?: unknown) => { - calls.push({ kind: 'publicGet', endpoint, params }); - return response; - }, - authenticatedGet: async (endpoint: string, params?: unknown) => { - calls.push({ kind: 'authenticatedGet', endpoint, params }); - return response; + predictions: { + placeOrder: method('placeOrder', { orderId: 1n }), + cancelOrder: method('cancelOrder', { result: 'ok', message: 'cancelled' }), + placeOrderBatch: method('placeOrderBatch', { results: [] }), + cancelOrderBatch: method('cancelOrderBatch', { results: [] }), + getActiveOrders: method('getActiveOrders', { orders: [], pagination: { limit: 50, offset: 0 } }), + getOrderHistory: method('getOrderHistory', { orders: [], pagination: { limit: 50, offset: 0 } }), }, - authenticatedPost: async (endpoint: string, body?: unknown, params?: unknown) => { - calls.push({ kind: 'authenticatedPost', endpoint, body, params }); - return response; - }, - } as unknown as GeminiHttpClient; + } as unknown as SdkClient; return { client, calls }; } -test('placeOrderBatch posts to /v1/prediction-markets/order/batch with orderType injected', async () => { - const { client, calls } = fakeClient({ results: [] }); +async function withAccount(account: string, fn: () => Promise): Promise { + const saved = config.account; + config.account = account; + try { + return await fn(); + } finally { + config.account = saved; + } +} + +const baseOrder = { + symbol: 'GEMI-PRES2028-VANCE', + side: 'buy' as const, + outcome: 'yes' as const, + quantity: '10', + price: '0.42', +}; + +// ---------------------------------------------------------------------------- +// What each function passes to the SDK +// ---------------------------------------------------------------------------- + +test('placeOrder sends a limit order with makerOrCancel: false and no timeInForce by default', async () => { + const { client, calls } = fakeClient(); - await predictions.placeOrderBatch(client, [ - { symbol: 'GEMI-PRES2028-VANCE', side: 'buy', outcome: 'yes', quantity: '10', price: '0.42' }, + await withAccount('', () => predictions.placeOrder(client, baseOrder)); + + assert.deepStrictEqual(calls, [ + { + fn: 'placeOrder', + input: { ...baseOrder, orderType: 'limit', makerOrCancel: false }, + }, ]); +}); + +test('placeOrder forwards a supported timeInForce unchanged', async () => { + const { client, calls } = fakeClient(); + + await withAccount('', () => predictions.placeOrder(client, { ...baseOrder, timeInForce: 'fill-or-kill' })); + + assert.deepStrictEqual(calls[0]!.input, { + ...baseOrder, + orderType: 'limit', + timeInForce: 'fill-or-kill', + makerOrCancel: false, + }); +}); + +test('placeOrder maps timeInForce maker-or-cancel onto the SDK makerOrCancel flag', async () => { + const { client, calls } = fakeClient(); + + await withAccount('', () => predictions.placeOrder(client, { ...baseOrder, timeInForce: 'maker-or-cancel' })); + + const input = calls[0]!.input as Record; + assert.strictEqual(input['makerOrCancel'], true); + assert.strictEqual('timeInForce' in input, false, 'the SDK rejects timeInForce: maker-or-cancel'); +}); + +test('cancelOrder converts an 18-digit orderId to an exact bigint, never Number()', async () => { + const { client, calls } = fakeClient(); + + await withAccount('', () => predictions.cancelOrder(client, '145828833218573125')); + + assert.deepStrictEqual(calls[0]!.input, { orderId: 145828833218573125n }); +}); + +test('placeOrderBatch maps every order and preserves request order', async () => { + const { client, calls } = fakeClient(); - assert.strictEqual(calls.length, 1); - const call = calls[0]!; - assert.strictEqual(call.kind, 'authenticatedPost'); - assert.strictEqual(call.endpoint, '/v1/prediction-markets/order/batch'); - assert.deepStrictEqual(call.body, { + await withAccount('', () => + predictions.placeOrderBatch(client, [ + { ...baseOrder, symbol: 'GEMI-A' }, + { ...baseOrder, symbol: 'GEMI-B', timeInForce: 'immediate-or-cancel' }, + { ...baseOrder, symbol: 'GEMI-C', timeInForce: 'maker-or-cancel' }, + ]) + ); + + assert.deepStrictEqual(calls[0]!.input, { orders: [ + { ...baseOrder, symbol: 'GEMI-A', orderType: 'limit', makerOrCancel: false }, { - symbol: 'GEMI-PRES2028-VANCE', + ...baseOrder, + symbol: 'GEMI-B', orderType: 'limit', - side: 'buy', - outcome: 'yes', - quantity: '10', - price: '0.42', + timeInForce: 'immediate-or-cancel', + makerOrCancel: false, }, + { ...baseOrder, symbol: 'GEMI-C', orderType: 'limit', makerOrCancel: true }, ], }); }); -test('placeOrderBatch only includes timeInForce for orders that specify it', async () => { - const { client, calls } = fakeClient({ results: [] }); +test('cancelOrderBatch passes orderId strings through unchanged', async () => { + const { client, calls } = fakeClient(); - await predictions.placeOrderBatch(client, [ - { symbol: 'GEMI-A', side: 'buy', outcome: 'yes', quantity: '1', price: '0.5' }, - { - symbol: 'GEMI-B', - side: 'sell', - outcome: 'no', - quantity: '2', - price: '0.6', - timeInForce: 'immediate-or-cancel', - }, - ]); + await withAccount('', () => predictions.cancelOrderBatch(client, ['111', '145828833218573125'])); - const body = calls[0]!.body as { orders: Record[] }; - assert.strictEqual('timeInForce' in body.orders[0]!, false); - assert.strictEqual(body.orders[1]!['timeInForce'], 'immediate-or-cancel'); + assert.deepStrictEqual(calls[0]!.input, { orderIds: ['111', '145828833218573125'] }); }); -test('placeOrderBatch preserves request order positionally', async () => { - const { client, calls } = fakeClient({ results: [] }); +test('getActiveOrders sends only the filters that are set', async () => { + const { client, calls } = fakeClient(); - await predictions.placeOrderBatch(client, [ - { symbol: 'GEMI-A', side: 'buy', outcome: 'yes', quantity: '1', price: '0.5' }, - { symbol: 'GEMI-B', side: 'sell', outcome: 'no', quantity: '2', price: '0.6' }, - { symbol: 'GEMI-C', side: 'buy', outcome: 'no', quantity: '3', price: '0.7' }, - ]); + await withAccount('', async () => { + await predictions.getActiveOrders(client); + await predictions.getActiveOrders(client, { symbol: '', limit: 10, offset: 0 }); + await predictions.getActiveOrders(client, { symbol: 'GEMI-A' }); + }); - const body = calls[0]!.body as { orders: Record[] }; assert.deepStrictEqual( - body.orders.map((o) => o['symbol']), - ['GEMI-A', 'GEMI-B', 'GEMI-C'] + calls.map((c) => c.input), + [{}, { limit: 10, offset: 0 }, { symbol: 'GEMI-A' }] ); }); -test('placeOrderBatch returns the response unchanged', async () => { - const response = { results: [{ order: { orderId: '1' } }] }; - const { client } = fakeClient(response); +test('getOrderHistory sends only the filters that are set', async () => { + const { client, calls } = fakeClient(); - const result = await predictions.placeOrderBatch(client, [ - { symbol: 'GEMI-A', side: 'buy', outcome: 'yes', quantity: '1', price: '0.5' }, - ]); + await withAccount('', async () => { + await predictions.getOrderHistory(client); + await predictions.getOrderHistory(client, { status: 'cancelled', symbol: 'GEMI-A', limit: 5, offset: 10 }); + }); + + assert.deepStrictEqual( + calls.map((c) => c.input), + [{}, { status: 'cancelled', symbol: 'GEMI-A', limit: 5, offset: 10 }] + ); +}); + +// ---------------------------------------------------------------------------- +// Sub-account scope — the legacy client added GEMINI_ACCOUNT to every signed body +// ---------------------------------------------------------------------------- + +test('every order call adds config.account when GEMINI_ACCOUNT is set', async () => { + const { client, calls } = fakeClient(); + + await withAccount('sub-account-1', async () => { + await predictions.placeOrder(client, baseOrder); + await predictions.cancelOrder(client, '1'); + await predictions.placeOrderBatch(client, [baseOrder]); + await predictions.cancelOrderBatch(client, ['1']); + await predictions.getActiveOrders(client); + await predictions.getOrderHistory(client); + }); + + assert.strictEqual(calls.length, 6); + for (const call of calls) { + assert.strictEqual((call.input as Record)['account'], 'sub-account-1', call.fn); + } +}); + +test('no order call adds an account when GEMINI_ACCOUNT is unset', async () => { + const { client, calls } = fakeClient(); + + await withAccount('', async () => { + await predictions.placeOrder(client, baseOrder); + await predictions.cancelOrder(client, '1'); + await predictions.getActiveOrders(client); + }); + + for (const call of calls) { + assert.strictEqual('account' in (call.input as Record), false, call.fn); + } +}); - assert.strictEqual(result, response); +// ---------------------------------------------------------------------------- +// Response mapping — bigint orderIds become exact strings, everything else unchanged +// ---------------------------------------------------------------------------- + +test('placeOrder stringifies orderId and passes every other field through', async () => { + const sdkOrder = { + orderId: 145828833218573125n, + hashOrderId: 'h1', + status: 'open', + symbol: 'GEMI-A', + side: 'buy', + outcome: 'yes', + orderType: 'limit', + quantity: '10', + filledQuantity: '0', + remainingQuantity: '10', + price: '0.42', + avgExecutionPrice: null, + createdAt: '2026-01-01T00:00:00Z', + }; + const { client } = fakeClient({ placeOrder: sdkOrder }); + + const result = await withAccount('', () => predictions.placeOrder(client, baseOrder)); + + assert.deepStrictEqual(result, { ...sdkOrder, orderId: '145828833218573125' }); }); -test('cancelOrderBatch posts to /v1/prediction-markets/order/batch/cancel with { orderIds }', async () => { - const { client, calls } = fakeClient({ results: [] }); +test('getActiveOrders stringifies every orderId and keeps pagination', async () => { + const { client } = fakeClient({ + getActiveOrders: { + orders: [ + { orderId: 145828833218573125n, symbol: 'GEMI-A' }, + { orderId: 145828833218573126n, symbol: 'GEMI-B' }, + ], + pagination: { limit: 50, offset: 0, count: 2 }, + }, + }); - await predictions.cancelOrderBatch(client, ['111', '222', '333']); + const result = await withAccount('', () => predictions.getActiveOrders(client)); - assert.strictEqual(calls.length, 1); - const call = calls[0]!; - assert.strictEqual(call.kind, 'authenticatedPost'); - assert.strictEqual(call.endpoint, '/v1/prediction-markets/order/batch/cancel'); - assert.deepStrictEqual(call.body, { orderIds: ['111', '222', '333'] }); + assert.deepStrictEqual(result, { + orders: [ + { orderId: '145828833218573125', symbol: 'GEMI-A' }, + { orderId: '145828833218573126', symbol: 'GEMI-B' }, + ], + pagination: { limit: 50, offset: 0, count: 2 }, + }); }); -test('cancelOrderBatch returns the response unchanged', async () => { - const response = { results: [{ orderId: '111', result: 'cancelled' }] }; - const { client } = fakeClient(response); +test('placeOrderBatch keeps mixed accepted and rejected results in request order', async () => { + const { client } = fakeClient({ + placeOrderBatch: { + results: [ + { order: { orderId: 145828833218573125n, status: 'open', symbol: 'GEMI-A' } }, + { error: 'InvalidPrice', message: 'price must be between 0.01 and 0.99' }, + ], + }, + }); + + const result = await withAccount('', () => predictions.placeOrderBatch(client, [baseOrder, baseOrder])); + + assert.deepStrictEqual(result, { + results: [ + { order: { orderId: '145828833218573125', status: 'open', symbol: 'GEMI-A' } }, + { error: 'InvalidPrice', message: 'price must be between 0.01 and 0.99' }, + ], + }); +}); + +test('cancelOrderBatch stringifies orderId on both successful and rejected results', async () => { + const { client } = fakeClient({ + cancelOrderBatch: { + results: [ + { orderId: 145828833218573125n, result: 'ok' }, + { orderId: 222n, error: 'OrderNotFound', message: 'no open order with that ID' }, + ], + }, + }); + + const result = await withAccount('', () => predictions.cancelOrderBatch(client, ['145828833218573125', '222'])); + + assert.deepStrictEqual(result, { + results: [ + { orderId: '145828833218573125', result: 'ok' }, + { orderId: '222', error: 'OrderNotFound', message: 'no open order with that ID' }, + ], + }); +}); + +// ---------------------------------------------------------------------------- +// Through the real SDK — proves what actually reaches the wire. A mocked SDK can't +// catch the SDK's own request validation or int64 serialization. +// ---------------------------------------------------------------------------- + +interface Captured { + url: string; + payload: Record; + body: string | undefined; +} + +async function realClient(responseBody: string, status = 200) { + const captured: Captured[] = []; + const client = await createSdkClient({ + auth: new HmacAuth({ apiKey: 'test-key', apiSecret: 'test-secret', nonceMode: 'time-based' }), + fetch: async (url, init) => { + const headers = init.headers as Record; + captured.push({ + url, + payload: JSON.parse(Buffer.from(headers['X-GEMINI-PAYLOAD']!, 'base64').toString('utf8')), + body: init.body as string | undefined, + }); + return new Response(responseBody, { status, headers: { 'Content-Type': 'application/json' } }); + }, + }); + return { client, captured }; +} + +test('real SDK: a maker-or-cancel order is accepted and sent as makerOrCancel: true', async () => { + const { client, captured } = await realClient('{"orderId":1}', 201); + + await withAccount('', () => predictions.placeOrder(client, { ...baseOrder, timeInForce: 'maker-or-cancel' })); + + assert.strictEqual(captured.length, 1); + assert.strictEqual(captured[0]!.url.endsWith('/v1/prediction-markets/order'), true); + assert.strictEqual(captured[0]!.payload['makerOrCancel'], true); + assert.strictEqual('timeInForce' in captured[0]!.payload, false); + assert.deepStrictEqual(JSON.parse(captured[0]!.body!), { + ...baseOrder, + orderType: 'limit', + makerOrCancel: true, + }); +}); + +test('real SDK: the old timeInForce maker-or-cancel value is rejected before sending (regression guard)', async () => { + const { client, captured } = await realClient('{}'); + + await assert.rejects( + client.predictions.placeOrder({ + ...baseOrder, + orderType: 'limit', + // Deliberately the pre-migration wire value the SDK does not accept. + timeInForce: 'maker-or-cancel' as 'good-til-cancel', + makerOrCancel: false, + }), + /timeInForce/ + ); + assert.strictEqual(captured.length, 0, 'nothing should reach the network'); +}); + +test('real SDK: cancelOrder sends an 18-digit orderId as an exact JSON number', async () => { + const { client, captured } = await realClient('{"result":"ok","message":"cancelled"}'); + + await withAccount('', () => predictions.cancelOrder(client, '145828833218573125')); + + assert.match(captured[0]!.body!, /"orderId":145828833218573125\b/); +}); + +test('real SDK: GEMINI_ACCOUNT reaches both the signed payload and the literal body', async () => { + const { client, captured } = await realClient('{"orderId":1}', 201); + + await withAccount('sub-account-1', () => predictions.placeOrder(client, baseOrder)); + + assert.strictEqual(captured[0]!.payload['account'], 'sub-account-1'); + assert.strictEqual(JSON.parse(captured[0]!.body!)['account'], 'sub-account-1'); +}); + +test('real SDK: an 18-digit orderId in the raw response survives as the exact string', async () => { + const { client } = await realClient( + '{"orders":[{"orderId":145828833218573125,"symbol":"GEMI-A"}],"pagination":{"limit":50,"offset":0}}' + ); - const result = await predictions.cancelOrderBatch(client, ['111']); + const result = await withAccount('', () => predictions.getActiveOrders(client)); - assert.strictEqual(result, response); + assert.strictEqual(result.orders[0]!.orderId, '145828833218573125'); }); diff --git a/packages/mcp-server/src/datasources/predictions/orders.ts b/packages/mcp-server/src/datasources/predictions/orders.ts index 9c209cd..9716720 100644 --- a/packages/mcp-server/src/datasources/predictions/orders.ts +++ b/packages/mcp-server/src/datasources/predictions/orders.ts @@ -1,4 +1,5 @@ -import type { GeminiHttpClient } from '../../client/http.js'; +import type { SdkClient } from '../../client/sdk.js'; +import { withAccountScope } from '../../client/sdk.js'; import type { OrdersResponse, PredictionOrder, @@ -6,92 +7,121 @@ import type { TimeInForce, PlaceOrderBatchResponse, CancelOrderBatchResponse, + BatchOrderResult, } from '../../types/predictions.js'; -export async function placeOrder( - client: GeminiHttpClient, - order: { - symbol: string; - side: 'buy' | 'sell'; - outcome: 'yes' | 'no'; - quantity: string; - price: string; - timeInForce?: TimeInForce; - } -): Promise { - return client.authenticatedPost('/v1/prediction-markets/order', { +type PredictionsService = SdkClient['predictions']; +type SdkPlaceOrderInput = Parameters[0]; +type SdkOrdersResult = Awaited>; +type SdkOrder = NonNullable[number]; +type SdkPlaceBatchResult = Awaited>['results'][number]; +type SdkCancelBatchResult = Awaited>['results'][number]; +type SdkBatchOrder = Extract['order']; + +export interface OrderInput { + symbol: string; + side: 'buy' | 'sell'; + outcome: 'yes' | 'no'; + quantity: string; + price: string; + timeInForce?: TimeInForce; +} + +// The tool keeps offering timeInForce: 'maker-or-cancel', but the SDK's TimeInForce enum +// has no such value — maker-or-cancel is its own makerOrCancel flag, and the SDK's request +// validation rejects the old value before anything is sent. Map it onto the flag (leaving +// timeInForce at the API's good-til-cancel default) so the tool contract is unchanged. +function toSdkOrder(order: OrderInput): SdkPlaceOrderInput { + const makerOrCancel = order.timeInForce === 'maker-or-cancel'; + return { symbol: order.symbol, orderType: 'limit', side: order.side, outcome: order.outcome, quantity: order.quantity, price: order.price, - ...(order.timeInForce ? { timeInForce: order.timeInForce } : {}), - }); + ...(order.timeInForce && order.timeInForce !== 'maker-or-cancel' ? { timeInForce: order.timeInForce } : {}), + makerOrCancel, + }; +} + +// The legacy client passed every order response through unmapped, so tool output carried +// every field the API sent. Keep that: spread the SDK's object and only stringify orderId, +// the one int64 field the SDK decodes to bigint — never Number(), which loses precision on +// these 17–18 digit IDs, and bigint isn't serializable by wrapHandler's JSON.stringify. +function mapOrder(o: SdkOrder): PredictionOrder { + return { ...o, orderId: o.orderId?.toString() } as PredictionOrder; +} + +function mapBatchOrder(o: SdkBatchOrder): BatchOrderResult { + return { ...o, orderId: o.orderId.toString() } as BatchOrderResult; +} + +// Results stay positional and keep rejected entries verbatim — callers must be able to +// report each order's own outcome, not assume a 200 means the whole batch succeeded. +function mapPlaceBatchResult(r: SdkPlaceBatchResult): PlaceOrderBatchResponse['results'][number] { + return 'order' in r ? { ...r, order: mapBatchOrder(r.order) } : r; } -export async function cancelOrder( - client: GeminiHttpClient, - orderId: string -): Promise { - return client.authenticatedPost('/v1/prediction-markets/order/cancel', { - orderId, - }); +function mapCancelBatchResult(r: SdkCancelBatchResult): CancelOrderBatchResponse['results'][number] { + return { ...r, orderId: r.orderId.toString() }; +} + +function mapOrdersResult(result: SdkOrdersResult): OrdersResponse { + return { ...result, orders: result.orders?.map(mapOrder) } as OrdersResponse; +} + +export async function placeOrder(client: SdkClient, order: OrderInput): Promise { + const result = await client.predictions.placeOrder(withAccountScope(toSdkOrder(order))); + return mapOrder(result); +} + +export async function cancelOrder(client: SdkClient, orderId: string): Promise { + // The SDK types orderId as bigint here (no string form accepted); BigInt() keeps all + // 17–18 digits exact. + const result = await client.predictions.cancelOrder(withAccountScope({ orderId: BigInt(orderId) })); + return result as CancelOrderResponse; } export async function placeOrderBatch( - client: GeminiHttpClient, - orders: Array<{ - symbol: string; - side: 'buy' | 'sell'; - outcome: 'yes' | 'no'; - quantity: string; - price: string; - timeInForce?: TimeInForce; - }> + client: SdkClient, + orders: OrderInput[] ): Promise { - return client.authenticatedPost('/v1/prediction-markets/order/batch', { - orders: orders.map((order) => ({ - symbol: order.symbol, - orderType: 'limit', - side: order.side, - outcome: order.outcome, - quantity: order.quantity, - price: order.price, - ...(order.timeInForce ? { timeInForce: order.timeInForce } : {}), - })), - }); + const result = await client.predictions.placeOrderBatch( + withAccountScope({ orders: orders.map(toSdkOrder) }) + ); + return { ...result, results: result.results.map(mapPlaceBatchResult) }; } export async function cancelOrderBatch( - client: GeminiHttpClient, + client: SdkClient, orderIds: string[] ): Promise { - return client.authenticatedPost( - '/v1/prediction-markets/order/batch/cancel', - { orderIds } - ); + // Unlike the single cancel, the batch cancel accepts numeric strings as-is. + const result = await client.predictions.cancelOrderBatch(withAccountScope({ orderIds })); + return { ...result, results: result.results.map(mapCancelBatchResult) }; } +// Same filter semantics as the legacy client: an empty symbol/status is not sent at all. export async function getActiveOrders( - client: GeminiHttpClient, + client: SdkClient, opts: { symbol?: string; limit?: number; offset?: number } = {} ): Promise { - const body: Record = {}; - if (opts.symbol) body['symbol'] = opts.symbol; - if (opts.limit !== undefined) body['limit'] = opts.limit; - if (opts.offset !== undefined) body['offset'] = opts.offset; - return client.authenticatedPost('/v1/prediction-markets/orders/active', body); + const input: NonNullable[0]> = {}; + if (opts.symbol) input.symbol = opts.symbol; + if (opts.limit !== undefined) input.limit = opts.limit; + if (opts.offset !== undefined) input.offset = opts.offset; + return mapOrdersResult(await client.predictions.getActiveOrders(withAccountScope(input))); } export async function getOrderHistory( - client: GeminiHttpClient, + client: SdkClient, opts: { status?: 'filled' | 'cancelled'; symbol?: string; limit?: number; offset?: number } = {} ): Promise { - const body: Record = {}; - if (opts.status) body['status'] = opts.status; - if (opts.symbol) body['symbol'] = opts.symbol; - if (opts.limit !== undefined) body['limit'] = opts.limit; - if (opts.offset !== undefined) body['offset'] = opts.offset; - return client.authenticatedPost('/v1/prediction-markets/orders/history', body); + const input: NonNullable[0]> = {}; + if (opts.status) input.status = opts.status; + if (opts.symbol) input.symbol = opts.symbol; + if (opts.limit !== undefined) input.limit = opts.limit; + if (opts.offset !== undefined) input.offset = opts.offset; + return mapOrdersResult(await client.predictions.getOrderHistory(withAccountScope(input))); } diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 34be707..0390d52 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -74,7 +74,7 @@ export function createServer(sdkClient: SdkClient): Server { ...createMarginTools(client), ...createStakingTools(client), ...createPredictionMarketDataTools(sdkClient), - ...createPredictionOrderTools(client), + ...createPredictionOrderTools(sdkClient), ...createPredictionPositionTools(sdkClient), ...createPredictionComboTools(sdkClient), ...createAlertTools(), diff --git a/packages/mcp-server/src/tools/annotations.test.ts b/packages/mcp-server/src/tools/annotations.test.ts index c783417..d21d133 100644 --- a/packages/mcp-server/src/tools/annotations.test.ts +++ b/packages/mcp-server/src/tools/annotations.test.ts @@ -29,7 +29,7 @@ const allTools: ToolDefinition[] = [ ...createMarginTools(client), ...createStakingTools(client), ...createPredictionMarketDataTools(sdkClient), - ...createPredictionOrderTools(client), + ...createPredictionOrderTools(sdkClient), ...createPredictionPositionTools(sdkClient), ...createPredictionComboTools(sdkClient), ...createAlertTools(), @@ -65,7 +65,7 @@ const EXPECTED_PREDICTION_TOOLS = [ test('exactly the expected 19 prediction-market tools are present, across all four split factories', () => { const predictionTools = [ ...createPredictionMarketDataTools(sdkClient), - ...createPredictionOrderTools(client), + ...createPredictionOrderTools(sdkClient), ...createPredictionPositionTools(sdkClient), ...createPredictionComboTools(sdkClient), ] diff --git a/packages/mcp-server/src/tools/predictions/orders.test.ts b/packages/mcp-server/src/tools/predictions/orders.test.ts index c604464..fcc9034 100644 --- a/packages/mcp-server/src/tools/predictions/orders.test.ts +++ b/packages/mcp-server/src/tools/predictions/orders.test.ts @@ -1,21 +1,26 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import JSONBig from 'json-bigint'; -import type { GeminiHttpClient } from '../../client/http.js'; +import type { SdkClient } from '../../client/sdk.js'; import { createPredictionOrderTools } from './orders.js'; -// Requests never leave this test — every batch call in these tests is -// intercepted by the fake client before it reaches GeminiHttpClient's real -// networking code. +// Requests never leave this test — every call is intercepted by this fake SDK client. +// Responses are shaped the way the SDK returns them: int64 orderIds already decoded to +// bigint, which the datasource layer must turn back into exact strings. function fakeClient(response: unknown = {}) { + const method = async () => response; return { - publicGet: async () => response, - authenticatedGet: async () => response, - authenticatedPost: async () => response, - } as unknown as GeminiHttpClient; + predictions: { + placeOrder: method, + cancelOrder: method, + placeOrderBatch: method, + cancelOrderBatch: method, + getActiveOrders: method, + getOrderHistory: method, + }, + } as unknown as SdkClient; } -function toolNamed(client: GeminiHttpClient, name: string) { +function toolNamed(client: SdkClient, name: string) { const tool = createPredictionOrderTools(client).find((t) => t.name === name); if (!tool) throw new Error(`tool not found: ${name}`); return tool; @@ -29,15 +34,6 @@ function textOf(result: { content: { type: string; text?: string }[] }): string return block.text; } -// Same precision-preserving parser the real GeminiHttpClient uses -// (src/client/http.ts). Used here to turn hand-written raw JSON text — with -// an 18-digit orderId as a bare JSON number, exactly as the API sends it — -// into the object a real client call would hand to the datasource layer. -// Building the fixture via JSON.stringify of a JS object literal would not -// prove anything: an 18-digit numeric literal in JS source is already -// truncated by the time any parser sees it. -const jsonParse = JSONBig({ storeAsString: true }); - function order(overrides: Record = {}) { return { symbol: 'GEMI-PRES2028-VANCE', @@ -151,16 +147,67 @@ test('gemini_cancel_prediction_order_batch is destructive and requires confirm: assert.strictEqual(tool.inputSchema.safeParse({ orderIds: ['1'], confirm: true }).success, true); }); +test('gemini_place_prediction_order and gemini_cancel_prediction_order are destructive and require confirm: true', () => { + const place = toolNamed(fakeClient(), 'gemini_place_prediction_order'); + assert.strictEqual(place.mutates, 'destructive'); + assert.strictEqual(place.inputSchema.safeParse(order()).success, false); + assert.strictEqual(place.inputSchema.safeParse({ ...order(), confirm: false }).success, false); + assert.strictEqual(place.inputSchema.safeParse({ ...order(), confirm: true }).success, true); + + const cancel = toolNamed(fakeClient(), 'gemini_cancel_prediction_order'); + assert.strictEqual(cancel.mutates, 'destructive'); + assert.strictEqual(cancel.inputSchema.safeParse({ orderId: '1' }).success, false); + assert.strictEqual(cancel.inputSchema.safeParse({ orderId: '1', confirm: true }).success, true); +}); + +test('the read-only order tools are not marked as mutating', () => { + for (const name of ['gemini_get_prediction_active_orders', 'gemini_get_prediction_order_history']) { + assert.strictEqual(toolNamed(fakeClient(), name).mutates, undefined, name); + } +}); + // ---------------------------------------------------------------------------- -// Precision regression — 18-digit orderId must survive verbatim +// Precision regression — 18-digit orderId must survive verbatim. The SDK hands back +// bigint (exact); the tool output must carry it as the exact string, not a rounded +// number and not a JSON.stringify crash. // ---------------------------------------------------------------------------- +function parsedOutput(result: { content: { type: string; text?: string }[] }) { + return JSON.parse(textOf(result).replace(/^]*>\n/, '').replace(/\n<\/tool-output>$/, '')); +} + +test('gemini_place_prediction_order returns an 18-digit orderId as the exact string', async () => { + const tool = toolNamed( + fakeClient({ orderId: 145828833218573125n, status: 'open', symbol: 'GEMI-A' }), + 'gemini_place_prediction_order' + ); + const result = await tool.handler(tool.inputSchema.parse({ ...order(), confirm: true })); + + assert.strictEqual(result.isError, undefined); + assert.strictEqual(parsedOutput(result).orderId, '145828833218573125'); +}); + test('gemini_place_prediction_order_batch preserves 18-digit orderId precision', async () => { - const raw = - '{"results":[{"order":{"orderId":145828833218573125,"hashOrderId":"h1","status":"open",' + - '"symbol":"GEMI-A","side":"buy","outcome":"yes","orderType":"limit","quantity":"10",' + - '"filledQuantity":"0","remainingQuantity":"10","price":"0.42","createdAt":"2026-01-01T00:00:00Z"}}]}'; - const response = jsonParse.parse(raw); + const response = { + results: [ + { + order: { + orderId: 145828833218573125n, + hashOrderId: 'h1', + status: 'open', + symbol: 'GEMI-A', + side: 'buy', + outcome: 'yes', + orderType: 'limit', + quantity: '10', + filledQuantity: '0', + remainingQuantity: '10', + price: '0.42', + createdAt: '2026-01-01T00:00:00Z', + }, + }, + ], + }; const tool = toolNamed(fakeClient(response), 'gemini_place_prediction_order_batch'); const result = await tool.handler(tool.inputSchema.parse({ orders: [order()], confirm: true })); @@ -171,16 +218,26 @@ test('gemini_place_prediction_order_batch preserves 18-digit orderId precision', }); test('gemini_cancel_prediction_order_batch preserves 18-digit orderId precision', async () => { - const raw = '{"results":[{"orderId":145828833218573125,"result":"cancelled"}]}'; - const response = jsonParse.parse(raw); + const response = { results: [{ orderId: 145828833218573125n, result: 'ok' }] }; const tool = toolNamed(fakeClient(response), 'gemini_cancel_prediction_order_batch'); const result = await tool.handler( tool.inputSchema.parse({ orderIds: ['145828833218573125'], confirm: true }) ); - const text = textOf(result); - assert.ok(text.includes('145828833218573125'), 'orderId must survive as the exact string'); + assert.strictEqual(parsedOutput(result).results[0].orderId, '145828833218573125'); +}); + +test('gemini_get_prediction_active_orders returns every orderId as the exact string', async () => { + const response = { + orders: [{ orderId: 145828833218573125n, symbol: 'GEMI-A' }], + pagination: { limit: 50, offset: 0 }, + }; + + const tool = toolNamed(fakeClient(response), 'gemini_get_prediction_active_orders'); + const result = await tool.handler(tool.inputSchema.parse({})); + + assert.strictEqual(parsedOutput(result).orders[0].orderId, '145828833218573125'); }); // ---------------------------------------------------------------------------- @@ -188,51 +245,68 @@ test('gemini_cancel_prediction_order_batch preserves 18-digit orderId precision' // ---------------------------------------------------------------------------- test('gemini_place_prediction_order_batch passes through a mix of accepted and rejected entries', async () => { - const raw = - '{"results":[' + - '{"order":{"orderId":"1","hashOrderId":"h1","status":"open","symbol":"GEMI-A","side":"buy",' + - '"outcome":"yes","orderType":"limit","quantity":"10","filledQuantity":"0",' + - '"remainingQuantity":"10","price":"0.42","createdAt":"2026-01-01T00:00:00Z"}},' + - '{"error":"InvalidPrice","message":"price must be between 0.01 and 0.99"}' + - ']}'; - const response = jsonParse.parse(raw); + const response = { + results: [ + { order: { orderId: 1n, hashOrderId: 'h1', status: 'open', symbol: 'GEMI-A' } }, + { error: 'InvalidPrice', message: 'price must be between 0.01 and 0.99' }, + ], + }; const tool = toolNamed(fakeClient(response), 'gemini_place_prediction_order_batch'); const result = await tool.handler( tool.inputSchema.parse({ - orders: [order(), order({ price: '5.00' })], + orders: [order(), order({ price: '0.43' })], confirm: true, }) ); - const text = textOf(result); - const parsed = JSON.parse(text.replace(/^]*>\n/, '').replace(/\n<\/tool-output>$/, '')); + const parsed = parsedOutput(result); assert.strictEqual(parsed.results.length, 2); assert.ok('order' in parsed.results[0], 'first result is a successful order'); + assert.strictEqual(parsed.results[0].order.orderId, '1'); assert.ok('error' in parsed.results[1], 'second result is a rejection'); assert.strictEqual(parsed.results[1].error, 'InvalidPrice'); assert.strictEqual(parsed.results[1].message, 'price must be between 0.01 and 0.99'); }); test('gemini_cancel_prediction_order_batch passes through a mix of successful and rejected cancels', async () => { - const raw = - '{"results":[' + - '{"orderId":"111","result":"cancelled"},' + - '{"orderId":"222","error":"OrderNotFound","message":"no open order with that ID"}' + - ']}'; - const response = jsonParse.parse(raw); + const response = { + results: [ + { orderId: 111n, result: 'ok' }, + { orderId: 222n, error: 'OrderNotFound', message: 'no open order with that ID' }, + ], + }; const tool = toolNamed(fakeClient(response), 'gemini_cancel_prediction_order_batch'); const result = await tool.handler( tool.inputSchema.parse({ orderIds: ['111', '222'], confirm: true }) ); - const text = textOf(result); - const parsed = JSON.parse(text.replace(/^]*>\n/, '').replace(/\n<\/tool-output>$/, '')); + const parsed = parsedOutput(result); assert.strictEqual(parsed.results.length, 2); assert.strictEqual(parsed.results[0].orderId, '111'); - assert.strictEqual(parsed.results[0].result, 'cancelled'); + assert.strictEqual(parsed.results[0].result, 'ok'); assert.strictEqual(parsed.results[1].orderId, '222'); assert.strictEqual(parsed.results[1].error, 'OrderNotFound'); assert.strictEqual(parsed.results[1].message, 'no open order with that ID'); }); + +// ---------------------------------------------------------------------------- +// Errors — an SDK ApiError surfaces with its reason/code detail +// ---------------------------------------------------------------------------- + +test('an SDK error surfaces as a tool error with its reason and code', async () => { + const failing = { + predictions: { + cancelOrder: async () => { + throw Object.assign(new Error('HTTP 404'), { reason: 'OrderNotFound', code: 'not_found' }); + }, + }, + } as unknown as SdkClient; + + const tool = toolNamed(failing, 'gemini_cancel_prediction_order'); + const result = await tool.handler(tool.inputSchema.parse({ orderId: '1', confirm: true })); + + assert.strictEqual(result.isError, true); + assert.match(textOf(result), /HTTP 404 \(reason=OrderNotFound, code=not_found\)/); +}); diff --git a/packages/mcp-server/src/tools/predictions/orders.ts b/packages/mcp-server/src/tools/predictions/orders.ts index 2bf1a39..1062b4d 100644 --- a/packages/mcp-server/src/tools/predictions/orders.ts +++ b/packages/mcp-server/src/tools/predictions/orders.ts @@ -1,12 +1,12 @@ import { z } from 'zod'; -import type { GeminiHttpClient } from '../../client/http.js'; +import type { SdkClient } from '../../client/sdk.js'; import type { ToolDefinition } from '../index.js'; import { wrapHandler, confirmField } from '../index.js'; import * as predictions from '../../datasources/predictions/orders.js'; const TimeInForceEnum = z.enum(['good-til-cancel', 'immediate-or-cancel', 'fill-or-kill', 'maker-or-cancel']); -export function createPredictionOrderTools(client: GeminiHttpClient): ToolDefinition[] { +export function createPredictionOrderTools(sdkClient: SdkClient): ToolDefinition[] { return [ { name: 'gemini_place_prediction_order', @@ -25,7 +25,7 @@ export function createPredictionOrderTools(client: GeminiHttpClient): ToolDefini ), confirm: confirmField, }), - handler: wrapHandler((args) => predictions.placeOrder(client, args)), + handler: wrapHandler((args) => predictions.placeOrder(sdkClient, args)), mutates: 'destructive', }, { @@ -41,7 +41,7 @@ export function createPredictionOrderTools(client: GeminiHttpClient): ToolDefini orderId: z.string().describe('Order ID to cancel'), confirm: confirmField, }), - handler: wrapHandler(({ orderId }) => predictions.cancelOrder(client, orderId)), + handler: wrapHandler(({ orderId }) => predictions.cancelOrder(sdkClient, orderId)), mutates: 'destructive', }, { @@ -73,7 +73,7 @@ export function createPredictionOrderTools(client: GeminiHttpClient): ToolDefini .describe('Orders to place, 1-20 per batch'), confirm: confirmField, }), - handler: wrapHandler((args) => predictions.placeOrderBatch(client, args.orders)), + handler: wrapHandler((args) => predictions.placeOrderBatch(sdkClient, args.orders)), mutates: 'destructive', }, { @@ -97,7 +97,7 @@ export function createPredictionOrderTools(client: GeminiHttpClient): ToolDefini }), confirm: confirmField, }), - handler: wrapHandler(({ orderIds }) => predictions.cancelOrderBatch(client, orderIds)), + handler: wrapHandler(({ orderIds }) => predictions.cancelOrderBatch(sdkClient, orderIds)), mutates: 'destructive', }, { @@ -108,7 +108,7 @@ export function createPredictionOrderTools(client: GeminiHttpClient): ToolDefini limit: z.number().min(1).max(100).optional().describe('Number of results (default 50, max 100)'), offset: z.number().min(0).optional().describe('Pagination offset'), }), - handler: wrapHandler((args) => predictions.getActiveOrders(client, args)), + handler: wrapHandler((args) => predictions.getActiveOrders(sdkClient, args)), }, { name: 'gemini_get_prediction_order_history', @@ -119,7 +119,7 @@ export function createPredictionOrderTools(client: GeminiHttpClient): ToolDefini limit: z.number().min(1).max(100).optional().describe('Number of results (default 50, max 100)'), offset: z.number().min(0).optional().describe('Pagination offset'), }), - handler: wrapHandler((args) => predictions.getOrderHistory(client, args)), + handler: wrapHandler((args) => predictions.getOrderHistory(sdkClient, args)), }, ]; }