From 3fd89998267a0f5cdc13aed436155e70b610ce35 Mon Sep 17 00:00:00 2001 From: namtran1812 <158846154+namtran1812@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:56:52 -0400 Subject: [PATCH] feat(mcp): support safe order reconciliation --- .../src/client/http.request.test.ts | 65 +++++++ packages/mcp-server/src/client/http.ts | 40 ++++- .../mcp-server/src/datasources/orders.test.ts | 167 ++++++++++++++++++ packages/mcp-server/src/datasources/orders.ts | 23 ++- packages/mcp-server/src/tools/orders.test.ts | 41 +++++ packages/mcp-server/src/tools/orders.ts | 22 ++- 6 files changed, 344 insertions(+), 14 deletions(-) create mode 100644 packages/mcp-server/src/datasources/orders.test.ts create mode 100644 packages/mcp-server/src/tools/orders.test.ts diff --git a/packages/mcp-server/src/client/http.request.test.ts b/packages/mcp-server/src/client/http.request.test.ts index 0e0b6d79..43a5cbe6 100644 --- a/packages/mcp-server/src/client/http.request.test.ts +++ b/packages/mcp-server/src/client/http.request.test.ts @@ -313,3 +313,68 @@ test('int64 precision survives the shared response parser', async () => { f.restore(); } }); + +test('a non-2xx response is classified as a Gemini API error', async () => { + const f = stubFetch('{"reason":"InvalidOrderType"}', 400); + try { + const { GeminiApiError } = await import('./http.js'); + const client = new GeminiHttpClient(); + + await assert.rejects( + () => + client.authenticatedPost('/v1/order/new', { + symbol: 'btcusd', + amount: '0.01', + price: '1', + side: 'buy', + type: 'exchange market', + }), + (err: unknown) => { + assert.ok(err instanceof GeminiApiError); + assert.strictEqual(err.status, 400); + assert.match(err.body, /InvalidOrderType/); + return true; + } + ); + + assert.strictEqual(f.calls.length, 1); + } finally { + f.restore(); + } +}); + +test('a fetch failure is classified as a transport error without replaying the request', async () => { + const original = globalThis.fetch; + let calls = 0; + + globalThis.fetch = (async () => { + calls += 1; + throw new TypeError('socket closed'); + }) as typeof fetch; + + try { + const { GeminiTransportError } = await import('./http.js'); + const client = new GeminiHttpClient(); + + await assert.rejects( + () => + client.authenticatedPost('/v1/order/new', { + symbol: 'btcusd', + amount: '0.01', + price: '1', + side: 'buy', + type: 'exchange limit', + client_order_id: 'recovery-42', + }), + (err: unknown) => { + assert.ok(err instanceof GeminiTransportError); + assert.match(err.message, /transport/i); + return true; + } + ); + + assert.strictEqual(calls, 1, 'a failed mutation must not be replayed'); + } finally { + globalThis.fetch = original; + } +}); diff --git a/packages/mcp-server/src/client/http.ts b/packages/mcp-server/src/client/http.ts index 0c565527..e90bfb24 100644 --- a/packages/mcp-server/src/client/http.ts +++ b/packages/mcp-server/src/client/http.ts @@ -28,6 +28,27 @@ const jsonParse = JSONBig({ storeAsString: true }); // arrays are emitted as repeated keys (`status[]=a&status[]=b`). export type QueryParams = Record; +export class GeminiApiError extends Error { + constructor( + public readonly status: number, + public readonly body: string + ) { + super(`Gemini API error ${status}: ${body}`); + this.name = 'GeminiApiError'; + } +} + +export class GeminiTransportError extends Error { + constructor( + public readonly method: string, + public readonly endpoint: string, + cause: unknown + ) { + super(`Gemini transport error during ${method} ${endpoint}`, { cause }); + this.name = 'GeminiTransportError'; + } +} + function applyQuery(url: URL, params?: QueryParams): void { if (!params) return; for (const [key, value] of Object.entries(params)) { @@ -120,19 +141,24 @@ export class GeminiHttpClient { }; const url = new URL(`${this.baseUrl}${endpoint}`); applyQuery(url, params); - const res = await fetch(url.toString(), { - method, - headers, - body: requestBody, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); + let res: Response; + try { + res = await fetch(url.toString(), { + method, + headers, + body: requestBody, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (err: unknown) { + throw new GeminiTransportError(method, endpoint, err); + } return this.parseResponse(res); } private async parseResponse(res: Response): Promise { const text = await res.text(); if (!res.ok) { - throw new Error(`Gemini API error ${res.status}: ${text}`); + throw new GeminiApiError(res.status, text); } return jsonParse.parse(text) as T; } diff --git a/packages/mcp-server/src/datasources/orders.test.ts b/packages/mcp-server/src/datasources/orders.test.ts new file mode 100644 index 00000000..a11209b4 --- /dev/null +++ b/packages/mcp-server/src/datasources/orders.test.ts @@ -0,0 +1,167 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import type { GeminiHttpClient } from '../client/http.js'; +import * as orders from './orders.js'; + +interface Call { + endpoint: string; + body?: unknown; +} + +function fakeClient(response: unknown = {}) { + const calls: Call[] = []; + + const client = { + authenticatedPost: async (endpoint: string, body?: unknown) => { + calls.push({ endpoint, body }); + return response; + }, + } as unknown as GeminiHttpClient; + + return { client, calls }; +} + +test('getOrderStatus looks up an order by exchange order_id', async () => { + const { client, calls } = fakeClient({ order_id: '123' }); + + await orders.getOrderStatus(client, { orderId: '123' }); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0]!.endpoint, '/v1/order/status'); + assert.deepStrictEqual(calls[0]!.body, { order_id: '123' }); +}); + +test('getOrderStatus looks up an order by client_order_id for submission reconciliation', async () => { + const { client, calls } = fakeClient({ client_order_id: 'agent-order-42' }); + + await orders.getOrderStatus(client, { clientOrderId: 'agent-order-42' }); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0]!.endpoint, '/v1/order/status'); + assert.deepStrictEqual(calls[0]!.body, { client_order_id: 'agent-order-42' }); +}); + +test('newOrder falls back exactly once after a definite InvalidOrderType API rejection', async () => { + const { GeminiApiError } = await import('../client/http.js'); + const calls: Call[] = []; + + const client = { + authenticatedPost: async (endpoint: string, body?: unknown) => { + calls.push({ endpoint, body }); + + if (calls.length === 1) { + throw new GeminiApiError(400, '{"reason":"InvalidOrderType"}'); + } + + return { + order_id: '456', + client_order_id: 'recovery-42', + }; + }, + // The fallback needs a ticker price. Keep that read deterministic and local. + publicGet: async () => ({ + bid: '100.00', + ask: '101.00', + last: '100.50', + }), + } as unknown as GeminiHttpClient; + + await orders.newOrder( + client, + 'btcusd', + '0.01', + '0', + 'buy', + 'exchange market', + undefined, + 'recovery-42' + ); + + const submissions = calls.filter((call) => call.endpoint === '/v1/order/new'); + + assert.strictEqual( + submissions.length, + 2, + 'a definite InvalidOrderType rejection may produce exactly one fallback submission' + ); + + assert.deepStrictEqual(submissions[0]!.body, { + symbol: 'btcusd', + amount: '0.01', + price: '0', + side: 'buy', + type: 'exchange market', + client_order_id: 'recovery-42', + }); + + assert.deepStrictEqual( + submissions[1]!.body, + { + symbol: 'btcusd', + amount: '0.01', + price: '101.00', + side: 'buy', + type: 'exchange limit', + client_order_id: 'recovery-42', + }, + 'the existing market-order fallback behavior is preserved' + ); +}); + +test('newOrder never submits a fallback order after an ambiguous transport failure', async () => { + const { GeminiTransportError } = await import('../client/http.js'); + const calls: Call[] = []; + let tickerReads = 0; + + const transportError = new GeminiTransportError( + 'POST', + '/v1/order/new', + new TypeError('socket closed') + ); + + const client = { + authenticatedPost: async (endpoint: string, body?: unknown) => { + calls.push({ endpoint, body }); + throw transportError; + }, + publicGet: async () => { + tickerReads += 1; + return { + bid: '100.00', + ask: '101.00', + last: '100.50', + }; + }, + } as unknown as GeminiHttpClient; + + await assert.rejects( + () => + orders.newOrder( + client, + 'btcusd', + '0.01', + '0', + 'buy', + 'exchange market', + undefined, + 'recovery-42' + ), + (err: unknown) => { + assert.strictEqual(err, transportError); + return true; + } + ); + + assert.strictEqual( + calls.filter((call) => call.endpoint === '/v1/order/new').length, + 1, + 'ambiguous submission outcome must never cause a second order mutation' + ); + + assert.strictEqual( + tickerReads, + 0, + 'transport ambiguity must not even enter the market-order fallback path' + ); +}); diff --git a/packages/mcp-server/src/datasources/orders.ts b/packages/mcp-server/src/datasources/orders.ts index ce322676..6987634a 100644 --- a/packages/mcp-server/src/datasources/orders.ts +++ b/packages/mcp-server/src/datasources/orders.ts @@ -1,4 +1,4 @@ -import type { GeminiHttpClient } from '../client/http.js'; +import { GeminiApiError, type GeminiHttpClient } from '../client/http.js'; import type { Order, MyTrade, TradeVolume, NotionalVolume } from '../types/orders.js'; import { getTicker } from './market.js'; @@ -19,8 +19,11 @@ export async function newOrder( try { return await client.authenticatedPost('/v1/order/new', body); } catch (err: unknown) { + // Only retry with the limit-order fallback after Gemini definitively + // rejected the first request. A transport failure leaves submission + // outcome unknown and must never trigger a second order mutation. const isInvalidOrderType = - err instanceof Error && err.message.includes('InvalidOrderType'); + err instanceof GeminiApiError && err.body.includes('InvalidOrderType'); const isMarketOrder = typeof type === 'string' && type.toLowerCase().includes('market'); @@ -54,8 +57,20 @@ export async function cancelAllActiveOrders(client: GeminiHttpClient): Promise>('/v1/order/cancel/all'); } -export async function getOrderStatus(client: GeminiHttpClient, orderId: string): Promise { - return client.authenticatedPost('/v1/order/status', { order_id: orderId }); +export type OrderStatusSelector = + | { orderId: string; clientOrderId?: never } + | { orderId?: never; clientOrderId: string }; + +export async function getOrderStatus( + client: GeminiHttpClient, + selector: OrderStatusSelector +): Promise { + const body = + selector.orderId !== undefined + ? { order_id: selector.orderId } + : { client_order_id: selector.clientOrderId }; + + return client.authenticatedPost('/v1/order/status', body); } export async function getActiveOrders(client: GeminiHttpClient): Promise { diff --git a/packages/mcp-server/src/tools/orders.test.ts b/packages/mcp-server/src/tools/orders.test.ts new file mode 100644 index 00000000..3c183c41 --- /dev/null +++ b/packages/mcp-server/src/tools/orders.test.ts @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import type { GeminiHttpClient } from '../client/http.js'; +import { createOrderTools } from './orders.js'; + +function fakeClient() { + return { + authenticatedPost: async () => ({}), + } as unknown as GeminiHttpClient; +} + +function statusTool() { + const tool = createOrderTools(fakeClient()).find( + (candidate) => candidate.name === 'gemini_get_order_status' + ); + if (!tool) throw new Error('gemini_get_order_status tool not found'); + return tool; +} + +test('gemini_get_order_status accepts exactly one order identifier', () => { + const schema = statusTool().inputSchema; + + assert.strictEqual(schema.safeParse({ orderId: '123' }).success, true); + assert.strictEqual(schema.safeParse({ clientOrderId: 'agent-order-42' }).success, true); + + assert.strictEqual( + schema.safeParse({}).success, + false, + 'an order identifier is required' + ); + + assert.strictEqual( + schema.safeParse({ + orderId: '123', + clientOrderId: 'agent-order-42', + }).success, + false, + 'orderId and clientOrderId must be mutually exclusive' + ); +}); diff --git a/packages/mcp-server/src/tools/orders.ts b/packages/mcp-server/src/tools/orders.ts index 969527d6..2f3acb91 100644 --- a/packages/mcp-server/src/tools/orders.ts +++ b/packages/mcp-server/src/tools/orders.ts @@ -51,9 +51,25 @@ export function createOrderTools(client: GeminiHttpClient): ToolDefinition[] { }, { name: 'gemini_get_order_status', - description: 'Get the status of an order', - inputSchema: z.object({ orderId: z.string().describe('Order ID') }), - handler: wrapHandler(({ orderId }: { orderId: string }) => orders.getOrderStatus(client, orderId)), + description: + 'Get the status of an order by exchange order ID or client-specified order ID. Use clientOrderId to reconcile an order when submission may have succeeded but its response was not received.', + inputSchema: z + .object({ + orderId: z.string().optional().describe('Exchange-assigned order ID'), + clientOrderId: z.string().optional().describe('Client-specified order ID'), + }) + .refine( + ({ orderId, clientOrderId }) => + (orderId !== undefined) !== (clientOrderId !== undefined), + { message: 'Provide exactly one of orderId or clientOrderId' } + ), + handler: wrapHandler( + ({ orderId, clientOrderId }: { orderId?: string; clientOrderId?: string }) => + orders.getOrderStatus( + client, + orderId !== undefined ? { orderId } : { clientOrderId: clientOrderId! } + ) + ), }, { name: 'gemini_get_active_orders',