Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions packages/mcp-server/src/client/http.request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
40 changes: 33 additions & 7 deletions packages/mcp-server/src/client/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,27 @@ const jsonParse = JSONBig({ storeAsString: true });
// arrays are emitted as repeated keys (`status[]=a&status[]=b`).
export type QueryParams = Record<string, string | string[] | undefined>;

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)) {
Expand Down Expand Up @@ -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<T>(res);
}

private async parseResponse<T>(res: Response): Promise<T> {
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;
}
Expand Down
167 changes: 167 additions & 0 deletions packages/mcp-server/src/datasources/orders.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
23 changes: 19 additions & 4 deletions packages/mcp-server/src/datasources/orders.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -19,8 +19,11 @@ export async function newOrder(
try {
return await client.authenticatedPost<Order>('/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');

Expand Down Expand Up @@ -54,8 +57,20 @@ export async function cancelAllActiveOrders(client: GeminiHttpClient): Promise<R
return client.authenticatedPost<Record<string, unknown>>('/v1/order/cancel/all');
}

export async function getOrderStatus(client: GeminiHttpClient, orderId: string): Promise<Order> {
return client.authenticatedPost<Order>('/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<Order> {
const body =
selector.orderId !== undefined
? { order_id: selector.orderId }
: { client_order_id: selector.clientOrderId };

return client.authenticatedPost<Order>('/v1/order/status', body);
}

export async function getActiveOrders(client: GeminiHttpClient): Promise<Order[]> {
Expand Down
41 changes: 41 additions & 0 deletions packages/mcp-server/src/tools/orders.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
Loading