@aihubspot/agent-trade-mcp 0.1.7 → 0.1.9

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 +5 -2
  2. package/dist/index.js +291 -61
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -23,7 +23,7 @@ Setup registers the currently installed MCP binary through its absolute Node run
23
23
 
24
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
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.
26
+ Before any order preview, MCP lazily loads `/sapi/v2/symbols` once per local profile and caches the symbol rules for one hour in memory and an isolated local cache. Known quantity/price precision and limit-order minimum violations are rejected before confirmation.
27
27
 
28
28
  ## Read response
29
29
 
@@ -39,7 +39,10 @@ MCP clients that support the current protocol also receive the same envelope in
39
39
 
40
40
  Use these tools for requests that would otherwise return a broad market payload:
41
41
 
42
- - `market_search_symbols`: bounded symbol search and metadata. Use this for every MCP symbol lookup; it returns matching rows in `data.value.items`.
42
+ - `market_get_symbol_overview`: counts by quote asset and a small sample. Use this first for a generic request to list trading pairs.
43
+ - `market_list_symbols`: a paged, optionally quote-asset-filtered list without order-rule metadata.
44
+ - `market_search_symbols`: required-keyword lookup with at most 20 basic matching rows. Do not use it for a generic pair list.
45
+ - `market_get_symbol_info`: exact precision and minimum order rules for one symbol only.
43
46
  - `market_get_ticker_summary`: watchlist, gainers, losers, and quote-volume leaders.
44
47
  - `market_get_depth_summary`: best bid/ask, spread, and up to 20 levels per side.
45
48
  - `market_get_trades_summary`: price range, buy/sell statistics, and up to 50 recent trades.
package/dist/index.js CHANGED
@@ -656,29 +656,82 @@ function optionalClientOrderId(input) {
656
656
  }
657
657
 
658
658
  // ../core/src/tools/asset-tools.ts
659
- var accountTypes = ["1", "2", "3", "4", "5"];
659
+ var ACCOUNT_TYPES = ["1", "2", "3", "4", "5"];
660
+ var ACCOUNT_TYPE_NAMES = {
661
+ "1": "Spot",
662
+ "2": "Isolated Margin",
663
+ "3": "Cross Margin",
664
+ "4": "C2C",
665
+ "5": "Derivatives"
666
+ };
667
+ var accountTypeSchema = {
668
+ type: "string",
669
+ oneOf: [
670
+ { const: "1", title: "Spot (1)" },
671
+ { const: "2", title: "Isolated Margin (2; symbol required)" },
672
+ { const: "3", title: "Cross Margin (3)" },
673
+ { const: "4", title: "C2C (4)" },
674
+ { const: "5", title: "Derivatives (5)" }
675
+ ],
676
+ description: "Account type: 1=Spot, 2=Isolated Margin (requires symbol), 3=Cross Margin, 4=C2C, 5=Derivatives. Do not use 2 for Derivatives."
677
+ };
678
+ var spotDerivativeAccounts = ["EXCHANGE", "FUTURE"];
660
679
  function accountType(value, name) {
661
680
  const result2 = requiredString(value, name);
662
- if (!accountTypes.includes(result2)) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", `${name} must be an account type from 1 to 5.`);
681
+ if (!ACCOUNT_TYPES.includes(result2)) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", `${name} must be an account type: 1=Spot, 2=Isolated Margin, 3=Cross Margin, 4=C2C, or 5=Derivatives.`);
663
682
  return result2;
664
683
  }
684
+ function accountTypeName(value) {
685
+ return ACCOUNT_TYPE_NAMES[value];
686
+ }
687
+ function isIsolatedMarginTransfer(fromAccountType, toAccountType) {
688
+ return fromAccountType === "2" || toAccountType === "2";
689
+ }
665
690
  function requiredTransfer(input) {
666
691
  const value = strictObject(input, ["fromAccountType", "toAccountType", "symbol", "coinSymbol", "amount"]);
667
- return { fromAccountType: accountType(value, "fromAccountType"), toAccountType: accountType(value, "toAccountType"), coinSymbol: requiredString(value, "coinSymbol"), amount: positiveDecimal(value, "amount"), ...optionalString(value, "symbol") ? { symbol: optionalString(value, "symbol") } : {} };
692
+ const fromAccountType = accountType(value, "fromAccountType");
693
+ const toAccountType = accountType(value, "toAccountType");
694
+ if (fromAccountType === toAccountType) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "fromAccountType and toAccountType must be different.");
695
+ const symbol = optionalString(value, "symbol");
696
+ if (isIsolatedMarginTransfer(fromAccountType, toAccountType) && !symbol) {
697
+ throw new AiHubError("AI_HUB_ISOLATED_MARGIN_SYMBOL_REQUIRED", "symbol is required when fromAccountType or toAccountType is 2 (Isolated Margin). Provide the isolated-margin trading pair, for example ETHUSDT.");
698
+ }
699
+ return { fromAccountType, toAccountType, coinSymbol: requiredString(value, "coinSymbol"), amount: positiveDecimal(value, "amount"), ...symbol ? { symbol } : {} };
668
700
  }
669
701
  function transferQuery(input) {
670
702
  const value = strictObject(input, ["fromAccountType", "toAccountType", "symbol", "coinSymbol", "page", "pageSize"]);
671
- return { fromAccountType: accountType(value, "fromAccountType"), toAccountType: accountType(value, "toAccountType"), page: optionalPositiveInteger(value, "page", 1), pageSize: optionalPositiveInteger(value, "pageSize", 20, 100), ...optionalString(value, "symbol") ? { symbol: optionalString(value, "symbol") } : {}, ...optionalString(value, "coinSymbol") ? { coinSymbol: optionalString(value, "coinSymbol") } : {} };
703
+ const fromAccountType = accountType(value, "fromAccountType");
704
+ const toAccountType = accountType(value, "toAccountType");
705
+ const symbol = optionalString(value, "symbol");
706
+ if (isIsolatedMarginTransfer(fromAccountType, toAccountType) && !symbol) {
707
+ throw new AiHubError("AI_HUB_ISOLATED_MARGIN_SYMBOL_REQUIRED", "symbol is required when fromAccountType or toAccountType is 2 (Isolated Margin). Provide the isolated-margin trading pair, for example ETHUSDT.");
708
+ }
709
+ return { fromAccountType, toAccountType, page: optionalPositiveInteger(value, "page", 1), pageSize: optionalPositiveInteger(value, "pageSize", 20, 100), ...symbol ? { symbol } : {}, ...optionalString(value, "coinSymbol") ? { coinSymbol: optionalString(value, "coinSymbol") } : {} };
672
710
  }
673
711
  function transferAccounts(input) {
674
712
  const value = strictObject(input, ["coinSymbol", "amount", "fromAccount", "toAccount"]);
675
- return { coinSymbol: requiredString(value, "coinSymbol"), amount: positiveDecimal(value, "amount"), fromAccount: requiredString(value, "fromAccount"), toAccount: requiredString(value, "toAccount") };
713
+ const fromAccount = requiredString(value, "fromAccount").toUpperCase();
714
+ const toAccount = requiredString(value, "toAccount").toUpperCase();
715
+ if (!spotDerivativeAccounts.includes(fromAccount) || !spotDerivativeAccounts.includes(toAccount) || fromAccount === toAccount) {
716
+ throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "account transfer supports only EXCHANGE -> FUTURE or FUTURE -> EXCHANGE. Use wallet transfer for isolated margin, cross margin, or C2C.");
717
+ }
718
+ return { coinSymbol: requiredString(value, "coinSymbol"), amount: positiveDecimal(value, "amount"), fromAccount, toAccount };
676
719
  }
677
720
  function transferAccountHistory(input) {
678
721
  const value = strictObject(input, ["transferId", "coinSymbol", "fromAccount", "toAccount", "startTime", "endTime", "page", "limit"]);
679
722
  const transferId = optionalString(value, "transferId");
680
723
  if (!transferId && (!optionalString(value, "fromAccount") || !optionalString(value, "toAccount"))) throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "fromAccount and toAccount are required when transferId is omitted.");
681
- return { ...transferId ? { transferId } : {}, ...optionalString(value, "coinSymbol") ? { coinSymbol: optionalString(value, "coinSymbol") } : {}, ...optionalString(value, "fromAccount") ? { fromAccount: optionalString(value, "fromAccount") } : {}, ...optionalString(value, "toAccount") ? { toAccount: optionalString(value, "toAccount") } : {}, ...optionalString(value, "startTime") ? { startTime: optionalString(value, "startTime") } : {}, ...optionalString(value, "endTime") ? { endTime: optionalString(value, "endTime") } : {}, page: optionalPositiveInteger(value, "page", 1), limit: optionalPositiveInteger(value, "limit", 20, 100) };
724
+ const fromAccount = optionalString(value, "fromAccount")?.toUpperCase();
725
+ const toAccount = optionalString(value, "toAccount")?.toUpperCase();
726
+ if (!transferId && (!spotDerivativeAccounts.includes(fromAccount) || !spotDerivativeAccounts.includes(toAccount) || fromAccount === toAccount)) {
727
+ throw new AiHubError("AI_HUB_INVALID_ARGUMENT", "account transfer history supports only EXCHANGE -> FUTURE or FUTURE -> EXCHANGE when transferId is omitted.");
728
+ }
729
+ return { ...transferId ? { transferId } : {}, ...optionalString(value, "coinSymbol") ? { coinSymbol: optionalString(value, "coinSymbol") } : {}, ...fromAccount ? { fromAccount } : {}, ...toAccount ? { toAccount } : {}, ...optionalString(value, "startTime") ? { startTime: optionalString(value, "startTime") } : {}, ...optionalString(value, "endTime") ? { endTime: optionalString(value, "endTime") } : {}, page: optionalPositiveInteger(value, "page", 1), limit: optionalPositiveInteger(value, "limit", 20, 100) };
730
+ }
731
+ function withAccountTypeMetadata(response, value) {
732
+ const metadata = { accountType: value, accountTypeName: accountTypeName(value) };
733
+ if (response && typeof response === "object" && !Array.isArray(response)) return { ...response, ...metadata };
734
+ return { ...metadata, response };
682
735
  }
683
736
  function pagedDates(input) {
684
737
  const value = strictObject(input, ["startTime", "endTime", "page", "pageSize"]);
@@ -691,18 +744,21 @@ function coinOnly(input) {
691
744
  var assetTools = [
692
745
  {
693
746
  name: "account_transfer",
694
- title: "Transfer Between Accounts",
695
- description: "Transfer an asset between documented account types after confirmation.",
747
+ title: "Transfer Between Spot and Derivatives",
748
+ description: "Transfer only between Spot and Derivatives after confirmation. Use fromAccount=EXCHANGE and toAccount=FUTURE for Spot to Derivatives; use the reverse for Derivatives to Spot. Do not pass numeric account types to this Tool.",
696
749
  cliPath: ["account", "transfer"],
697
750
  module: "spot-account",
698
751
  access: "signed",
699
752
  operation: "write",
700
753
  riskLevel: "high",
701
- inputSchema: { type: "object", properties: { coinSymbol: { type: "string" }, amount: { type: "string" }, fromAccount: { type: "string" }, toAccount: { type: "string" } }, required: ["coinSymbol", "amount", "fromAccount", "toAccount"], additionalProperties: false },
754
+ inputSchema: { type: "object", properties: { coinSymbol: { type: "string" }, amount: { type: "string" }, fromAccount: { type: "string", enum: spotDerivativeAccounts, description: "EXCHANGE means Spot; FUTURE means Derivatives." }, toAccount: { type: "string", enum: spotDerivativeAccounts, description: "EXCHANGE means Spot; FUTURE means Derivatives." } }, required: ["coinSymbol", "amount", "fromAccount", "toAccount"], additionalProperties: false },
702
755
  errorCodes: writeErrors,
703
756
  validate: transferAccounts,
704
757
  handler: (input, context) => context.api.signedPost("/sapi/v1/asset/transfer", input, signed(context)),
705
- writeSummary: (input) => ({ action: "account_transfer", ...input })
758
+ writeSummary: (input) => {
759
+ const value = input;
760
+ return { action: "spot_derivatives_transfer", coinSymbol: value.coinSymbol, amount: value.amount, fromAccount: value.fromAccount, fromAccountName: value.fromAccount === "EXCHANGE" ? "Spot" : "Derivatives", toAccount: value.toAccount, toAccountName: value.toAccount === "EXCHANGE" ? "Spot" : "Derivatives" };
761
+ }
706
762
  },
707
763
  {
708
764
  name: "account_get_transfer_history",
@@ -721,28 +777,31 @@ var assetTools = [
721
777
  {
722
778
  name: "wallet_universal_transfer",
723
779
  title: "Universal Asset Transfer",
724
- description: "Transfer between account types after confirmation.",
780
+ description: "Transfer between numeric account types after confirmation: 1=Spot, 2=Isolated Margin, 3=Cross Margin, 4=C2C, 5=Derivatives. Account type 2 is not Derivatives and requires symbol. For a straightforward Spot to Derivatives transfer, prefer account_transfer with EXCHANGE -> FUTURE.",
725
781
  cliPath: ["wallet", "transfer"],
726
782
  module: "spot-deposit-withdraw",
727
783
  access: "signed",
728
784
  operation: "write",
729
785
  riskLevel: "high",
730
- inputSchema: { type: "object", properties: { fromAccountType: { type: "string" }, toAccountType: { type: "string" }, symbol: { type: "string" }, coinSymbol: { type: "string" }, amount: { type: "string" } }, required: ["fromAccountType", "toAccountType", "coinSymbol", "amount"], additionalProperties: false },
786
+ inputSchema: { type: "object", properties: { fromAccountType: accountTypeSchema, toAccountType: accountTypeSchema, symbol: { type: "string", description: "Required isolated-margin trading pair when either account type is 2, for example ETHUSDT. Not a Derivatives identifier." }, coinSymbol: { type: "string" }, amount: { type: "string" } }, required: ["fromAccountType", "toAccountType", "coinSymbol", "amount"], additionalProperties: false },
731
787
  errorCodes: writeErrors,
732
788
  validate: requiredTransfer,
733
789
  handler: (input, context) => context.api.signedPost("/sapi/v1/asset/universal_transfer", input, signed(context)),
734
- writeSummary: (input) => ({ action: "universal_transfer", ...input })
790
+ writeSummary: (input) => {
791
+ const value = input;
792
+ return { action: "universal_transfer", fromAccountType: value.fromAccountType, fromAccountTypeName: accountTypeName(String(value.fromAccountType)), toAccountType: value.toAccountType, toAccountTypeName: accountTypeName(String(value.toAccountType)), coinSymbol: value.coinSymbol, amount: value.amount, symbol: value.symbol ?? null };
793
+ }
735
794
  },
736
795
  {
737
796
  name: "wallet_get_universal_transfer_history",
738
797
  title: "Get Universal Transfer History",
739
- description: "Query universal asset transfer history.",
798
+ description: "Query universal transfer history using account types 1=Spot, 2=Isolated Margin, 3=Cross Margin, 4=C2C, and 5=Derivatives.",
740
799
  cliPath: ["wallet", "transfer-history"],
741
800
  module: "spot-deposit-withdraw",
742
801
  access: "signed",
743
802
  operation: "read",
744
803
  riskLevel: "low",
745
- inputSchema: { type: "object", properties: { fromAccountType: { type: "string" }, toAccountType: { type: "string" }, symbol: { type: "string" }, coinSymbol: { type: "string" }, page: { type: "integer" }, pageSize: { type: "integer" } }, required: ["fromAccountType", "toAccountType"], additionalProperties: false },
804
+ inputSchema: { type: "object", properties: { fromAccountType: accountTypeSchema, toAccountType: accountTypeSchema, symbol: { type: "string", description: "Isolated-margin trading pair when account type 2 is involved." }, coinSymbol: { type: "string" }, page: { type: "integer" }, pageSize: { type: "integer" } }, required: ["fromAccountType", "toAccountType"], additionalProperties: false },
746
805
  errorCodes: signedReadErrors,
747
806
  validate: transferQuery,
748
807
  handler: (input, context) => context.api.signedPost("/sapi/v1/asset/universal_transfer_query", input, signed(context))
@@ -795,19 +854,22 @@ var assetTools = [
795
854
  {
796
855
  name: "wallet_get_transferable_assets",
797
856
  title: "Get Transferable Assets",
798
- description: "Query assets transferable from one account type.",
857
+ description: "Query transferable assets for one account type. The response echoes the selected account type and its name: 1=Spot, 2=Isolated Margin, 3=Cross Margin, 4=C2C, 5=Derivatives.",
799
858
  cliPath: ["wallet", "transferable-assets"],
800
859
  module: "spot-deposit-withdraw",
801
860
  access: "signed",
802
861
  operation: "read",
803
862
  riskLevel: "low",
804
- inputSchema: { type: "object", properties: { accountType: { type: "string" } }, required: ["accountType"], additionalProperties: false },
863
+ inputSchema: { type: "object", properties: { accountType: accountTypeSchema }, required: ["accountType"], additionalProperties: false },
805
864
  errorCodes: signedReadErrors,
806
865
  validate: (input) => {
807
866
  const value = strictObject(input, ["accountType"]);
808
867
  return { accountType: accountType(value, "accountType") };
809
868
  },
810
- handler: (input, context) => context.api.signedPost("/sapi/v1/asset/account/by_type", input, signed(context))
869
+ handler: async (input, context) => {
870
+ const value = input;
871
+ return withAccountTypeMetadata(await context.api.signedPost("/sapi/v1/asset/account/by_type", value, signed(context)), value.accountType);
872
+ }
811
873
  },
812
874
  {
813
875
  name: "wallet_get_exchange_account",
@@ -970,47 +1032,112 @@ function summarizeTickers(value, options) {
970
1032
  topByQuoteVolume: [...rows2].sort(compareByNumericField("amount", "desc")).slice(0, limit).map(tickerItem)
971
1033
  };
972
1034
  }
973
- function summarizeSymbols(value, options) {
1035
+ function normalizedSymbol(value) {
1036
+ return value.replaceAll("/", "").trim().toUpperCase();
1037
+ }
1038
+ function parsedSymbolRows(value) {
974
1039
  const payload = record(value, "Symbols response must be an object.");
975
1040
  const rows2 = records(payload.symbols, "Symbols response must contain a symbols array.");
976
- const query = options.query?.trim().toUpperCase();
977
- const quoteAsset = options.quoteAsset?.trim().toUpperCase();
978
- const matches = rows2.filter((item) => {
979
- const symbol = text(item.SymbolName) ?? text(item.symbol) ?? "";
980
- const quote = text(item.quoteAsset)?.toUpperCase();
981
- return (!query || symbol.toUpperCase().includes(query)) && (!quoteAsset || quote === quoteAsset);
982
- });
983
- const sortedMatches = [...matches].sort((left, right) => {
984
- if (!query) return 0;
1041
+ return rows2.map((item) => {
1042
+ const apiSymbol = text(item.symbol);
1043
+ const symbol = text(item.SymbolName) ?? apiSymbol ?? "";
1044
+ return {
1045
+ symbol,
1046
+ apiSymbol,
1047
+ baseAsset: text(item.baseAssetName) ?? text(item.baseAsset),
1048
+ quoteAsset: text(item.quoteAssetName) ?? text(item.quoteAsset),
1049
+ pricePrecision: item.pricePrecision ?? null,
1050
+ quantityPrecision: item.quantityPrecision ?? null,
1051
+ limitVolumeMin: text(item.limitVolumeMin),
1052
+ limitPriceMin: text(item.limitPriceMin),
1053
+ limitAmountMin: text(item.limitAmountMin)
1054
+ };
1055
+ }).filter((item) => Boolean(item.symbol));
1056
+ }
1057
+ function quoteAssetCounts(rows2) {
1058
+ const counts = /* @__PURE__ */ new Map();
1059
+ for (const item of rows2) {
1060
+ const quote = item.quoteAsset?.toUpperCase();
1061
+ if (quote) counts.set(quote, (counts.get(quote) ?? 0) + 1);
1062
+ }
1063
+ return [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])).map(([asset, count]) => ({ asset, count }));
1064
+ }
1065
+ function basicSymbolItem(item) {
1066
+ return {
1067
+ symbol: item.symbol,
1068
+ apiSymbol: item.apiSymbol,
1069
+ baseAsset: item.baseAsset,
1070
+ quoteAsset: item.quoteAsset
1071
+ };
1072
+ }
1073
+ function fullSymbolItem(item) {
1074
+ return {
1075
+ ...basicSymbolItem(item),
1076
+ pricePrecision: item.pricePrecision,
1077
+ quantityPrecision: item.quantityPrecision,
1078
+ limitVolumeMin: item.limitVolumeMin,
1079
+ limitPriceMin: item.limitPriceMin,
1080
+ limitAmountMin: item.limitAmountMin
1081
+ };
1082
+ }
1083
+ function sortSymbolMatches(rows2, query) {
1084
+ const normalizedQuery = query?.trim().toUpperCase();
1085
+ return [...rows2].sort((left, right) => {
1086
+ if (!normalizedQuery) return left.symbol.localeCompare(right.symbol);
985
1087
  const rank = (item) => {
986
- const symbol = (text(item.SymbolName) ?? text(item.symbol) ?? "").toUpperCase();
987
- return symbol.startsWith(`${query}/`) ? 0 : symbol.startsWith(query) ? 1 : 2;
1088
+ const symbol = item.symbol.toUpperCase();
1089
+ return symbol.startsWith(`${normalizedQuery}/`) ? 0 : symbol.startsWith(normalizedQuery) ? 1 : 2;
988
1090
  };
989
- return rank(left) - rank(right);
1091
+ return rank(left) - rank(right) || left.symbol.localeCompare(right.symbol);
990
1092
  });
991
- const quoteAssetCounts = /* @__PURE__ */ new Map();
992
- for (const item of rows2) {
993
- const quote = text(item.quoteAsset)?.toUpperCase();
994
- if (quote) quoteAssetCounts.set(quote, (quoteAssetCounts.get(quote) ?? 0) + 1);
995
- }
996
- const items = sortedMatches.slice(0, options.limit).map((item) => ({
997
- symbol: text(item.SymbolName) ?? text(item.symbol),
998
- apiSymbol: text(item.symbol),
999
- baseAsset: text(item.baseAssetName) ?? text(item.baseAsset),
1000
- quoteAsset: text(item.quoteAssetName) ?? text(item.quoteAsset),
1001
- pricePrecision: item.pricePrecision ?? null,
1002
- quantityPrecision: item.quantityPrecision ?? null,
1003
- limitVolumeMin: text(item.limitVolumeMin),
1004
- limitPriceMin: text(item.limitPriceMin),
1005
- limitAmountMin: text(item.limitAmountMin)
1006
- }));
1093
+ }
1094
+ function summarizeSymbolOverview(value, options) {
1095
+ const rows2 = parsedSymbolRows(value);
1007
1096
  return {
1008
1097
  totalSymbols: rows2.length,
1098
+ quoteAssetCounts: quoteAssetCounts(rows2),
1099
+ sampleSymbols: sortSymbolMatches(rows2).slice(0, options.limit).map((item) => item.symbol)
1100
+ };
1101
+ }
1102
+ function listSymbols(value, options) {
1103
+ const allRows = parsedSymbolRows(value);
1104
+ const quoteAsset = options.quoteAsset?.trim().toUpperCase();
1105
+ const matches = sortSymbolMatches(allRows.filter((item) => !quoteAsset || item.quoteAsset?.toUpperCase() === quoteAsset));
1106
+ const items = matches.slice(options.offset, options.offset + options.limit).map(basicSymbolItem);
1107
+ const nextOffset = options.offset + items.length;
1108
+ return {
1109
+ totalSymbols: allRows.length,
1009
1110
  matchedSymbols: matches.length,
1010
- quoteAssetCounts: [...quoteAssetCounts.entries()].sort((left, right) => right[1] - left[1]).map(([asset, count]) => ({ asset, count })),
1111
+ quoteAsset: quoteAsset ?? null,
1112
+ offset: options.offset,
1113
+ limit: options.limit,
1114
+ nextOffset: nextOffset < matches.length ? nextOffset : null,
1011
1115
  items
1012
1116
  };
1013
1117
  }
1118
+ function searchSymbols(value, options) {
1119
+ const allRows = parsedSymbolRows(value);
1120
+ const query = options.query.trim().toUpperCase();
1121
+ const quoteAsset = options.quoteAsset?.trim().toUpperCase();
1122
+ const matches = allRows.filter((item) => {
1123
+ const symbol = item.symbol.toUpperCase();
1124
+ const apiSymbol = item.apiSymbol?.toUpperCase() ?? "";
1125
+ return (symbol.includes(query) || apiSymbol.includes(query)) && (!quoteAsset || item.quoteAsset?.toUpperCase() === quoteAsset);
1126
+ });
1127
+ const sortedMatches = sortSymbolMatches(matches, query);
1128
+ return {
1129
+ matchedSymbols: matches.length,
1130
+ query,
1131
+ quoteAsset: quoteAsset ?? null,
1132
+ items: sortedMatches.slice(0, options.limit).map(basicSymbolItem)
1133
+ };
1134
+ }
1135
+ function getSymbolInfo(value, symbol) {
1136
+ const target = normalizedSymbol(symbol);
1137
+ const item = parsedSymbolRows(value).find((row) => normalizedSymbol(row.symbol) === target || row.apiSymbol !== null && normalizedSymbol(row.apiSymbol) === target);
1138
+ if (!item) throw new AiHubError("AI_HUB_SYMBOL_NOT_FOUND", `Symbol "${symbol}" was not returned by the configured tenant OpenAPI symbols endpoint.`);
1139
+ return fullSymbolItem(item);
1140
+ }
1014
1141
  function level(value) {
1015
1142
  if (!Array.isArray(value)) return { price: null, quantity: null };
1016
1143
  return { price: text(value[0]), quantity: text(value[1]) };
@@ -1107,13 +1234,53 @@ function summarizeKlines(value, symbol, interval) {
1107
1234
  }
1108
1235
 
1109
1236
  // ../core/src/tools/symbol-rules.ts
1237
+ import { createHash as createHash4 } from "crypto";
1238
+ import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, rename as rename2, rm, writeFile as writeFile2 } from "fs/promises";
1239
+ import { homedir as homedir2 } from "os";
1240
+ import { join as join2 } from "path";
1110
1241
  var CACHE_TTL_MS = 60 * 60 * 1e3;
1111
1242
  var cache = /* @__PURE__ */ new Map();
1112
1243
  var pendingLoads = /* @__PURE__ */ new Map();
1113
1244
  function cacheKey(context) {
1114
1245
  return `${context.profile.name}\0${context.profile.configVersion}\0${context.profile.openApiBaseUrl}`;
1115
1246
  }
1116
- function normalizedSymbol(symbol) {
1247
+ function persistentCacheEnabled() {
1248
+ return process.env.AI_HUB_DISABLE_PERSISTENT_CACHE !== "1";
1249
+ }
1250
+ function persistentCacheDirectory() {
1251
+ return process.env.AI_HUB_CACHE_DIR ?? join2(homedir2(), ".ai-hub", "cache");
1252
+ }
1253
+ function persistentCachePath(key) {
1254
+ const hash2 = createHash4("sha256").update(key).digest("hex");
1255
+ return join2(persistentCacheDirectory(), `symbols-${hash2}.json`);
1256
+ }
1257
+ async function loadPersistentSnapshot(key) {
1258
+ if (!persistentCacheEnabled()) return void 0;
1259
+ try {
1260
+ const parsed = JSON.parse(await readFile2(persistentCachePath(key), "utf8"));
1261
+ if (typeof parsed.expiresAt !== "number" || !Number.isFinite(parsed.expiresAt) || parsed.expiresAt <= Date.now() || !("response" in parsed)) return void 0;
1262
+ return { expiresAt: parsed.expiresAt, response: parsed.response, rules: parseRules(parsed.response) };
1263
+ } catch {
1264
+ return void 0;
1265
+ }
1266
+ }
1267
+ async function persistSnapshot(key, entry) {
1268
+ if (!persistentCacheEnabled()) return;
1269
+ const directory = persistentCacheDirectory();
1270
+ const filePath = persistentCachePath(key);
1271
+ const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
1272
+ try {
1273
+ await mkdir2(directory, { recursive: true, mode: 448 });
1274
+ await chmod2(directory, 448);
1275
+ await writeFile2(temporaryPath, JSON.stringify({ expiresAt: entry.expiresAt, response: entry.response }), { mode: 384 });
1276
+ await chmod2(temporaryPath, 384);
1277
+ await rename2(temporaryPath, filePath);
1278
+ await chmod2(filePath, 384);
1279
+ } catch {
1280
+ await rm(temporaryPath, { force: true }).catch(() => void 0);
1281
+ }
1282
+ }
1283
+ function normalizedSymbol2(symbol) {
1117
1284
  return symbol.replaceAll("/", "").trim().toUpperCase();
1118
1285
  }
1119
1286
  function stringValue(value) {
@@ -1140,7 +1307,7 @@ function parseRules(response) {
1140
1307
  for (const row of rows(response)) {
1141
1308
  const symbol = stringValue(row.symbol);
1142
1309
  if (!symbol) continue;
1143
- result2.set(normalizedSymbol(symbol), {
1310
+ result2.set(normalizedSymbol2(symbol), {
1144
1311
  symbol,
1145
1312
  quantityPrecision: nonNegativeInteger(row.quantityPrecision),
1146
1313
  pricePrecision: nonNegativeInteger(row.pricePrecision),
@@ -1157,11 +1324,18 @@ async function loadSnapshot(context) {
1157
1324
  if (current && current.expiresAt > Date.now()) return current;
1158
1325
  const pending = pendingLoads.get(key);
1159
1326
  if (pending) return pending;
1160
- const load = context.api.symbols().then((response) => {
1327
+ const load = (async () => {
1328
+ const persisted = await loadPersistentSnapshot(key);
1329
+ if (persisted) {
1330
+ cache.set(key, persisted);
1331
+ return persisted;
1332
+ }
1333
+ const response = await context.api.symbols();
1161
1334
  const entry = { response, rules: parseRules(response), expiresAt: Date.now() + CACHE_TTL_MS };
1162
1335
  cache.set(key, entry);
1336
+ await persistSnapshot(key, entry);
1163
1337
  return entry;
1164
- }).finally(() => pendingLoads.delete(key));
1338
+ })().finally(() => pendingLoads.delete(key));
1165
1339
  pendingLoads.set(key, load);
1166
1340
  return load;
1167
1341
  }
@@ -1170,7 +1344,7 @@ async function getCachedSymbols(context) {
1170
1344
  }
1171
1345
  async function getSymbolRule(context, symbol) {
1172
1346
  const entry = await loadSnapshot(context);
1173
- const rule = entry.rules.get(normalizedSymbol(symbol));
1347
+ const rule = entry.rules.get(normalizedSymbol2(symbol));
1174
1348
  if (!rule) throw new AiHubError("AI_HUB_SYMBOL_NOT_FOUND", `Symbol "${symbol}" was not returned by the configured tenant OpenAPI symbols endpoint.`);
1175
1349
  return rule;
1176
1350
  }
@@ -1227,6 +1401,7 @@ async function preflightSymbolOrder(context, order) {
1227
1401
 
1228
1402
  // ../core/src/tools/market-tools.ts
1229
1403
  var readErrors = ["AI_HUB_INVALID_ARGUMENT", "AI_HUB_OPENAPI_NETWORK_ERROR", "AI_HUB_OPENAPI_HTTP_ERROR", "AI_HUB_OPENAPI_INVALID_RESPONSE", "AI_HUB_OPENAPI_BUSINESS_ERROR"];
1404
+ var symbolReadErrors = [...readErrors, "AI_HUB_SYMBOL_NOT_FOUND"];
1230
1405
  var KLINE_INTERVALS = ["1min", "5min", "15min", "30min", "60min", "1day", "1week", "1month"];
1231
1406
  var KLINE_INTERVAL_ALIASES = {
1232
1407
  "1m": "1min",
@@ -1299,7 +1474,7 @@ var marketTools = [
1299
1474
  {
1300
1475
  name: "market_get_symbols",
1301
1476
  title: "Get Spot Symbols",
1302
- description: "Get complete spot symbol metadata from the configured tenant OpenAPI. Use market_search_symbols for browsing or filtering, because this response may be large.",
1477
+ description: "Get complete spot symbol metadata from the configured tenant OpenAPI. Use market_get_symbol_overview, market_list_symbols, market_search_symbols, or market_get_symbol_info for Agent-facing symbol requests, because this response may be large.",
1303
1478
  cliPath: ["market", "symbols"],
1304
1479
  module: "spot-common",
1305
1480
  access: "public",
@@ -1311,26 +1486,81 @@ var marketTools = [
1311
1486
  validate: (input) => strictObject(input, []),
1312
1487
  handler: (_input, context) => getCachedSymbols(context)
1313
1488
  },
1489
+ {
1490
+ name: "market_get_symbol_overview",
1491
+ title: "Get Spot Symbol Overview",
1492
+ description: "Get a small overview of all configured spot symbols: total count, quote-asset counts, and a bounded sample of display symbols. Use this for generic requests such as listing available trading pairs.",
1493
+ cliPath: ["market", "symbols-overview"],
1494
+ module: "spot-common",
1495
+ access: "public",
1496
+ operation: "read",
1497
+ riskLevel: "low",
1498
+ inputSchema: { type: "object", properties: { limit: { type: "integer", minimum: 1, maximum: 20, description: "Number of sample display symbols. Defaults to 12." } }, additionalProperties: false },
1499
+ errorCodes: readErrors,
1500
+ validate: (input) => {
1501
+ const value = strictObject(input, ["limit"]);
1502
+ return { limit: optionalInteger(value, "limit", 12, 1, 20) };
1503
+ },
1504
+ handler: async (input, context) => summarizeSymbolOverview(await getCachedSymbols(context), input)
1505
+ },
1506
+ {
1507
+ name: "market_list_symbols",
1508
+ title: "List Spot Symbols",
1509
+ description: "List one bounded page of configured spot symbols, optionally restricted to a quote asset. This response excludes precision and trading-rule metadata. Use market_get_symbol_info for one exact symbol's rules.",
1510
+ cliPath: ["market", "symbols-list"],
1511
+ module: "spot-common",
1512
+ access: "public",
1513
+ operation: "read",
1514
+ riskLevel: "low",
1515
+ inputSchema: { type: "object", properties: { quoteAsset: { type: "string" }, offset: { type: "integer", minimum: 0, description: "Zero-based page offset. Defaults to 0." }, limit: { type: "integer", minimum: 1, maximum: 50, description: "Page size. Defaults to 20." } }, additionalProperties: false },
1516
+ errorCodes: readErrors,
1517
+ validate: (input) => {
1518
+ const value = strictObject(input, ["quoteAsset", "offset", "limit"]);
1519
+ return {
1520
+ quoteAsset: optionalString(value, "quoteAsset"),
1521
+ offset: optionalInteger(value, "offset", 0, 0, Number.MAX_SAFE_INTEGER),
1522
+ limit: optionalInteger(value, "limit", 20, 1, 50)
1523
+ };
1524
+ },
1525
+ handler: async (input, context) => listSymbols(await getCachedSymbols(context), input)
1526
+ },
1314
1527
  {
1315
1528
  name: "market_search_symbols",
1316
1529
  title: "Search Spot Symbols",
1317
- description: "Search the configured tenant's spot symbols and return a bounded metadata result. In MCP, matching rows are at data.value.items. Use this instead of market_get_symbols unless complete raw metadata is explicitly required.",
1530
+ description: "Search configured spot symbols by a required keyword and return a small matching set without trading-rule metadata. Use market_list_symbols for browsing, or market_get_symbol_info for exact precision and minimum rules.",
1318
1531
  cliPath: ["market", "symbols-search"],
1319
1532
  module: "spot-common",
1320
1533
  access: "public",
1321
1534
  operation: "read",
1322
1535
  riskLevel: "low",
1323
- inputSchema: { type: "object", properties: { query: { type: "string" }, quoteAsset: { type: "string" }, limit: { type: "integer", minimum: 1, maximum: 50 } }, additionalProperties: false },
1536
+ inputSchema: { type: "object", properties: { query: { type: "string", minLength: 1, description: "Required asset or symbol keyword, for example BTC." }, quoteAsset: { type: "string" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, required: ["query"], additionalProperties: false },
1324
1537
  errorCodes: readErrors,
1325
1538
  validate: (input) => {
1326
1539
  const value = strictObject(input, ["query", "quoteAsset", "limit"]);
1327
- return { query: optionalString(value, "query"), quoteAsset: optionalString(value, "quoteAsset"), limit: optionalInteger(value, "limit", 20, 1, 50) };
1540
+ return { query: requiredString(value, "query"), quoteAsset: optionalString(value, "quoteAsset"), limit: optionalInteger(value, "limit", 10, 1, 20) };
1328
1541
  },
1329
1542
  handler: async (input, context) => {
1330
1543
  const value = input;
1331
- return summarizeSymbols(await getCachedSymbols(context), value);
1544
+ return searchSymbols(await getCachedSymbols(context), value);
1332
1545
  }
1333
1546
  },
1547
+ {
1548
+ name: "market_get_symbol_info",
1549
+ title: "Get Spot Symbol Information",
1550
+ description: "Get the exact configured spot symbol's trading-rule metadata, including price and quantity precision plus minimum order constraints. Requires an exact symbol such as BTCUSDT or BTC/USDT.",
1551
+ cliPath: ["market", "symbol-info"],
1552
+ module: "spot-common",
1553
+ access: "public",
1554
+ operation: "read",
1555
+ riskLevel: "low",
1556
+ inputSchema: { type: "object", properties: { symbol: { type: "string", minLength: 1 } }, required: ["symbol"], additionalProperties: false },
1557
+ errorCodes: symbolReadErrors,
1558
+ validate: (input) => {
1559
+ const value = strictObject(input, ["symbol"]);
1560
+ return { symbol: requiredString(value, "symbol") };
1561
+ },
1562
+ handler: async (input, context) => getSymbolInfo(await getCachedSymbols(context), input.symbol)
1563
+ },
1334
1564
  {
1335
1565
  name: "market_get_ticker",
1336
1566
  title: "Get Spot Ticker",
@@ -2581,10 +2811,10 @@ function createServer(profileName, readOnly) {
2581
2811
  const registry = createToolRegistry();
2582
2812
  const writeExecutor = new ToolWriteExecutor(registry);
2583
2813
  const server = new Server(
2584
- { name: "ai-hub-agent-trade", version: "0.1.7" },
2814
+ { name: "ai-hub-agent-trade", version: "0.1.9" },
2585
2815
  {
2586
2816
  capabilities: { tools: {} },
2587
- instructions: "Every successful read-tool response provides both JSON text and MCP structuredContent 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. Inspect data.dataType before formatting and never assume undocumented nested keys. For symbol lookup, use market_search_symbols. It returns an object whose bounded matches are at data.value.items; the complete raw symbols payload is intentionally unavailable in MCP. For broad market questions, call market_get_ticker_summary, market_get_depth_summary, market_get_trades_summary, or market_get_klines_summary instead of fetching large raw payloads. 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."
2817
+ instructions: "Every successful read-tool response provides both JSON text and MCP structuredContent 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. Inspect data.dataType before formatting and never assume undocumented nested keys. For a generic trading-pair list, call market_get_symbol_overview first; it returns only counts and a small sample. For paged or quote-asset browsing, use market_list_symbols. Use market_search_symbols only when the user gave a keyword; query is required. Use market_get_symbol_info only for exact precision and minimum rules. Do not use a symbol search as a generic all-symbol list. The complete raw symbols payload is intentionally unavailable in MCP. For broad market questions, call market_get_ticker_summary, market_get_depth_summary, market_get_trades_summary, or market_get_klines_summary instead of fetching large raw payloads. Account types are fixed: 1=Spot, 2=Isolated Margin, 3=Cross Margin, 4=C2C, 5=Derivatives. Never call account type 2 Derivatives. For Spot to Derivatives, use account_prepare_transfer with fromAccount=EXCHANGE and toAccount=FUTURE. Use wallet_prepare_universal_transfer for margin or C2C; account type 2 requires its isolated-margin trading pair in symbol. 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."
2588
2818
  }
2589
2819
  );
2590
2820
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -2632,7 +2862,7 @@ function createServer(profileName, readOnly) {
2632
2862
  }
2633
2863
  const tool = registry.byName(request.params.name, { readOnly });
2634
2864
  if (!isMcpVisible(tool)) {
2635
- throw new AiHubError("AI_HUB_TOOL_NOT_AVAILABLE", `Tool "${request.params.name}" is not available through MCP. Use market_search_symbols for symbol lookup.`);
2865
+ throw new AiHubError("AI_HUB_TOOL_NOT_AVAILABLE", `Tool "${request.params.name}" is not available through MCP. Use market_get_symbol_overview, market_list_symbols, market_search_symbols, or market_get_symbol_info instead.`);
2636
2866
  }
2637
2867
  const data = await registry.execute(request.params.name, request.params.arguments ?? {}, context, { readOnly });
2638
2868
  return toMcpReadResult(formatMcpData(data));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aihubspot/agent-trade-mcp",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Local stdio MCP server for AI Hub spot trading tools",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {