@aihubspot/agent-trade-mcp 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +16 -0
  2. package/dist/index.js +404 -76
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -18,3 +18,19 @@ ai-hub-trade-mcp setup --client codex --profile default
18
18
  ```
19
19
 
20
20
  Setup registers the currently installed MCP binary through its absolute Node runtime and entrypoint path, so desktop clients do not depend on a global PATH or an unpublished `npx` package. Cursor and Claude Desktop configurations are merged with existing MCP servers through their JSON configurations. Claude Code and Codex are registered through their official CLIs; Claude Code uses user scope. Existing JSON configurations are validated, atomically updated, and backed up before their first modification. The setup command stores no API credentials; the MCP server continues to read its profile from `~/.ai-hub/config.toml`.
21
+
22
+ ## Spot market-order units
23
+
24
+ Use `spot_prepare_market_buy` or `margin_prepare_market_buy` only with `quoteAmount` (for example, the exact USDT amount to spend for `ETHUSDT`). Use `spot_prepare_market_sell` or `margin_prepare_market_sell` only with `baseQuantity` (the exact ETH amount to sell for `ETHUSDT`). A market buy cannot guarantee an exact base-asset quantity; it must never reinterpret a requested base quantity as a quote amount.
25
+
26
+ Before any order preview, MCP lazily loads `/sapi/v2/symbols` once per local profile and caches the symbol rules in memory for five minutes. Known quantity/price precision and limit-order minimum violations are rejected before confirmation.
27
+
28
+ ## Ticker response
29
+
30
+ All read tools return `{ "ok": true, "data": ... }` in one of these stable forms:
31
+
32
+ - Arrays: `{ "dataType": "array", "items": [...], "count": 500 }`
33
+ - Objects: `{ "dataType": "object", "value": { ... } }`
34
+ - Scalars: `{ "dataType": "scalar", "value": "..." }`
35
+
36
+ `market_get_ticker` additionally exposes `data.tickers` as an alias of `data.items`. Check `data.dataType` before formatting; do not infer raw OpenAPI nesting.
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import { realpathSync } from "fs";
5
+ import { fileURLToPath } from "url";
4
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
7
 
6
8
  // ../core/src/errors.ts
@@ -915,7 +917,7 @@ var marketTools = [
915
917
  {
916
918
  name: "market_get_ticker",
917
919
  title: "Get Spot Ticker",
918
- description: "Get spot ticker data. Pass symbol or symbols when filtering is needed.",
920
+ description: "Get spot ticker data. Pass symbol or symbols when filtering is needed. The raw OpenAPI response is a ticker array.",
919
921
  cliPath: ["market", "ticker"],
920
922
  module: "spot-common",
921
923
  access: "public",
@@ -990,14 +992,186 @@ var marketTools = [
990
992
  }
991
993
  ];
992
994
 
995
+ // ../core/src/tools/symbol-rules.ts
996
+ var CACHE_TTL_MS = 5 * 60 * 1e3;
997
+ var cache = /* @__PURE__ */ new Map();
998
+ var pendingLoads = /* @__PURE__ */ new Map();
999
+ function cacheKey(context) {
1000
+ return `${context.profile.name}\0${context.profile.configVersion}\0${context.profile.openApiBaseUrl}`;
1001
+ }
1002
+ function normalizedSymbol(symbol) {
1003
+ return symbol.replaceAll("/", "").trim().toUpperCase();
1004
+ }
1005
+ function stringValue(value) {
1006
+ if (typeof value === "string" && value.trim()) return value.trim();
1007
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
1008
+ return void 0;
1009
+ }
1010
+ function nonNegativeInteger(value) {
1011
+ const result2 = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
1012
+ return Number.isInteger(result2) && result2 >= 0 ? result2 : void 0;
1013
+ }
1014
+ function rows(response) {
1015
+ if (Array.isArray(response)) return response.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item));
1016
+ if (!response || typeof response !== "object" || Array.isArray(response)) return [];
1017
+ const root = response;
1018
+ const candidates = [root.symbols, root.data, root.data?.symbols];
1019
+ for (const candidate of candidates) {
1020
+ if (Array.isArray(candidate)) return candidate.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item));
1021
+ }
1022
+ return [];
1023
+ }
1024
+ function parseRules(response) {
1025
+ const result2 = /* @__PURE__ */ new Map();
1026
+ for (const row of rows(response)) {
1027
+ const symbol = stringValue(row.symbol);
1028
+ if (!symbol) continue;
1029
+ result2.set(normalizedSymbol(symbol), {
1030
+ symbol,
1031
+ quantityPrecision: nonNegativeInteger(row.quantityPrecision),
1032
+ pricePrecision: nonNegativeInteger(row.pricePrecision),
1033
+ limitVolumeMin: stringValue(row.limitVolumeMin),
1034
+ limitAmountMin: stringValue(row.limitAmountMin),
1035
+ limitPriceMin: stringValue(row.limitPriceMin)
1036
+ });
1037
+ }
1038
+ return result2;
1039
+ }
1040
+ async function loadRules(context) {
1041
+ const key = cacheKey(context);
1042
+ const current = cache.get(key);
1043
+ if (current && current.expiresAt > Date.now()) return current;
1044
+ const pending = pendingLoads.get(key);
1045
+ if (pending) return pending;
1046
+ const load = context.api.symbols().then((response) => {
1047
+ const entry = { rules: parseRules(response), expiresAt: Date.now() + CACHE_TTL_MS };
1048
+ cache.set(key, entry);
1049
+ return entry;
1050
+ }).finally(() => pendingLoads.delete(key));
1051
+ pendingLoads.set(key, load);
1052
+ return load;
1053
+ }
1054
+ async function getSymbolRule(context, symbol) {
1055
+ const entry = await loadRules(context);
1056
+ const rule = entry.rules.get(normalizedSymbol(symbol));
1057
+ if (!rule) throw new AiHubError("AI_HUB_SYMBOL_NOT_FOUND", `Symbol "${symbol}" was not returned by the configured tenant OpenAPI symbols endpoint.`);
1058
+ return rule;
1059
+ }
1060
+ function decimalScale(value) {
1061
+ return value.split(".")[1]?.length ?? 0;
1062
+ }
1063
+ function decimalParts(value) {
1064
+ const [whole, fraction = ""] = value.split(".");
1065
+ return { digits: BigInt(`${whole}${fraction}`.replace(/^0+(?=\d)/, "") || "0"), scale: fraction.length };
1066
+ }
1067
+ function compareDecimal(left, right) {
1068
+ const a = decimalParts(left);
1069
+ const b = decimalParts(right);
1070
+ const scale = Math.max(a.scale, b.scale);
1071
+ const leftValue = a.digits * 10n ** BigInt(scale - a.scale);
1072
+ const rightValue = b.digits * 10n ** BigInt(scale - b.scale);
1073
+ return leftValue === rightValue ? 0 : leftValue > rightValue ? 1 : -1;
1074
+ }
1075
+ function multipliedDecimal(left, right) {
1076
+ const a = decimalParts(left);
1077
+ const b = decimalParts(right);
1078
+ const scale = a.scale + b.scale;
1079
+ const digits = (a.digits * b.digits).toString();
1080
+ if (scale === 0) return digits;
1081
+ const padded = digits.padStart(scale + 1, "0");
1082
+ return `${padded.slice(0, -scale)}.${padded.slice(-scale)}`;
1083
+ }
1084
+ function assertPrecision(name, value, precision, symbol) {
1085
+ if (precision !== void 0 && decimalScale(value) > precision) {
1086
+ throw new AiHubError("AI_HUB_SYMBOL_PRECISION_INVALID", `${name} for ${symbol} allows at most ${precision} decimal places; received ${value}.`);
1087
+ }
1088
+ }
1089
+ function assertAtLeast(name, value, minimum, symbol) {
1090
+ if (minimum && compareDecimal(value, minimum) < 0) {
1091
+ throw new AiHubError("AI_HUB_SYMBOL_MINIMUM_NOT_MET", `${name} for ${symbol} must be at least ${minimum}; received ${value}.`);
1092
+ }
1093
+ }
1094
+ async function preflightSymbolOrder(context, order) {
1095
+ const rule = await getSymbolRule(context, order.symbol);
1096
+ if (order.type === "MARKET" && order.side === "BUY" && order.quoteAmount) {
1097
+ assertPrecision("quoteAmount", order.quoteAmount, rule.pricePrecision, rule.symbol);
1098
+ }
1099
+ if (order.baseQuantity) assertPrecision("baseQuantity", order.baseQuantity, rule.quantityPrecision, rule.symbol);
1100
+ if (order.price) assertPrecision("price", order.price, rule.pricePrecision, rule.symbol);
1101
+ if (order.type === "LIMIT" && order.baseQuantity && order.price) {
1102
+ assertAtLeast("baseQuantity", order.baseQuantity, rule.limitVolumeMin, rule.symbol);
1103
+ assertAtLeast("price", order.price, rule.limitPriceMin, rule.symbol);
1104
+ if (rule.limitAmountMin && compareDecimal(rule.limitAmountMin, "0") > 0) {
1105
+ assertAtLeast("limit order amount", multipliedDecimal(order.baseQuantity, order.price), rule.limitAmountMin, rule.symbol);
1106
+ }
1107
+ }
1108
+ return rule;
1109
+ }
1110
+
993
1111
  // ../core/src/tools/margin-tools.ts
994
- function validateMarginOrder(input) {
995
- const value = strictObject(input, ["symbol", "volume", "side", "type", "price", "newClientOrderId", "isolated"]);
1112
+ function validateMarginSemanticOrder(input) {
1113
+ const value = strictObject(input, ["symbol", "side", "type", "quoteAmount", "baseQuantity", "price", "newClientOrderId", "isolated"]);
1114
+ const symbol = requiredString(value, "symbol");
1115
+ const side = requiredEnum(value, "side", ["BUY", "SELL"]);
996
1116
  const type = requiredEnum(value, "type", ["LIMIT", "MARKET"]);
997
- const price = optionalString(value, "price");
998
- if (type === "LIMIT" && !price) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "price is required for LIMIT orders.");
999
- if (type === "MARKET" && price) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "price is not allowed for MARKET orders.");
1000
- return { symbol: requiredString(value, "symbol"), volume: positiveDecimal(value, "volume"), side: requiredEnum(value, "side", ["BUY", "SELL"]), type, ...price ? { price } : {}, newClientOrderId: optionalClientOrderId(value), ...optionalBoolean(value, "isolated") !== void 0 ? { isolated: optionalBoolean(value, "isolated") } : {} };
1117
+ const isolated = optionalBoolean(value, "isolated");
1118
+ const common = { symbol, side, type, newClientOrderId: optionalClientOrderId(value), ...isolated !== void 0 ? { isolated } : {} };
1119
+ if (type === "MARKET" && side === "BUY") {
1120
+ if (value.baseQuantity !== void 0) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "MARKET BUY uses quoteAmount (the amount of quote asset to spend), not baseQuantity.");
1121
+ if (value.price !== void 0) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "price is not allowed for MARKET orders.");
1122
+ const quoteAmount = positiveDecimal(value, "quoteAmount");
1123
+ return { ...common, volume: quoteAmount, intent: "market_buy", quoteAmount };
1124
+ }
1125
+ if (type === "MARKET" && side === "SELL") {
1126
+ if (value.quoteAmount !== void 0) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "MARKET SELL uses baseQuantity (the amount of base asset to sell), not quoteAmount.");
1127
+ if (value.price !== void 0) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "price is not allowed for MARKET orders.");
1128
+ const baseQuantity2 = positiveDecimal(value, "baseQuantity");
1129
+ return { ...common, volume: baseQuantity2, intent: "market_sell", baseQuantity: baseQuantity2 };
1130
+ }
1131
+ if (value.quoteAmount !== void 0) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "LIMIT orders use baseQuantity (the amount of base asset), not quoteAmount.");
1132
+ const baseQuantity = positiveDecimal(value, "baseQuantity");
1133
+ const price = positiveDecimal(value, "price");
1134
+ return { ...common, volume: baseQuantity, price, intent: "limit", baseQuantity };
1135
+ }
1136
+ function validateMarginMarketBuy(input) {
1137
+ const value = strictObject(input, ["symbol", "quoteAmount", "newClientOrderId", "isolated"]);
1138
+ return validateMarginSemanticOrder({ ...value, side: "BUY", type: "MARKET" });
1139
+ }
1140
+ function validateMarginMarketSell(input) {
1141
+ const value = strictObject(input, ["symbol", "baseQuantity", "newClientOrderId", "isolated"]);
1142
+ return validateMarginSemanticOrder({ ...value, side: "SELL", type: "MARKET" });
1143
+ }
1144
+ function validateMarginLimitOrder(input) {
1145
+ const value = strictObject(input, ["symbol", "side", "baseQuantity", "price", "newClientOrderId", "isolated"]);
1146
+ return validateMarginSemanticOrder({ ...value, type: "LIMIT" });
1147
+ }
1148
+ function toMarginOpenApiOrder(order) {
1149
+ return {
1150
+ symbol: order.symbol,
1151
+ volume: order.volume,
1152
+ side: order.side,
1153
+ type: order.type,
1154
+ newClientOrderId: order.newClientOrderId,
1155
+ ...order.price ? { price: order.price } : {},
1156
+ ...order.isolated !== void 0 ? { isolated: order.isolated } : {}
1157
+ };
1158
+ }
1159
+ function marginOrderSummary(order) {
1160
+ return {
1161
+ action: order.intent === "market_buy" ? "margin_market_buy" : order.intent === "market_sell" ? "margin_market_sell" : "margin_limit_order",
1162
+ symbol: order.symbol,
1163
+ side: order.side,
1164
+ type: order.type,
1165
+ ...order.quoteAmount ? { quoteAmount: order.quoteAmount, amountMeaning: "exact quote-asset amount to spend" } : {},
1166
+ ...order.baseQuantity ? { baseQuantity: order.baseQuantity, amountMeaning: "exact base-asset quantity" } : {},
1167
+ ...order.price ? { price: order.price } : {},
1168
+ isolated: order.isolated ?? false,
1169
+ newClientOrderId: order.newClientOrderId
1170
+ };
1171
+ }
1172
+ async function preflightMarginOrder(order, context) {
1173
+ await preflightSymbolOrder(context, order);
1174
+ return order;
1001
1175
  }
1002
1176
  function validateMarginOrderLookup(input) {
1003
1177
  const value = strictObject(input, ["symbol", "orderId", "newClientOrderId", "isolated"]);
@@ -1055,22 +1229,52 @@ var marginTools = [
1055
1229
  handler: (input, context) => context.api.signedGet("/sapi/v2/margin/myTrades", input, signed(context))
1056
1230
  },
1057
1231
  {
1058
- name: "margin_place_order",
1059
- title: "Place Margin Order",
1060
- description: "Create one v2 margin order after confirmation.",
1061
- cliPath: ["margin", "order", "place"],
1232
+ name: "margin_market_buy",
1233
+ title: "Margin Market Buy by Quote Amount",
1234
+ description: "Spend an exact quote-asset amount for an isolated or cross-margin market buy. For ETHUSDT, quoteAmount is USDT. A market buy cannot guarantee an exact base-asset quantity.",
1235
+ cliPath: ["margin", "order", "market-buy"],
1062
1236
  module: "spot-margin",
1063
1237
  access: "signed",
1064
1238
  operation: "write",
1065
1239
  riskLevel: "high",
1066
- inputSchema: { type: "object", properties: { symbol: { type: "string" }, volume: { type: "string" }, side: { type: "string", enum: ["BUY", "SELL"] }, type: { type: "string", enum: ["LIMIT", "MARKET"] }, price: { type: "string" }, newClientOrderId: { type: "string" }, isolated: { type: "boolean" } }, required: ["symbol", "volume", "side", "type"], additionalProperties: false },
1240
+ inputSchema: { type: "object", properties: { symbol: { type: "string" }, quoteAmount: { type: "string", description: "Required exact quote-asset amount to spend." }, newClientOrderId: { type: "string" }, isolated: { type: "boolean" } }, required: ["symbol", "quoteAmount"], additionalProperties: false },
1067
1241
  errorCodes: writeErrors,
1068
- validate: validateMarginOrder,
1069
- handler: (input, context) => context.api.signedPost("/sapi/v2/margin/order", input, signed(context)),
1070
- writeSummary: (input) => {
1071
- const value = input;
1072
- return { action: "margin_place_order", symbol: value.symbol, side: value.side, type: value.type, volume: value.volume, price: value.price ?? null, isolated: value.isolated ?? false, newClientOrderId: value.newClientOrderId };
1073
- }
1242
+ validate: validateMarginMarketBuy,
1243
+ preflight: preflightMarginOrder,
1244
+ handler: (input, context) => context.api.signedPost("/sapi/v2/margin/order", toMarginOpenApiOrder(input), signed(context)),
1245
+ writeSummary: (input) => marginOrderSummary(input)
1246
+ },
1247
+ {
1248
+ name: "margin_market_sell",
1249
+ title: "Margin Market Sell by Base Quantity",
1250
+ description: "Sell an exact base-asset quantity at market for an isolated or cross-margin account. For ETHUSDT, baseQuantity is ETH.",
1251
+ cliPath: ["margin", "order", "market-sell"],
1252
+ module: "spot-margin",
1253
+ access: "signed",
1254
+ operation: "write",
1255
+ riskLevel: "high",
1256
+ inputSchema: { type: "object", properties: { symbol: { type: "string" }, baseQuantity: { type: "string", description: "Required exact base-asset quantity to sell." }, newClientOrderId: { type: "string" }, isolated: { type: "boolean" } }, required: ["symbol", "baseQuantity"], additionalProperties: false },
1257
+ errorCodes: writeErrors,
1258
+ validate: validateMarginMarketSell,
1259
+ preflight: preflightMarginOrder,
1260
+ handler: (input, context) => context.api.signedPost("/sapi/v2/margin/order", toMarginOpenApiOrder(input), signed(context)),
1261
+ writeSummary: (input) => marginOrderSummary(input)
1262
+ },
1263
+ {
1264
+ name: "margin_limit_order",
1265
+ title: "Place Margin Limit Order",
1266
+ description: "Place an isolated or cross-margin LIMIT BUY or SELL using an exact base-asset quantity and a limit price.",
1267
+ cliPath: ["margin", "order", "limit"],
1268
+ module: "spot-margin",
1269
+ access: "signed",
1270
+ operation: "write",
1271
+ riskLevel: "high",
1272
+ inputSchema: { type: "object", properties: { symbol: { type: "string" }, side: { type: "string", enum: ["BUY", "SELL"] }, baseQuantity: { type: "string", description: "Required exact base-asset quantity." }, price: { type: "string", description: "Required limit price in the quote asset." }, newClientOrderId: { type: "string" }, isolated: { type: "boolean" } }, required: ["symbol", "side", "baseQuantity", "price"], additionalProperties: false },
1273
+ errorCodes: writeErrors,
1274
+ validate: validateMarginLimitOrder,
1275
+ preflight: preflightMarginOrder,
1276
+ handler: (input, context) => context.api.signedPost("/sapi/v2/margin/order", toMarginOpenApiOrder(input), signed(context)),
1277
+ writeSummary: (input) => marginOrderSummary(input)
1074
1278
  },
1075
1279
  {
1076
1280
  name: "margin_cancel_order",
@@ -1123,53 +1327,89 @@ function clientOrderId(value) {
1123
1327
  if (supplied) return supplied;
1124
1328
  return `agent_${randomUUID3().replaceAll("-", "").slice(0, 24)}`;
1125
1329
  }
1126
- function validatePlaceOrder(input) {
1127
- const value = strictObject(input, ["symbol", "volume", "side", "type", "price", "timeInForce", "newClientOrderId", "recvWindow"]);
1128
- const type = orderType(value);
1129
- const price = optionalString(value, "price");
1130
- if (type === "LIMIT" && !price) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "price is required for LIMIT orders.");
1131
- if (type === "MARKET" && price) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "price is not allowed for MARKET orders.");
1330
+ function optionalOrderFields(value) {
1132
1331
  const timeInForce = optionalString(value, "timeInForce")?.toUpperCase();
1133
1332
  if (timeInForce && !["GTC", "IOC", "FOK"].includes(timeInForce)) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "timeInForce must be GTC, IOC, or FOK.");
1333
+ const recvWindow = optionalString(value, "recvWindow");
1134
1334
  return {
1135
- symbol: requiredString(value, "symbol"),
1136
- volume: positiveDecimal2(value, "volume"),
1137
- side: orderSide(value),
1138
- type,
1139
- ...price ? { price } : {},
1140
- ...timeInForce ? { timeInForce } : {},
1141
1335
  newClientOrderId: clientOrderId(value),
1142
- ...optionalString(value, "recvWindow") ? { recvWindow: optionalString(value, "recvWindow") } : {}
1336
+ ...recvWindow ? { recvWindow } : {},
1337
+ ...timeInForce ? { timeInForce } : {}
1143
1338
  };
1144
1339
  }
1340
+ function validateSemanticOrder(input) {
1341
+ const value = strictObject(input, ["symbol", "side", "type", "quoteAmount", "baseQuantity", "price", "timeInForce", "newClientOrderId", "recvWindow"]);
1342
+ const side = orderSide(value);
1343
+ const type = orderType(value);
1344
+ const symbol = requiredString(value, "symbol");
1345
+ if (type === "MARKET" && side === "BUY") {
1346
+ if (value.baseQuantity !== void 0) {
1347
+ throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "MARKET BUY uses quoteAmount (the amount of quote asset to spend), not baseQuantity.");
1348
+ }
1349
+ if (value.price !== void 0 || value.timeInForce !== void 0) {
1350
+ throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "price and timeInForce are not allowed for MARKET orders.");
1351
+ }
1352
+ const quoteAmount = positiveDecimal2(value, "quoteAmount");
1353
+ return { symbol, volume: quoteAmount, side, type, intent: "market_buy", quoteAmount, ...optionalOrderFields(value) };
1354
+ }
1355
+ if (type === "MARKET" && side === "SELL") {
1356
+ if (value.quoteAmount !== void 0) {
1357
+ throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "MARKET SELL uses baseQuantity (the amount of base asset to sell), not quoteAmount.");
1358
+ }
1359
+ if (value.price !== void 0 || value.timeInForce !== void 0) {
1360
+ throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "price and timeInForce are not allowed for MARKET orders.");
1361
+ }
1362
+ const baseQuantity2 = positiveDecimal2(value, "baseQuantity");
1363
+ return { symbol, volume: baseQuantity2, side, type, intent: "market_sell", baseQuantity: baseQuantity2, ...optionalOrderFields(value) };
1364
+ }
1365
+ if (value.quoteAmount !== void 0) {
1366
+ throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "LIMIT orders use baseQuantity (the amount of base asset), not quoteAmount.");
1367
+ }
1368
+ const baseQuantity = positiveDecimal2(value, "baseQuantity");
1369
+ const price = positiveDecimal2(value, "price");
1370
+ return { symbol, volume: baseQuantity, side, type, price, intent: "limit", baseQuantity, ...optionalOrderFields(value) };
1371
+ }
1372
+ function validateMarketBuy(input) {
1373
+ const value = strictObject(input, ["symbol", "quoteAmount", "newClientOrderId", "recvWindow"]);
1374
+ return validateSemanticOrder({ ...value, side: "BUY", type: "MARKET" });
1375
+ }
1376
+ function toOpenApiOrder(order) {
1377
+ return {
1378
+ symbol: order.symbol,
1379
+ volume: order.volume,
1380
+ side: order.side,
1381
+ type: order.type,
1382
+ newClientOrderId: order.newClientOrderId,
1383
+ ...order.price ? { price: order.price } : {},
1384
+ ...order.timeInForce ? { timeInForce: order.timeInForce } : {},
1385
+ ...order.recvWindow ? { recvWindow: order.recvWindow } : {}
1386
+ };
1387
+ }
1388
+ function validateMarketSell(input) {
1389
+ const value = strictObject(input, ["symbol", "baseQuantity", "newClientOrderId", "recvWindow"]);
1390
+ return validateSemanticOrder({ ...value, side: "SELL", type: "MARKET" });
1391
+ }
1392
+ function validateLimitOrder(input) {
1393
+ const value = strictObject(input, ["symbol", "side", "baseQuantity", "price", "timeInForce", "newClientOrderId", "recvWindow"]);
1394
+ return validateSemanticOrder({ ...value, type: "LIMIT" });
1395
+ }
1145
1396
  function validateCancelOrder(input) {
1146
1397
  const value = strictObject(input, ["symbol", "orderId", "newClientOrderId"]);
1147
1398
  return { symbol: requiredString(value, "symbol"), orderId: requiredString(value, "orderId"), ...optionalString(value, "newClientOrderId") ? { newClientOrderId: optionalString(value, "newClientOrderId") } : {} };
1148
1399
  }
1149
1400
  function validateTestOrder(input) {
1150
- const value = strictObject(input, ["symbol", "volume", "side", "type", "price", "newClientOrderId", "recvWindow"]);
1151
- const type = orderType(value);
1152
- const price = optionalString(value, "price");
1153
- if (type === "LIMIT" && !price) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "price is required for LIMIT orders.");
1154
- if (type === "MARKET" && price) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "price is not allowed for MARKET orders.");
1155
- return { symbol: requiredString(value, "symbol"), volume: positiveDecimal2(value, "volume"), side: orderSide(value), type, ...price ? { price } : {}, ...optionalString(value, "newClientOrderId") ? { newClientOrderId: optionalString(value, "newClientOrderId") } : {}, ...optionalString(value, "recvWindow") ? { recvWindow: optionalString(value, "recvWindow") } : {} };
1401
+ return toOpenApiOrder(validateSemanticOrder(input));
1156
1402
  }
1157
1403
  function validateBatchOrders(input) {
1158
1404
  const value = strictObject(input, ["symbol", "orders"]);
1405
+ const symbol = requiredString(value, "symbol");
1159
1406
  const orders = value.orders;
1160
1407
  if (!Array.isArray(orders) || orders.length < 1 || orders.length > 10) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "orders must contain between 1 and 10 orders.");
1161
- const normalized = orders.map((item, index) => {
1162
- if (!item || typeof item !== "object" || Array.isArray(item)) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", `orders[${index}] must be an object.`);
1163
- const order = item;
1164
- const price = optionalString(order, "price");
1165
- const volume = positiveDecimal2(order, "volume");
1166
- const side = orderSide(order);
1167
- const batchType = requiredEnum(order, "batchType", ["LIMIT", "MARKET"]);
1168
- if (batchType === "LIMIT" && !price) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", `orders[${index}].price is required for LIMIT.`);
1169
- if (batchType === "MARKET" && price) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", `orders[${index}].price is not allowed for MARKET.`);
1170
- return { volume, side, batchType, ...price ? { price } : {} };
1171
- });
1172
- return { symbol: requiredString(value, "symbol"), orders: normalized };
1408
+ return { symbol, orders: orders.map((order, index) => {
1409
+ if (!order || typeof order !== "object" || Array.isArray(order)) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", `orders[${index}] must be an object.`);
1410
+ const semantic = validateSemanticOrder({ ...order, symbol });
1411
+ return { volume: semantic.volume, side: semantic.side, batchType: semantic.type, ...semantic.price ? { price: semantic.price } : {} };
1412
+ }) };
1173
1413
  }
1174
1414
  function validateBatchCancel(input) {
1175
1415
  const value = strictObject(input, ["symbol", "orderIds"]);
@@ -1178,17 +1418,44 @@ function validateBatchCancel(input) {
1178
1418
  }
1179
1419
  return { symbol: requiredString(value, "symbol"), orderIds: value.orderIds.map((id) => id.trim()) };
1180
1420
  }
1421
+ function semanticOrderSummary(order) {
1422
+ return {
1423
+ action: order.intent === "market_buy" ? "market_buy" : order.intent === "market_sell" ? "market_sell" : "limit_order",
1424
+ symbol: order.symbol,
1425
+ side: order.side,
1426
+ type: order.type,
1427
+ ...order.quoteAmount ? { quoteAmount: order.quoteAmount, amountMeaning: "exact quote-asset amount to spend" } : {},
1428
+ ...order.baseQuantity ? { baseQuantity: order.baseQuantity, amountMeaning: "exact base-asset quantity" } : {},
1429
+ ...order.price ? { price: order.price } : {},
1430
+ ...order.timeInForce ? { timeInForce: order.timeInForce } : {},
1431
+ newClientOrderId: order.newClientOrderId
1432
+ };
1433
+ }
1434
+ async function preflightSpotOrder(order, context) {
1435
+ await preflightSymbolOrder(context, order);
1436
+ return order;
1437
+ }
1438
+ async function preflightSpotBatch(input, context) {
1439
+ await Promise.all(input.orders.map((order) => preflightSymbolOrder(context, {
1440
+ symbol: input.symbol,
1441
+ side: order.side,
1442
+ type: order.batchType,
1443
+ ...order.batchType === "MARKET" && order.side === "BUY" ? { quoteAmount: order.volume } : { baseQuantity: order.volume },
1444
+ ...order.price ? { price: order.price } : {}
1445
+ })));
1446
+ return input;
1447
+ }
1181
1448
  var orderTools = [
1182
1449
  {
1183
1450
  name: "spot_test_order",
1184
1451
  title: "Test Spot Order",
1185
- description: "Validate one LIMIT or MARKET spot order without sending it to the matching engine.",
1452
+ description: "Validate an order without sending it to the matching engine. MARKET BUY requires quoteAmount (quote asset to spend); MARKET SELL and LIMIT require baseQuantity. LIMIT also requires price.",
1186
1453
  cliPath: ["spot", "order", "test"],
1187
1454
  module: "spot-order",
1188
1455
  access: "signed",
1189
1456
  operation: "read",
1190
1457
  riskLevel: "low",
1191
- inputSchema: { type: "object", properties: { symbol: { type: "string" }, volume: { type: "string" }, side: { type: "string", enum: ["BUY", "SELL"] }, type: { type: "string", enum: ["LIMIT", "MARKET"] }, price: { type: "string" }, newClientOrderId: { type: "string" }, recvWindow: { type: "string" } }, required: ["symbol", "volume", "side", "type"], additionalProperties: false },
1458
+ inputSchema: { type: "object", properties: { symbol: { type: "string" }, side: { type: "string", enum: ["BUY", "SELL"] }, type: { type: "string", enum: ["LIMIT", "MARKET"] }, quoteAmount: { type: "string", description: "Required only for MARKET BUY: exact quote-asset amount to spend." }, baseQuantity: { type: "string", description: "Required for MARKET SELL and LIMIT: exact base-asset quantity." }, price: { type: "string", description: "Required only for LIMIT." }, timeInForce: { type: "string", enum: ["GTC", "IOC", "FOK"] }, newClientOrderId: { type: "string" }, recvWindow: { type: "string" } }, required: ["symbol", "side", "type"], additionalProperties: false },
1192
1459
  errorCodes: signedReadErrors2,
1193
1460
  validate: validateTestOrder,
1194
1461
  handler: (input, context) => context.api.signedPost("/sapi/v2/order/test", input, signed2(context))
@@ -1213,15 +1480,16 @@ var orderTools = [
1213
1480
  {
1214
1481
  name: "spot_batch_place_orders",
1215
1482
  title: "Batch Place Spot Orders",
1216
- description: "Create up to 10 spot orders after confirmation.",
1483
+ description: "Create up to 10 spot orders after confirmation. Each MARKET BUY item requires quoteAmount; MARKET SELL and LIMIT items require baseQuantity; LIMIT items also require price.",
1217
1484
  cliPath: ["spot", "order", "batch-place"],
1218
1485
  module: "spot-order",
1219
1486
  access: "signed",
1220
1487
  operation: "write",
1221
1488
  riskLevel: "high",
1222
- inputSchema: { type: "object", properties: { symbol: { type: "string" }, orders: { type: "array" } }, required: ["symbol", "orders"], additionalProperties: false },
1489
+ inputSchema: { type: "object", properties: { symbol: { type: "string" }, orders: { type: "array", description: "Items use side/type plus quoteAmount for MARKET BUY, baseQuantity for MARKET SELL/LIMIT, and price for LIMIT." } }, required: ["symbol", "orders"], additionalProperties: false },
1223
1490
  errorCodes: writeErrors2,
1224
1491
  validate: validateBatchOrders,
1492
+ preflight: preflightSpotBatch,
1225
1493
  handler: (input, context) => context.api.signedPost("/sapi/v2/batchOrders", input, signed2(context)),
1226
1494
  writeSummary: (input) => {
1227
1495
  const value = input;
@@ -1248,7 +1516,7 @@ var orderTools = [
1248
1516
  },
1249
1517
  {
1250
1518
  name: "spot_get_open_orders",
1251
- title: "Get Open Spot Orders",
1519
+ title: "Get Spot Open Orders",
1252
1520
  description: "Get current signed spot orders.",
1253
1521
  cliPath: ["spot", "order", "open"],
1254
1522
  module: "spot-order",
@@ -1281,22 +1549,52 @@ var orderTools = [
1281
1549
  handler: (input, context) => context.api.getMyTrades(input, signed2(context))
1282
1550
  },
1283
1551
  {
1284
- name: "spot_place_order",
1285
- title: "Place Spot Order",
1286
- description: "Create one LIMIT or MARKET spot order after confirmation.",
1287
- cliPath: ["spot", "order", "place"],
1552
+ name: "spot_market_buy",
1553
+ title: "Market Buy by Quote Amount",
1554
+ description: "Spend an exact quote-asset amount to buy at market. For ETHUSDT, quoteAmount is USDT. Do not use this tool to request a base-asset quantity such as 1 ETH; market execution cannot guarantee an exact base quantity.",
1555
+ cliPath: ["spot", "order", "market-buy"],
1288
1556
  module: "spot-order",
1289
1557
  access: "signed",
1290
1558
  operation: "write",
1291
1559
  riskLevel: "high",
1292
- inputSchema: { type: "object", properties: { symbol: { type: "string" }, volume: { type: "string" }, side: { type: "string", enum: ["BUY", "SELL"] }, type: { type: "string", enum: ["LIMIT", "MARKET"] }, price: { type: "string" }, timeInForce: { type: "string", enum: ["GTC", "IOC", "FOK"] }, newClientOrderId: { type: "string" }, recvWindow: { type: "string" } }, required: ["symbol", "volume", "side", "type"], additionalProperties: false },
1560
+ inputSchema: { type: "object", properties: { symbol: { type: "string" }, quoteAmount: { type: "string", description: "Required exact quote-asset amount to spend, e.g. 100 means spend 100 USDT for ETHUSDT." }, newClientOrderId: { type: "string" }, recvWindow: { type: "string" } }, required: ["symbol", "quoteAmount"], additionalProperties: false },
1293
1561
  errorCodes: writeErrors2,
1294
- validate: validatePlaceOrder,
1295
- handler: (input, context) => context.api.placeOrder(input, signed2(context)),
1296
- writeSummary: (input) => {
1297
- const value = input;
1298
- return { action: "place_order", symbol: value.symbol, side: value.side, type: value.type, volume: value.volume, price: value.price ?? null, timeInForce: value.timeInForce ?? null, newClientOrderId: value.newClientOrderId };
1299
- }
1562
+ validate: validateMarketBuy,
1563
+ preflight: preflightSpotOrder,
1564
+ handler: (input, context) => context.api.placeOrder(toOpenApiOrder(input), signed2(context)),
1565
+ writeSummary: (input) => semanticOrderSummary(input)
1566
+ },
1567
+ {
1568
+ name: "spot_market_sell",
1569
+ title: "Market Sell by Base Quantity",
1570
+ description: "Sell an exact base-asset quantity at market. For ETHUSDT, baseQuantity is ETH.",
1571
+ cliPath: ["spot", "order", "market-sell"],
1572
+ module: "spot-order",
1573
+ access: "signed",
1574
+ operation: "write",
1575
+ riskLevel: "high",
1576
+ inputSchema: { type: "object", properties: { symbol: { type: "string" }, baseQuantity: { type: "string", description: "Required exact base-asset quantity to sell, e.g. 0.5 means sell 0.5 ETH for ETHUSDT." }, newClientOrderId: { type: "string" }, recvWindow: { type: "string" } }, required: ["symbol", "baseQuantity"], additionalProperties: false },
1577
+ errorCodes: writeErrors2,
1578
+ validate: validateMarketSell,
1579
+ preflight: preflightSpotOrder,
1580
+ handler: (input, context) => context.api.placeOrder(toOpenApiOrder(input), signed2(context)),
1581
+ writeSummary: (input) => semanticOrderSummary(input)
1582
+ },
1583
+ {
1584
+ name: "spot_limit_order",
1585
+ title: "Place Limit Spot Order",
1586
+ description: "Place a LIMIT BUY or SELL using an exact base-asset quantity and a limit price.",
1587
+ cliPath: ["spot", "order", "limit"],
1588
+ module: "spot-order",
1589
+ access: "signed",
1590
+ operation: "write",
1591
+ riskLevel: "high",
1592
+ inputSchema: { type: "object", properties: { symbol: { type: "string" }, side: { type: "string", enum: ["BUY", "SELL"] }, baseQuantity: { type: "string", description: "Required exact base-asset quantity." }, price: { type: "string", description: "Required limit price in the quote asset." }, timeInForce: { type: "string", enum: ["GTC", "IOC", "FOK"] }, newClientOrderId: { type: "string" }, recvWindow: { type: "string" } }, required: ["symbol", "side", "baseQuantity", "price"], additionalProperties: false },
1593
+ errorCodes: writeErrors2,
1594
+ validate: validateLimitOrder,
1595
+ preflight: preflightSpotOrder,
1596
+ handler: (input, context) => context.api.placeOrder(toOpenApiOrder(input), signed2(context)),
1597
+ writeSummary: (input) => semanticOrderSummary(input)
1300
1598
  },
1301
1599
  {
1302
1600
  name: "spot_cancel_order",
@@ -1598,22 +1896,22 @@ var ToolRegistry = class {
1598
1896
  const validInput = tool.validate(input);
1599
1897
  return tool.handler(validInput, context);
1600
1898
  }
1601
- prepareWrite(name, input, context, confirmations) {
1899
+ async prepareWrite(name, input, context, confirmations) {
1602
1900
  const tool = this.byName(name);
1603
1901
  if (tool.operation !== "write" || !tool.writeSummary) throw new AiHubError("AI_HUB_TOOL_NOT_WRITE", `Tool "${name}" is not a confirmable write Tool.`);
1604
1902
  const validInput = tool.validate(input);
1903
+ const preflightInput = tool.preflight ? await tool.preflight(validInput, context) : validInput;
1605
1904
  return confirmations.prepare({
1606
1905
  action: tool.name,
1607
- payload: validInput,
1906
+ payload: preflightInput,
1608
1907
  context: confirmationContext(context),
1609
- summary: tool.writeSummary(validInput)
1908
+ summary: tool.writeSummary(preflightInput)
1610
1909
  });
1611
1910
  }
1612
1911
  async executeConfirmed(action, payload, context) {
1613
1912
  const tool = this.byName(action);
1614
1913
  if (tool.operation !== "write") throw new AiHubError("AI_HUB_TOOL_NOT_WRITE", `Tool "${action}" is not a write Tool.`);
1615
- const validInput = tool.validate(payload);
1616
- return tool.handler(validInput, context);
1914
+ return tool.handler(payload, context);
1617
1915
  }
1618
1916
  capabilities(context, options = {}) {
1619
1917
  return this.list(options).map((tool) => ({
@@ -1831,6 +2129,19 @@ var CONFIRM_ACTION_TOOL = "confirm_action";
1831
2129
  function result(data) {
1832
2130
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1833
2131
  }
2132
+ function formatMcpData(toolName, data) {
2133
+ if (Array.isArray(data)) {
2134
+ return {
2135
+ dataType: "array",
2136
+ items: data,
2137
+ count: data.length,
2138
+ ...toolName === "market_get_ticker" ? { tickers: data } : {}
2139
+ };
2140
+ }
2141
+ if (data === null || data === void 0) return { dataType: "null", value: null };
2142
+ if (typeof data === "object") return { dataType: "object", value: data };
2143
+ return { dataType: "scalar", value: data };
2144
+ }
1834
2145
  function toMcpErrorResult(error) {
1835
2146
  const payload = toAiHubErrorPayload(error);
1836
2147
  return { isError: true, content: [{ type: "text", text: JSON.stringify({ ok: false, ...payload }) }] };
@@ -1839,7 +2150,7 @@ function toMcpTool(tool) {
1839
2150
  return {
1840
2151
  name: tool.name,
1841
2152
  title: tool.title,
1842
- description: tool.description,
2153
+ description: `${tool.description}${tool.operation === "read" ? " Successful MCP output is always { ok: true, data: ... }. For list results, use data.items and data.count (market_get_ticker also provides data.tickers); for object results, use data.value. Check data.dataType before formatting." : ""}`,
1843
2154
  inputSchema: tool.inputSchema,
1844
2155
  annotations: {
1845
2156
  readOnlyHint: tool.operation === "read",
@@ -1866,10 +2177,10 @@ function createServer(profileName, readOnly) {
1866
2177
  const registry = createToolRegistry();
1867
2178
  const writeExecutor = new ToolWriteExecutor(registry);
1868
2179
  const server = new Server(
1869
- { name: "ai-hub-agent-trade", version: "0.1.0" },
2180
+ { name: "ai-hub-agent-trade", version: "0.1.4" },
1870
2181
  {
1871
2182
  capabilities: { tools: {} },
1872
- instructions: "For every state-changing action, call only a spot_prepare_* tool first and show its exact summary to the user. Stop and wait for a new, explicit user confirmation message. Only then call confirm_action with that new message verbatim in userConfirmation. Never call prepare and confirm consecutively for one user instruction; never infer confirmation from prior intent, silence, or an Agent-generated message."
2183
+ instructions: "Every successful tool response is JSON text in the envelope { ok: true, data: ... }. All read-tool data is normalized: dataType=array means use data.items and data.count; dataType=object or scalar means use data.value; dataType=null means no value. market_get_ticker also provides data.tickers as an alias of data.items. Inspect data.dataType before formatting and never assume undocumented nested keys. Prepare/confirm tools return their documented action payload directly in data. For every state-changing action, call only a spot_prepare_* or margin_prepare_* tool first and show its exact summary to the user. Stop and wait for a new, explicit user confirmation message. Only then call confirm_action with that new message verbatim in userConfirmation. Never call prepare and confirm consecutively for one user instruction; never infer confirmation from prior intent, silence, or an Agent-generated message. For spot and margin orders, MARKET BUY always uses quoteAmount (the quote asset to spend); MARKET SELL uses baseQuantity (the base asset to sell). Never reinterpret a requested base quantity as quoteAmount."
1873
2184
  }
1874
2185
  );
1875
2186
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -1913,10 +2224,10 @@ function createServer(profileName, readOnly) {
1913
2224
  }
1914
2225
  const preparedTool = registry.list({ readOnly: false }).find((tool) => tool.operation === "write" && prepareToolName(tool) === request.params.name);
1915
2226
  if (preparedTool) {
1916
- return result({ ok: true, data: writeExecutor.prepare(preparedTool.name, request.params.arguments ?? {}, context) });
2227
+ return result({ ok: true, data: await writeExecutor.prepare(preparedTool.name, request.params.arguments ?? {}, context) });
1917
2228
  }
1918
2229
  const data = await registry.execute(request.params.name, request.params.arguments ?? {}, context, { readOnly });
1919
- return result({ ok: true, data });
2230
+ return result({ ok: true, data: formatMcpData(request.params.name, data) });
1920
2231
  } catch (error) {
1921
2232
  return toMcpErrorResult(error);
1922
2233
  }
@@ -1932,7 +2243,16 @@ function readOption(args, option) {
1932
2243
  if (!value || value.startsWith("--")) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", `${option} requires a value.`);
1933
2244
  return value;
1934
2245
  }
2246
+ function printUsage() {
2247
+ process.stdout.write(
2248
+ "AI Hub Agent Trade MCP\n\nUsage:\n ai-hub-trade-mcp [--profile <name>] [--read-only]\n ai-hub-trade-mcp setup --client <client> [--profile <name>]\n\nThe default command starts the local stdio MCP server.\nUse setup to register it with Cursor, Claude Desktop, Claude Code, or Codex.\n"
2249
+ );
2250
+ }
1935
2251
  async function main(argv) {
2252
+ if (argv.includes("--help") || argv.includes("-h")) {
2253
+ printUsage();
2254
+ return;
2255
+ }
1936
2256
  if (argv[0] === "setup") {
1937
2257
  const client = readOption(argv.slice(1), "--client");
1938
2258
  const profile = readOption(argv.slice(1), "--profile");
@@ -1953,7 +2273,15 @@ async function main(argv) {
1953
2273
  const server = createServer(profileName, readOnly);
1954
2274
  await server.connect(new StdioServerTransport());
1955
2275
  }
1956
- main(process.argv.slice(2)).catch((error) => {
2276
+ function isDirectExecution() {
2277
+ if (!process.argv[1]) return false;
2278
+ try {
2279
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
2280
+ } catch {
2281
+ return false;
2282
+ }
2283
+ }
2284
+ if (isDirectExecution()) main(process.argv.slice(2)).catch((error) => {
1957
2285
  const payload = error instanceof AiHubError ? { code: error.code, message: error.message } : { code: "AI_HUB_UNEXPECTED_ERROR", message: error instanceof Error ? error.message : "Unexpected error" };
1958
2286
  process.stderr.write(`${JSON.stringify(payload)}
1959
2287
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aihubspot/agent-trade-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "Local stdio MCP server for AI Hub spot trading tools",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",