diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json index 844f889..16f7fd3 100644 --- a/packages/mcp-server/package-lock.json +++ b/packages/mcp-server/package-lock.json @@ -8,7 +8,7 @@ "name": "gemini-mcp", "version": "1.0.1", "dependencies": { - "@gemini-markets/sdk": "^0.1.0", + "@gemini-markets/sdk": "^0.1.1", "@modelcontextprotocol/sdk": "^1.27.1", "json-bigint": "^1.0.0", "node-notifier": "^10.0.1", @@ -474,9 +474,9 @@ } }, "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==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@gemini-markets/sdk/-/sdk-0.1.1.tgz", + "integrity": "sha512-smA6AB6mzVTmTT9MhkRlOY/GuL+uow6zPVk0g85tLjN6WM4+j28IHePzRinjwc66BUfropYaudojmOUR4VLNeA==", "license": "Apache-2.0", "engines": { "node": ">=22.4.0" diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 4720c85..ee0301d 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -24,7 +24,7 @@ "smoke:sdk": "tsx scripts/smoke-sdk.ts" }, "dependencies": { - "@gemini-markets/sdk": "^0.1.0", + "@gemini-markets/sdk": "^0.1.1", "@modelcontextprotocol/sdk": "^1.27.1", "json-bigint": "^1.0.0", "node-notifier": "^10.0.1", diff --git a/packages/mcp-server/src/datasources/predictions/combos.test.ts b/packages/mcp-server/src/datasources/predictions/combos.test.ts index 7ec1e2c..2cf8933 100644 --- a/packages/mcp-server/src/datasources/predictions/combos.test.ts +++ b/packages/mcp-server/src/datasources/predictions/combos.test.ts @@ -1,53 +1,65 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import type { GeminiHttpClient } from '../../client/http.js'; +import type { SdkClient } from '../../client/sdk.js'; import { listCombos, getCombo, createCombo } from './combos.js'; -interface RecordedCall { - method: 'publicGet' | 'authenticatedGet' | 'authenticatedPost'; - endpoint: string; - body?: Record; - params?: Record; +interface Call { + fn: 'listCombos' | 'getComboByInstrumentSymbol' | 'createCombo'; + input: unknown; } -function fakeClient(response: unknown) { - const calls: RecordedCall[] = []; +function fakeClient( + listCombosResponse: unknown = { combos: [], pagination: { limit: 50, offset: 0 } }, + getComboResponse: unknown = { contract: {}, legs: [] }, + createComboResponse: unknown = { + alreadyExisted: false, + combo: { id: 1n, instrumentRegistered: false, legCount: 0, canonicalLegKey: 'k', legs: [] }, + } +) { + const calls: Call[] = []; const client = { - publicGet: async (endpoint: string, params?: Record) => { - calls.push({ method: 'publicGet', endpoint, params }); - return response; + predictions: { + listCombos: async (input?: unknown) => { + calls.push({ fn: 'listCombos', input }); + return listCombosResponse; + }, + getComboByInstrumentSymbol: async (input?: unknown) => { + calls.push({ fn: 'getComboByInstrumentSymbol', input }); + return getComboResponse; + }, + createCombo: async (input?: unknown) => { + calls.push({ fn: 'createCombo', input }); + return createComboResponse; + }, }, - authenticatedGet: async (endpoint: string, params?: Record) => { - calls.push({ method: 'authenticatedGet', endpoint, params }); - return response; - }, - authenticatedPost: async ( - endpoint: string, - body?: Record, - params?: Record - ) => { - calls.push({ method: 'authenticatedPost', endpoint, body, params }); - return response; - }, - }; - return { client: client as unknown as GeminiHttpClient, calls }; + } as unknown as SdkClient; + return { client, calls }; } // ---------------------------------------------------------------------------- -// listCombos +// listCombos — forwards options to the SDK (contractId converted string→bigint), +// maps the response back onto the existing ListCombosResponse/ComboResponse shape // ---------------------------------------------------------------------------- -test('listCombos with no opts sends no query params', async () => { - const { client, calls } = fakeClient({ combos: [], pagination: { limit: 50, offset: 0 } }); +test('listCombos with no opts calls the SDK with every field present but undefined', async () => { + const { client, calls } = fakeClient(); + await listCombos(client); assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0]!.endpoint, '/v1/prediction-markets/combos'); - assert.deepStrictEqual(calls[0]!.params, {}); + assert.strictEqual(calls[0]!.fn, 'listCombos'); + assert.deepStrictEqual(calls[0]!.input, { + status: undefined, + contractId: undefined, + instrumentRegistered: undefined, + limit: undefined, + offset: undefined, + }); }); -test('listCombos with all opts sends exactly those params, correctly stringified', async () => { - const { client, calls } = fakeClient({ combos: [], pagination: { limit: 10, offset: 5 } }); +test('listCombos converts a large contractId string to bigint, never Number() (precision)', async () => { + const { client, calls } = fakeClient(); + await listCombos(client, { status: 'Active', contractId: '123456789012345678', @@ -56,59 +68,145 @@ test('listCombos with all opts sends exactly those params, correctly stringified offset: 5, }); - assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0]!.endpoint, '/v1/prediction-markets/combos'); - assert.deepStrictEqual(calls[0]!.params, { + assert.deepStrictEqual(calls[0]!.input, { status: 'Active', - contractId: '123456789012345678', - instrumentRegistered: 'true', - limit: '10', - offset: '5', + contractId: 123456789012345678n, + instrumentRegistered: true, + limit: 10, + offset: 5, }); }); -test('listCombos stringifies instrumentRegistered: false rather than dropping it', async () => { - const { client, calls } = fakeClient({ combos: [], pagination: { limit: 50, offset: 0 } }); +test('listCombos forwards instrumentRegistered: false, not treated as absent', async () => { + const { client, calls } = fakeClient(); + await listCombos(client, { instrumentRegistered: false }); - assert.deepStrictEqual(calls[0]!.params, { instrumentRegistered: 'false' }); + assert.strictEqual((calls[0]!.input as { instrumentRegistered?: boolean }).instrumentRegistered, false); +}); + +test('listCombos maps a bigint comboId on each leg to an exact string, and drops extra contract fields', async () => { + const { client } = fakeClient({ + combos: [ + { + contract: { + contractId: 'c1', + contractName: 'Contract One', + eventTicker: 'FEDJAN26', + eventName: 'Fed January', + category: 'Politics', + // Extra SDK-only field — must not leak into the mapped output. + contractStatus: 'active', + }, + legs: [ + { + comboId: 123456789012345678n, + legIndex: 0, + contractId: '111', + requiredOutcome: 'Yes', + }, + ], + }, + ], + pagination: { limit: 50, offset: 0, total: 1 }, + }); + + const result = await listCombos(client); + + assert.deepStrictEqual(result, { + combos: [ + { + contract: { + contractId: 'c1', + contractName: 'Contract One', + eventTicker: 'FEDJAN26', + eventName: 'Fed January', + category: 'Politics', + }, + legs: [ + { + comboId: '123456789012345678', + contract: undefined, + contractId: '111', + legIndex: 0, + requiredOutcome: 'Yes', + legOutcome: undefined, + resolvedAt: undefined, + }, + ], + }, + ], + pagination: { limit: 50, offset: 0, total: 1 }, + }); }); // ---------------------------------------------------------------------------- -// getCombo +// getCombo — now calls getComboByInstrumentSymbol under the hood // ---------------------------------------------------------------------------- -test('getCombo interpolates the instrument symbol into the path, unencoded', async () => { - const { client, calls } = fakeClient({ contract: {}, legs: [] }); - // A symbol containing characters that would change under encodeURIComponent - // (e.g. nothing to encode here, but the assertion below pins the literal, - // unencoded interpolation convention shared with getEvent/getEventStrike). +test('getCombo calls the SDK with { instrumentSymbol }', async () => { + const { client, calls } = fakeClient(undefined, { contract: {}, legs: [] }); + await getCombo(client, 'GEMI-COMBO-ABC123'); assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0]!.method, 'publicGet'); - assert.strictEqual(calls[0]!.endpoint, '/v1/prediction-markets/combos/GEMI-COMBO-ABC123'); + assert.strictEqual(calls[0]!.fn, 'getComboByInstrumentSymbol'); + assert.deepStrictEqual(calls[0]!.input, { instrumentSymbol: 'GEMI-COMBO-ABC123' }); +}); + +test('getCombo maps a bigint comboId to an exact string', async () => { + const { client } = fakeClient(undefined, { + contract: { contractId: 'c1', contractName: 'C1', eventTicker: 'E', eventName: 'E', category: 'Cat' }, + legs: [{ comboId: 987654321098765432n, legIndex: 0, contractId: '111', requiredOutcome: 'No' }], + }); + + const result = await getCombo(client, 'GEMI-COMBO-ABC123'); + + assert.strictEqual(result.legs[0]!.comboId, '987654321098765432'); }); // ---------------------------------------------------------------------------- -// createCombo +// createCombo — legs pass through verbatim (capitalized Yes/No preserved), +// response mapped through mapComboSummary // ---------------------------------------------------------------------------- -test('createCombo posts { legs } with the exact legs array passed through', async () => { - const { client, calls } = fakeClient({ alreadyExisted: false, combo: {} }); +test('createCombo calls the SDK with { legs } and the exact legs array, capitalization intact', async () => { + const { client, calls } = fakeClient(); const legs: Array<{ contractId: string; requiredOutcome: 'Yes' | 'No' }> = [ { contractId: '111', requiredOutcome: 'Yes' }, { contractId: '222', requiredOutcome: 'No' }, ]; + await createCombo(client, legs); assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0]!.method, 'authenticatedPost'); - assert.strictEqual(calls[0]!.endpoint, '/v1/prediction-markets/combos'); - // Capitalized outcomes must survive verbatim — not lowercased for - // consistency with the rest of the codebase's 'yes'/'no' convention. - assert.deepStrictEqual(calls[0]!.body, { legs }); - const sentLegs = (calls[0]!.body as { legs: Array<{ requiredOutcome: string }> }).legs; - assert.strictEqual(sentLegs[0]!.requiredOutcome, 'Yes'); - assert.strictEqual(sentLegs[1]!.requiredOutcome, 'No'); + assert.strictEqual(calls[0]!.fn, 'createCombo'); + assert.deepStrictEqual(calls[0]!.input, { legs }); +}); + +test('createCombo maps a bigint combo.id and instrumentId to exact strings, preserves alreadyExisted', async () => { + const { client } = fakeClient(undefined, undefined, { + alreadyExisted: true, + combo: { + canonicalLegKey: 'k', + id: 145828833218573125n, + instrumentId: 999999999999999999n, + instrumentRegistered: true, + instrumentSymbol: 'GEMI-COMBO-XYZ', + legCount: 2, + legs: [ + { comboId: 145828833218573125n, legIndex: 0, contractId: '111', requiredOutcome: 'Yes' }, + { comboId: 145828833218573125n, legIndex: 1, contractId: '222', requiredOutcome: 'No' }, + ], + }, + }); + + const result = await createCombo(client, [ + { contractId: '111', requiredOutcome: 'Yes' }, + { contractId: '222', requiredOutcome: 'No' }, + ]); + + assert.strictEqual(result.alreadyExisted, true); + assert.strictEqual(result.combo.id, '145828833218573125'); + assert.strictEqual(result.combo.instrumentId, '999999999999999999'); }); diff --git a/packages/mcp-server/src/datasources/predictions/combos.ts b/packages/mcp-server/src/datasources/predictions/combos.ts index 0c1dac6..ec3592e 100644 --- a/packages/mcp-server/src/datasources/predictions/combos.ts +++ b/packages/mcp-server/src/datasources/predictions/combos.ts @@ -1,29 +1,124 @@ -import type { GeminiHttpClient } from '../../client/http.js'; -import type { ListCombosResponse, ComboResponse, CreateComboResponse } from '../../types/predictions.js'; +import type { SdkClient } from '../../client/sdk.js'; +import type { + ListCombosResponse, + ComboResponse, + ComboLeg, + ComboSummary, + ComboSummaryLeg, + CreateComboResponse, +} from '../../types/predictions.js'; +import { mapContractMetadata } from './mappers.js'; + +type PredictionsService = SdkClient['predictions']; +type SdkListCombosResult = Awaited>; +type SdkComboResponse = SdkListCombosResult['combos'][number]; +type SdkComboLeg = SdkComboResponse['legs'][number]; +type SdkCreateComboResult = Awaited>; +type SdkComboSummary = SdkCreateComboResult['combo']; +type SdkComboSummaryLeg = SdkComboSummary['legs'][number]; + +// Kept as a hand-declared shape rather than derived from the SDK's own listCombos +// input type: the SDK types contractId as bigint|number (via Int64Input), but the +// existing tool schema/datasource contract keeps it a string — converted with +// BigInt() below, same "never Number(), it loses precision above 2^53" reasoning +// already applied to instrumentId/accountId in positions.ts. +export interface ListCombosOptions { + status?: string; + contractId?: string; + instrumentRegistered?: boolean; + limit?: number; + offset?: number; +} + +// comboId is bigint (int64-precision) — stringify it so the existing Int64/string +// contract holds and so wrapHandler's JSON.stringify doesn't throw (bigint isn't +// serializable by JSON.stringify at all, not just imprecise). contractId stays a +// plain string on the SDK side too — no conversion needed, distinct from the +// bigint|number contractId filter on the listCombos request above. +function mapComboLeg(leg: SdkComboLeg): ComboLeg { + return { + comboId: leg.comboId.toString(), + contract: mapContractMetadata(leg.contract), + contractId: leg.contractId, + legIndex: leg.legIndex, + requiredOutcome: leg.requiredOutcome, + // ComboLeg.legOutcome is typed as a loose `string | null` on the SDK side (unlike + // ComboSummaryLeg's, which is a proper "Yes"|"No" enum) — the wire format is + // documented as always "Yes"/"No"/absent, matching the existing local type. + legOutcome: (leg.legOutcome ?? undefined) as 'Yes' | 'No' | undefined, + resolvedAt: leg.resolvedAt ?? undefined, + }; +} + +function mapComboResponse(r: SdkComboResponse): ComboResponse { + return { + contract: mapContractMetadata(r.contract)!, + legs: r.legs.map(mapComboLeg), + }; +} + +// ComboSummaryLeg is structurally identical to ComboLeg (confirmed against the SDK's +// generated spec) — SdkComboSummaryLeg's slightly stricter legOutcome/contract types +// are assignable into mapComboLeg's parameter type, so there's no need to duplicate +// the mapping logic. +function mapComboSummaryLeg(leg: SdkComboSummaryLeg): ComboSummaryLeg { + return mapComboLeg(leg); +} + +function mapComboSummary(s: SdkComboSummary): ComboSummary { + return { + canonicalLegKey: s.canonicalLegKey, + createdAt: s.createdAt, + displayName: s.displayName, + id: s.id.toString(), + instrumentId: s.instrumentId !== undefined ? s.instrumentId.toString() : undefined, + instrumentRegistered: s.instrumentRegistered, + instrumentSymbol: s.instrumentSymbol, + latestExpiryDate: s.latestExpiryDate, + legCount: s.legCount, + legs: s.legs.map(mapComboSummaryLeg), + status: s.status, + updatedAt: s.updatedAt, + }; +} export async function listCombos( - client: GeminiHttpClient, - opts: { status?: string; contractId?: string; instrumentRegistered?: boolean; limit?: number; offset?: number } = {} + client: SdkClient, + opts: ListCombosOptions = {} ): Promise { - const params: Record = {}; - if (opts.status) params['status'] = opts.status; - if (opts.contractId) params['contractId'] = opts.contractId; - if (opts.instrumentRegistered !== undefined) params['instrumentRegistered'] = String(opts.instrumentRegistered); - if (opts.limit !== undefined) params['limit'] = String(opts.limit); - if (opts.offset !== undefined) params['offset'] = String(opts.offset); - return client.publicGet('/v1/prediction-markets/combos', params); + const result = await client.predictions.listCombos({ + status: opts.status, + contractId: opts.contractId !== undefined ? BigInt(opts.contractId) : undefined, + instrumentRegistered: opts.instrumentRegistered, + limit: opts.limit, + offset: opts.offset, + }); + return { + combos: result.combos.map(mapComboResponse), + // limit/offset are optional on the SDK's generated Pagination type, but the real + // API always returns them on a listing response — same trust the legacy client + // already placed in this shape (it passed the raw parsed response straight + // through with no runtime validation at all). + pagination: { + limit: result.pagination.limit!, + offset: result.pagination.offset!, + total: result.pagination.total, + }, + }; } -export async function getCombo( - client: GeminiHttpClient, - instrumentSymbol: string -): Promise { - return client.publicGet(`/v1/prediction-markets/combos/${instrumentSymbol}`); +export async function getCombo(client: SdkClient, instrumentSymbol: string): Promise { + const result = await client.predictions.getComboByInstrumentSymbol({ instrumentSymbol }); + return mapComboResponse(result); } export async function createCombo( - client: GeminiHttpClient, + client: SdkClient, legs: Array<{ contractId: string; requiredOutcome: 'Yes' | 'No' }> ): Promise { - return client.authenticatedPost('/v1/prediction-markets/combos', { legs }); + const result = await client.predictions.createCombo({ legs }); + return { + alreadyExisted: result.alreadyExisted, + combo: mapComboSummary(result.combo), + }; } diff --git a/packages/mcp-server/src/datasources/predictions/mappers.ts b/packages/mcp-server/src/datasources/predictions/mappers.ts new file mode 100644 index 0000000..2ee797a --- /dev/null +++ b/packages/mcp-server/src/datasources/predictions/mappers.ts @@ -0,0 +1,25 @@ +import type { SdkClient } from '../../client/sdk.js'; +import type { ContractMetadata } from '../../types/predictions.js'; + +type PredictionsService = SdkClient['predictions']; +type SdkPositionsResult = Awaited>; +type SdkPosition = NonNullable[number]; +// Widened with `| null`: Position.contractMetadata is only ever undefined, but combo +// legs' `contract` field (a different embedding of the same ContractMetadata schema) +// is typed nullable on the SDK side — one shared mapper needs to accept both. +export type SdkContractMetadata = SdkPosition['contractMetadata'] | null; + +// Shared across every prediction datasource that embeds contract metadata (positions, +// combos, ...). The SDK marks contract metadata's identifying fields optional; a real +// contract response always has them, matching the level of trust the legacy client +// already placed in this shape (no runtime validation there either). +export function mapContractMetadata(meta: SdkContractMetadata): ContractMetadata | undefined { + if (!meta) return undefined; + return { + contractId: meta.contractId!, + contractName: meta.contractName!, + eventTicker: meta.eventTicker!, + eventName: meta.eventName!, + category: meta.category!, + }; +} diff --git a/packages/mcp-server/src/datasources/predictions/positions.ts b/packages/mcp-server/src/datasources/predictions/positions.ts index e510201..7212295 100644 --- a/packages/mcp-server/src/datasources/predictions/positions.ts +++ b/packages/mcp-server/src/datasources/predictions/positions.ts @@ -5,8 +5,8 @@ import type { PredictionPosition, SettledPosition, CashedOutPosition, - ContractMetadata, } from '../../types/predictions.js'; +import { mapContractMetadata } from './mappers.js'; type PredictionsService = SdkClient['predictions']; type SdkPositionsResult = Awaited>; @@ -14,7 +14,6 @@ type SdkSettledPositionsResult = Awaited[number]; type SdkSettledPosition = NonNullable[number]; type SdkCashedOutPosition = NonNullable[number]; -type SdkContractMetadata = SdkPosition['contractMetadata']; // Derived directly from the SDK's own method signatures rather than hand-declared, so // the accepted `sort`/etc. literal unions can't silently drift from what the SDK (and @@ -24,20 +23,6 @@ export type GetSettledPositionsOptions = NonNullable< Parameters[0] >; -// The SDK marks contract metadata's identifying fields optional; a real contract -// response always has them, matching the level of trust the legacy client already -// placed in this shape (no runtime validation there either). -function mapContractMetadata(meta: SdkContractMetadata): ContractMetadata | undefined { - if (!meta) return undefined; - return { - contractId: meta.contractId!, - contractName: meta.contractName!, - eventTicker: meta.eventTicker!, - eventName: meta.eventName!, - category: meta.category!, - }; -} - // The SDK returns instrumentId/accountId as bigint (int64-precision fields) — stringify // them so the existing Int64/string contracts hold and so wrapHandler's JSON.stringify // doesn't throw (bigint isn't serializable by JSON.stringify at all, not just imprecise). diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 27acd23..34be707 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -76,7 +76,7 @@ export function createServer(sdkClient: SdkClient): Server { ...createPredictionMarketDataTools(sdkClient), ...createPredictionOrderTools(client), ...createPredictionPositionTools(sdkClient), - ...createPredictionComboTools(client), + ...createPredictionComboTools(sdkClient), ...createAlertTools(), ...createMarketStreamTools(wsManager), ...createOrderStreamTools(wsManager), diff --git a/packages/mcp-server/src/tools/annotations.test.ts b/packages/mcp-server/src/tools/annotations.test.ts index c5fb60f..c783417 100644 --- a/packages/mcp-server/src/tools/annotations.test.ts +++ b/packages/mcp-server/src/tools/annotations.test.ts @@ -31,7 +31,7 @@ const allTools: ToolDefinition[] = [ ...createPredictionMarketDataTools(sdkClient), ...createPredictionOrderTools(client), ...createPredictionPositionTools(sdkClient), - ...createPredictionComboTools(client), + ...createPredictionComboTools(sdkClient), ...createAlertTools(), ]; @@ -67,7 +67,7 @@ test('exactly the expected 19 prediction-market tools are present, across all fo ...createPredictionMarketDataTools(sdkClient), ...createPredictionOrderTools(client), ...createPredictionPositionTools(sdkClient), - ...createPredictionComboTools(client), + ...createPredictionComboTools(sdkClient), ] .map((t) => t.name) .sort(); diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts index c95646b..725ea05 100644 --- a/packages/mcp-server/src/tools/index.ts +++ b/packages/mcp-server/src/tools/index.ts @@ -86,6 +86,21 @@ export interface WrapHandlerOptions { stringCap?: number; } +// @gemini-markets/sdk's ApiError always sets .message to the unhelpful `HTTP {status}` +// literal by design — the actual detail (what was actually wrong) lives on separate +// `.reason`/`.code`/`.category` properties that a plain `.message` read never sees. +// Duck-typed rather than importing the SDK's error classes, so this stays useful for +// any thrown error shape, not just the SDK's, and this file stays provider-agnostic. +function extractErrorDetail(err: unknown): string | undefined { + if (typeof err !== 'object' || err === null) return undefined; + const parts: string[] = []; + for (const key of ['reason', 'code', 'category'] as const) { + const value = (err as Record)[key]; + if (typeof value === 'string' && value.length > 0) parts.push(`${key}=${value}`); + } + return parts.length > 0 ? parts.join(', ') : undefined; +} + export function wrapHandler( handler: (args: z.infer) => Promise, opts?: WrapHandlerOptions @@ -99,8 +114,10 @@ export function wrapHandler( }; } catch (err) { const message = err instanceof Error ? err.message : String(err); + const detail = extractErrorDetail(err); + const fullMessage = detail ? `${message} (${detail})` : message; return { - content: [{ type: 'text', text: wrap(`Error: ${sanitizeString(message)}`) }], + content: [{ type: 'text', text: wrap(`Error: ${sanitizeString(fullMessage)}`) }], isError: true, }; } diff --git a/packages/mcp-server/src/tools/predictions/combos.test.ts b/packages/mcp-server/src/tools/predictions/combos.test.ts index 8fd0e33..4340cf9 100644 --- a/packages/mcp-server/src/tools/predictions/combos.test.ts +++ b/packages/mcp-server/src/tools/predictions/combos.test.ts @@ -1,20 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { SdkClient } from '../../client/sdk.js'; +import { createPredictionComboTools } from './combos.js'; +import { annotationsFor, requiresConfirmation } from '../index.js'; -// config.ts snapshots process.env when the module is first imported, so the -// credentials have to be in place before the module graph loads — hence the -// dynamic imports below (same technique as client/http.request.test.ts). -// node:test runs each test file in its own process, so this cannot leak into -// other suites. -process.env.GEMINI_API_KEY = 'test-api-key'; -process.env.GEMINI_API_SECRET = 'test-api-secret'; -process.env.GEMINI_API_BASE_URL = 'https://api.gemini.invalid'; -delete process.env.GEMINI_ACCOUNT; - -const { GeminiHttpClient } = await import('../../client/http.js'); -const { createPredictionComboTools } = await import('./combos.js'); -const { annotationsFor, requiresConfirmation } = await import('../index.js'); type ToolDefinition = ReturnType[number]; function toolNamed(tools: ToolDefinition[], name: string): ToolDefinition { @@ -31,18 +21,24 @@ function textOf(result: CallToolResult): string { return block.text; } -function stubFetch(body: string, status = 200) { - const original = globalThis.fetch; - globalThis.fetch = (async () => new Response(body, { status })) as unknown as typeof fetch; +// Requests never leave this test — every call is intercepted by the fake SDK client +// before it would reach the real @gemini-markets/sdk transport. +function fakeClient( + createComboResponse: unknown = { + alreadyExisted: false, + combo: { id: 1n, instrumentRegistered: false, legCount: 0, canonicalLegKey: 'k', legs: [] }, + } +) { return { - restore: () => { - globalThis.fetch = original; + predictions: { + listCombos: async () => ({ combos: [], pagination: { limit: 50, offset: 0 } }), + getComboByInstrumentSymbol: async () => ({ contract: {}, legs: [] }), + createCombo: async () => createComboResponse, }, - }; + } as unknown as SdkClient; } -const client = new GeminiHttpClient(); -const tools = createPredictionComboTools(client); +const tools = createPredictionComboTools(fakeClient()); // ---------------------------------------------------------------------------- // gemini_create_prediction_combo — schema validation @@ -118,7 +114,8 @@ test("gemini_create_prediction_combo rejects lowercase 'no' too", () => { }); // ---------------------------------------------------------------------------- -// mutates annotations +// mutates annotations — regression guard for the ticket-vs-code discrepancy: +// createCombo stays 'write'/no-confirm, it is NOT reclassified 'destructive' // ---------------------------------------------------------------------------- test("gemini_create_prediction_combo has mutates === 'write' and no confirm field", () => { @@ -149,35 +146,41 @@ test('gemini_list_prediction_combos and gemini_get_prediction_combo are plain re // ---------------------------------------------------------------------------- // Precision fixture — a comboId at 17-18 digits must survive as an exact -// string through the tool's output, same technique as -// client/http.request.test.ts's int64-precision test. +// string through the tool's output. The SDK hands this back as a real bigint +// (a bigint literal is exact in JS source, unlike a plain numeric literal past +// MAX_SAFE_INTEGER), and the datasource's mapper must stringify it rather than +// let it reach wrapHandler's JSON.stringify, which cannot serialize bigint at all. // ---------------------------------------------------------------------------- test('a large comboId in combo.legs[0].comboId survives as an exact string through the tool output', async () => { - // Raw JSON text, not JSON.stringify of an object literal: an 18-digit - // literal in JS source is already truncated before the parser runs. - const raw = - '{"alreadyExisted":false,"combo":{"canonicalLegKey":"k","id":1,' + - '"instrumentRegistered":false,"legCount":2,' + - '"legs":[{"comboId":145828833218573125,"contractId":"111","requiredOutcome":"Yes"},' + - '{"comboId":145828833218573125,"contractId":"222","requiredOutcome":"No"}]}}'; - const f = stubFetch(raw); - try { - const parsed = createCombo.inputSchema.parse({ + const response = { + alreadyExisted: false, + combo: { + canonicalLegKey: 'k', + id: 1n, + instrumentRegistered: false, + legCount: 2, legs: [ - { contractId: '111', requiredOutcome: 'Yes' }, - { contractId: '222', requiredOutcome: 'No' }, + { comboId: 145828833218573125n, legIndex: 0, contractId: '111', requiredOutcome: 'Yes' }, + { comboId: 145828833218573125n, legIndex: 1, contractId: '222', requiredOutcome: 'No' }, ], - }); - const result = await createCombo.handler(parsed); - const text = textOf(result); - - assert.ok(!result.isError, `expected success, got: ${text}`); - assert.match(text, /"comboId": "145828833218573125"/); - assert.doesNotMatch(text, /145828833218573120/, 'comboId must not be silently truncated to a rounded value'); - } finally { - f.restore(); - } + }, + }; + const toolsWithFixture = createPredictionComboTools(fakeClient(response)); + const combo = toolNamed(toolsWithFixture, 'gemini_create_prediction_combo'); + + const parsed = combo.inputSchema.parse({ + legs: [ + { contractId: '111', requiredOutcome: 'Yes' }, + { contractId: '222', requiredOutcome: 'No' }, + ], + }); + const result = await combo.handler(parsed); + const text = textOf(result); + + assert.ok(!result.isError, `expected success, got: ${text}`); + assert.match(text, /"comboId": "145828833218573125"/); + assert.doesNotMatch(text, /145828833218573120/, 'comboId must not be silently truncated to a rounded value'); }); // ---------------------------------------------------------------------------- @@ -185,26 +188,33 @@ test('a large comboId in combo.legs[0].comboId survives as an exact string throu // ---------------------------------------------------------------------------- test('alreadyExisted: true is preserved in the tool output, not dropped or fabricated', async () => { - const raw = - '{"alreadyExisted":true,"combo":{"canonicalLegKey":"k","id":42,' + - '"instrumentRegistered":true,"instrumentSymbol":"GEMI-COMBO-XYZ","legCount":2,' + - '"legs":[{"contractId":"111","requiredOutcome":"Yes"},{"contractId":"222","requiredOutcome":"No"}]}}'; - const f = stubFetch(raw); - try { - const parsed = createCombo.inputSchema.parse({ + const response = { + alreadyExisted: true, + combo: { + canonicalLegKey: 'k', + id: 42n, + instrumentRegistered: true, + instrumentSymbol: 'GEMI-COMBO-XYZ', + legCount: 2, legs: [ - { contractId: '111', requiredOutcome: 'Yes' }, - { contractId: '222', requiredOutcome: 'No' }, + { comboId: 42n, legIndex: 0, contractId: '111', requiredOutcome: 'Yes' }, + { comboId: 42n, legIndex: 1, contractId: '222', requiredOutcome: 'No' }, ], - }); - const result = await createCombo.handler(parsed); - const text = textOf(result); - - assert.ok(!result.isError, `expected success, got: ${text}`); - assert.match(text, /"alreadyExisted": true/); - assert.doesNotMatch(text, /"alreadyExisted": false/); - } finally { - f.restore(); - } -}); + }, + }; + const toolsWithFixture = createPredictionComboTools(fakeClient(response)); + const combo = toolNamed(toolsWithFixture, 'gemini_create_prediction_combo'); + const parsed = combo.inputSchema.parse({ + legs: [ + { contractId: '111', requiredOutcome: 'Yes' }, + { contractId: '222', requiredOutcome: 'No' }, + ], + }); + const result = await combo.handler(parsed); + const text = textOf(result); + + assert.ok(!result.isError, `expected success, got: ${text}`); + assert.match(text, /"alreadyExisted": true/); + assert.doesNotMatch(text, /"alreadyExisted": false/); +}); diff --git a/packages/mcp-server/src/tools/predictions/combos.ts b/packages/mcp-server/src/tools/predictions/combos.ts index 69a98bb..e2c8a7d 100644 --- a/packages/mcp-server/src/tools/predictions/combos.ts +++ b/packages/mcp-server/src/tools/predictions/combos.ts @@ -1,10 +1,10 @@ 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 } from '../index.js'; import * as predictions from '../../datasources/predictions/combos.js'; -export function createPredictionComboTools(client: GeminiHttpClient): ToolDefinition[] { +export function createPredictionComboTools(sdkClient: SdkClient): ToolDefinition[] { return [ { name: 'gemini_list_prediction_combos', @@ -19,7 +19,7 @@ export function createPredictionComboTools(client: GeminiHttpClient): ToolDefini limit: z.number().min(1).max(500).optional().describe('Number of results (max 500)'), offset: z.number().min(0).optional().describe('Pagination offset'), }), - handler: wrapHandler((args) => predictions.listCombos(client, args)), + handler: wrapHandler((args) => predictions.listCombos(sdkClient, args)), }, { name: 'gemini_get_prediction_combo', @@ -27,7 +27,7 @@ export function createPredictionComboTools(client: GeminiHttpClient): ToolDefini inputSchema: z.object({ instrumentSymbol: z.string().describe('Combo instrument symbol'), }), - handler: wrapHandler(({ instrumentSymbol }) => predictions.getCombo(client, instrumentSymbol)), + handler: wrapHandler(({ instrumentSymbol }) => predictions.getCombo(sdkClient, instrumentSymbol)), }, { name: 'gemini_create_prediction_combo', @@ -55,7 +55,7 @@ export function createPredictionComboTools(client: GeminiHttpClient): ToolDefini { message: 'legs must not contain duplicate contractIds' } ), }), - handler: wrapHandler((args) => predictions.createCombo(client, args.legs)), + handler: wrapHandler((args) => predictions.createCombo(sdkClient, args.legs)), mutates: 'write', }, ]; diff --git a/packages/mcp-server/src/types/predictions.ts b/packages/mcp-server/src/types/predictions.ts index 37cb8c1..cae173d 100644 --- a/packages/mcp-server/src/types/predictions.ts +++ b/packages/mcp-server/src/types/predictions.ts @@ -94,10 +94,12 @@ export interface PredictionPosition { export interface SettledPosition { accountId?: Int64; contractMetadata?: ContractMetadata; - costBasis?: string; + // costBasis/netProfit/realizedPnl: `null` is the API's explicit "cost-basis data + // unavailable" signal, passed through as-is — distinct from the field being absent. + costBasis?: string | null; instrumentId?: Int64; instrumentSymbol?: string; - netProfit?: string; + netProfit?: string | null; outcome?: 'yes' | 'no'; payout?: string; // Signed position held at settlement: positive = yes, negative = no. @@ -106,7 +108,7 @@ export interface SettledPosition { // a quoted string — typed as a union to match observed behavior. position?: string | number; positionQuantity?: string; - realizedPnl?: string; + realizedPnl?: string | null; resolutionSide?: 'yes' | 'no'; settledAt?: string; } @@ -197,15 +199,20 @@ export interface ListCombosResponse { pagination: Pagination; } -// The exact shape of a leg nested inside ComboSummary.legs (the create/register -// response) is not confirmed from the generated spec available in this project -// — only the top-level ComboSummary fields and the request-side leg shape are. -// Modeled loosely here as the subset we're confident about (contractId, -// requiredOutcome — same wire format as ComboLeg above) rather than guessing -// fields with no evidence. Extend once the actual response shape is confirmed. +// Now confirmed against the @gemini-markets/sdk generated spec (PREDICT-8819): +// structurally identical to ComboLeg above, including comboId — the legacy client +// did a raw passthrough of createCombo's response with no mapping at all, so comboId +// was already present in the real tool output despite this type previously omitting +// it (see the regression test in tools/predictions/combos.test.ts asserting comboId +// survives verbatim, which predates this widening). export interface ComboSummaryLeg { + comboId: Int64; + contract?: ContractMetadata; contractId: string; + legIndex: number; requiredOutcome: 'Yes' | 'No'; + legOutcome?: 'Yes' | 'No'; + resolvedAt?: string; } export interface ComboSummary {