@candledottv/mcp 0.10.16 → 0.10.18

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 +4 -3
  2. package/dist/index.js +137 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -126,7 +126,7 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
126
126
  | `candle_execution_status` | One call before trading: wallets to spend from, tier, and whether trading is possible | Composes the wallet and tier reads | `CANDLE_AGENT_API_KEY` |
127
127
  | `candle_get_operation` | Look up a trade or launch by the id its write used, and whether it landed | `GET /api/v1/trade/agent/jobs/:clientId` or `/api/v1/launch/headless/jobs/:clientId` | `CANDLE_AGENT_API_KEY` |
128
128
  | `candle_get_fees` | The creator fees the account's launched tokens can pay: raw amounts from chain reads, and the reason a token cannot be claimed | `GET /api/v1/agent/fees` | `CANDLE_AGENT_API_KEY` (`account:read`) |
129
- | `candle_claim_fees` | Collect one token's creator fees to the wallet fixed at launch (no destination argument); `dryRun` sends nothing. Answers `LAUNCH_DISABLED` until the server enables agent claims | `POST /api/v1/agent/fees/:mint/claim` | `CANDLE_AGENT_API_KEY` (`launch:write`) |
129
+ | `candle_claim_fees` | Collect one token's creator fees to the wallet fixed at launch (no destination argument); `dryRun` sends nothing | `POST /api/v1/agent/fees/:mint/claim` | `CANDLE_AGENT_API_KEY` (`launch:write`) |
130
130
  | `candle_get_staking` | Read CNDL/sCNDL, withdrawals, rewards, pool wait and wallet eligibility notes | `GET /api/v1/agent/staking` |
131
131
  | `candle_stake` | Stake raw CNDL; stable clientStakeId, dryRun first | `POST /api/v1/agent/staking/stake/build` |
132
132
  | `candle_unstake` | Start a withdrawal of raw sCNDL; no instant unstake | `POST /api/v1/agent/staking/unstake/build` |
@@ -313,8 +313,9 @@ the section and its coverage fields. Agent boards are outside this slice.
313
313
 
314
314
  Solana staking is available through the agent rail while the deployment enables it (off by
315
315
  default). There is no instant unstake. Read the pool wait and wallet eligibility notes first:
316
- pending withdrawal CNDL counts toward nothing for Pro; outside the primary wallet sCNDL earns
317
- Solana rewards but no Hood rewards, and Pro follows the served tier mode.
316
+ pending withdrawal CNDL counts toward nothing for Pro; sCNDL in any eligible account wallet
317
+ counts toward Pro and earns the account's Hood reward, except a wallet another account also
318
+ claims, wallets past the 200-wallet cap, and an account with no EVM wallet.
318
319
 
319
320
  Tools: `candle_get_staking`, `candle_stake`, `candle_unstake`, `candle_finish_unstake`,
320
321
  `candle_claim_staking_reward`. Build tools accept stable `clientStakeId`, raw `amountRaw`
package/dist/index.js CHANGED
@@ -57,6 +57,45 @@ function assertTransportSecurity(apiUrl, env) {
57
57
  return;
58
58
  throw new Error(`Refusing to send credentials in the clear to ${parsed.origin}. Set CANDLE_API_URL to an https:// ` + (isPrivateHost(parsed.hostname) ? "URL, or set CANDLE_ALLOW_INSECURE_HTTP=1 if this really is a trusted local endpoint." : "URL. CANDLE_ALLOW_INSECURE_HTTP does not apply here: it covers private networks only, " + "and this is a public address."));
59
59
  }
60
+ var REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
61
+ var MAX_REDIRECTS = 5;
62
+
63
+ class CrossOriginRedirectError extends Error {
64
+ from;
65
+ to;
66
+ constructor(from, to) {
67
+ super(`The API at ${from} redirected to ${to}, another origin. Not followed, so the API key was not sent there.`);
68
+ this.from = from;
69
+ this.to = to;
70
+ this.name = "CrossOriginRedirectError";
71
+ }
72
+ }
73
+ function keyedRedirectGuard(doFetch) {
74
+ return async (url, init) => {
75
+ if (!init?.headers || !new Headers(init.headers).has("x-api-key"))
76
+ return doFetch(url, init);
77
+ let current = url;
78
+ let next = { ...init, redirect: "manual" };
79
+ for (let hops = 0;; hops++) {
80
+ const response = await doFetch(current, next);
81
+ const headers = response.headers;
82
+ const location = REDIRECT_STATUSES.has(response.status) ? headers?.get?.("location") : null;
83
+ if (!location)
84
+ return response;
85
+ const from = new URL(current);
86
+ const target = new URL(location, from);
87
+ if (target.origin !== from.origin)
88
+ throw new CrossOriginRedirectError(from.origin, target.origin);
89
+ if (hops >= MAX_REDIRECTS)
90
+ throw new Error(`More than ${MAX_REDIRECTS} redirects from ${url}`);
91
+ const method = (next.method ?? "GET").toUpperCase();
92
+ if (response.status === 303 && method !== "GET" && method !== "HEAD" || (response.status === 301 || response.status === 302) && method === "POST") {
93
+ next = { ...next, method: "GET", body: undefined };
94
+ }
95
+ current = target.href;
96
+ }
97
+ };
98
+ }
60
99
  function resolveConfig(env = process.env) {
61
100
  const apiUrl = env.CANDLE_API_URL?.trim() || DEFAULT_API_URL;
62
101
  assertTransportSecurity(apiUrl, env);
@@ -112,7 +151,7 @@ import { lstat, open, realpath } from "node:fs/promises";
112
151
  import { dirname, isAbsolute, relative, sep } from "node:path";
113
152
 
114
153
  // src/version.ts
115
- var SERVER_VERSION = "0.10.16";
154
+ var SERVER_VERSION = "0.10.18";
116
155
 
117
156
  // src/update-notice.ts
118
157
  var PLAIN_VERSION = /^\d+\.\d+\.\d+$/;
@@ -796,6 +835,10 @@ var HYPERLIQUID_EXCHANGE_URLS = {
796
835
  mainnet: "https://api.hyperliquid.xyz/exchange",
797
836
  testnet: "https://api.hyperliquid-testnet.xyz/exchange"
798
837
  };
838
+ var HYPERLIQUID_INFO_URLS = {
839
+ mainnet: "https://api.hyperliquid.xyz/info",
840
+ testnet: "https://api.hyperliquid-testnet.xyz/info"
841
+ };
799
842
  var CANDLE_HYPERLIQUID_BUILDER_ADDRESS = null;
800
843
  var HYPERLIQUID_MAX_BUILDER_FEE_TENTHS_BPS = 100;
801
844
  var HYPERLIQUID_MAX_BUILDER_FEE_RATE = "0.1%";
@@ -1062,6 +1105,12 @@ function checkActionIntent(action, intent) {
1062
1105
  const trigger = leg.t?.trigger;
1063
1106
  if (leg.a !== first.a || leg.b !== !buy || leg.r !== true || decimal(leg.s) !== decimal(p.size) || trigger?.tpsl !== kind || trigger.isMarket !== true || decimal(trigger.triggerPx) !== decimal(p[field]))
1064
1107
  return "trigger side, size, reduce-only flag or price does not match the request";
1108
+ const px = decimal(leg.p) ?? 0n;
1109
+ const triggerPx = decimal(trigger.triggerPx) ?? 0n;
1110
+ const bound = BigInt(HYPERLIQUID_TPSL_SLIPPAGE_BPS + HYPERLIQUID_MID_DRIFT_BPS);
1111
+ const within = leg.b === true ? px * 10000n <= triggerPx * (10000n + bound) : px * 10000n >= triggerPx * (10000n - bound);
1112
+ if (triggerPx <= 0n || !within)
1113
+ return "trigger order price is outside its slippage of the trigger price";
1065
1114
  }
1066
1115
  } else {
1067
1116
  if (list.length !== 1)
@@ -1101,6 +1150,68 @@ async function hyperliquidCloseOrder(fetcher, network, address, params) {
1101
1150
  const size = asked < held ? String(params.size) : String(szi).replace(/^-/, "");
1102
1151
  return { asset, isBuy: signed < 0n, size };
1103
1152
  }
1153
+ var HYPERLIQUID_DEFAULT_SLIPPAGE_BPS = 100;
1154
+ var HYPERLIQUID_MID_DRIFT_BPS = 100;
1155
+ var HYPERLIQUID_TPSL_SLIPPAGE_BPS = 500;
1156
+ async function hyperliquidOrderMarket(fetcher, network, address, method, params) {
1157
+ const read = async (body) => {
1158
+ const res = await fetcher(HYPERLIQUID_INFO_URLS[network], {
1159
+ method: "POST",
1160
+ headers: { "content-type": "application/json" },
1161
+ body: JSON.stringify(body)
1162
+ });
1163
+ if (!res.ok)
1164
+ throw new Error("Cannot read the market from Hyperliquid");
1165
+ return JSON.parse(await res.text());
1166
+ };
1167
+ let coin = String(params.coin ?? "").toUpperCase();
1168
+ let order;
1169
+ if (method === "modify") {
1170
+ const status = await read({ type: "orderStatus", user: address, oid: params.cloid });
1171
+ const resting = status?.order?.order ?? null;
1172
+ if (!resting || typeof resting.coin !== "string" || decimal(resting.limitPx) === null)
1173
+ throw new Error("No resting order with this cloid on Hyperliquid");
1174
+ coin = resting.coin.toUpperCase();
1175
+ order = { isBuy: resting.side === "B", limitPx: String(resting.limitPx) };
1176
+ }
1177
+ const [meta, contexts] = await read({ type: "metaAndAssetCtxs" });
1178
+ const asset = meta.universe.findIndex((m) => m.name.toUpperCase() === coin);
1179
+ if (asset < 0 || asset >= 1e4)
1180
+ throw new Error("No main-universe market for this coin on Hyperliquid");
1181
+ const context = contexts[asset];
1182
+ const midPx = context?.midPx ?? context?.markPx;
1183
+ if ((decimal(midPx) ?? 0n) <= 0n)
1184
+ throw new Error("No Hyperliquid price for this coin");
1185
+ return { asset, midPx: String(midPx), ...order ? { order } : {} };
1186
+ }
1187
+ function checkOrderMarket(action, intent, market) {
1188
+ const list = action.type === "modify" ? [action.order] : action.orders;
1189
+ if (!Array.isArray(list) || list.length === 0)
1190
+ return "missing requested order";
1191
+ if (list.some((o) => !o || o.a !== market.asset))
1192
+ return "order asset is not the requested coin's on Hyperliquid";
1193
+ const first = list[0];
1194
+ const p = intent.params;
1195
+ if (intent.method === "open" && p.price == null) {
1196
+ const requested = p.slippageBps;
1197
+ const slippage = typeof requested === "number" && Number.isInteger(requested) && requested > 0 ? requested : HYPERLIQUID_DEFAULT_SLIPPAGE_BPS;
1198
+ const bound = BigInt(slippage + HYPERLIQUID_MID_DRIFT_BPS);
1199
+ const px = decimal(first.p) ?? 0n;
1200
+ const mid = decimal(market.midPx) ?? 0n;
1201
+ const within = first.b === true ? px * 10000n <= mid * (10000n + bound) : px * 10000n >= mid * (10000n - bound);
1202
+ if (mid <= 0n || !within)
1203
+ return "market order price is outside its slippage of Hyperliquid's mid";
1204
+ }
1205
+ if (intent.method === "modify") {
1206
+ if (!market.order)
1207
+ return "no resting order on Hyperliquid to check the modify against";
1208
+ if (first.b !== market.order.isBuy)
1209
+ return "modify changes the resting order's side";
1210
+ if (p.price == null && decimal(first.p) !== decimal(market.order.limitPx))
1211
+ return "modify changes the resting order's price";
1212
+ }
1213
+ return null;
1214
+ }
1104
1215
  function verifyPerpsBuild(build, opts) {
1105
1216
  try {
1106
1217
  const network = opts.network ?? "mainnet";
@@ -1128,6 +1239,13 @@ function verifyPerpsBuild(build, opts) {
1128
1239
  if (!order || order.a !== opts.closeOrder.asset || order.b !== opts.closeOrder.isBuy || decimal(order.s) !== decimal(opts.closeOrder.size) || order.r !== true)
1129
1240
  return { ok: false, reason: "close side, asset or size does not match the requested position" };
1130
1241
  }
1242
+ if (opts.market) {
1243
+ if (opts.intent?.method !== "open" && opts.intent?.method !== "modify")
1244
+ return { ok: false, reason: "a market check needs an open or modify intent" };
1245
+ const marketRefusal = checkOrderMarket(action, opts.intent, opts.market);
1246
+ if (marketRefusal)
1247
+ return { ok: false, reason: marketRefusal };
1248
+ }
1131
1249
  if (typed.primary_type === "Agent") {
1132
1250
  const type = action.type;
1133
1251
  if (typeof type !== "string" || !HYPERLIQUID_ALLOWED_ACTION_TYPES.includes(type)) {
@@ -1345,6 +1463,18 @@ async function executePerps(tool, args, cfg, env, fetch2) {
1345
1463
  if (!closeCheck.ok)
1346
1464
  return fail("PERPS_BUILD_REFUSED", `Refused to sign this build: ${closeCheck.reason}. Nothing was signed.`);
1347
1465
  }
1466
+ if (method === "open") {
1467
+ const market = await hyperliquidOrderMarket(fetch2, network, row.address, method, fields);
1468
+ const marketCheck = verifyPerpsBuild(build, {
1469
+ builder,
1470
+ network,
1471
+ address: row.address,
1472
+ intent: { method, params: fields },
1473
+ market
1474
+ });
1475
+ if (!marketCheck.ok)
1476
+ return fail("PERPS_BUILD_REFUSED", `Refused to sign this build: ${marketCheck.reason}. Nothing was signed.`);
1477
+ }
1348
1478
  if (!willSubmit || signerPem === null)
1349
1479
  return { text: JSON.stringify({ ...build, signed: false, submitted: false }) };
1350
1480
  if (!appId || !row.privyWalletId)
@@ -1591,7 +1721,7 @@ var PLAN_CAPABILITY_LABELS = {
1591
1721
  createLinkedWallets: "Create linked wallets",
1592
1722
  importLinkedWallets: "Import linked wallets",
1593
1723
  limitOrders: "Limit orders",
1594
- quant: "Quant on Telegram"
1724
+ quant: "Quant deep analyses (Max)"
1595
1725
  };
1596
1726
  var PLAN_LABELS = { free: "Free", believer: "Believer", pro: "Pro", max: "Max" };
1597
1727
  function planLabel(plan) {
@@ -1653,9 +1783,9 @@ function planTableMarkdown(table) {
1653
1783
 
1654
1784
  // src/tools.ts
1655
1785
  var VAMP_CANDIDATES_DESCRIPTION = "Lists Solana tokens that are close to graduating or graduated in the last 6 hours whose ticker other tokens " + "are copying, most-copied in the last 24 hours first. Each entry carries a launch draft (name, ticker, image URL) " + "for `candle_launch_token` or `candle_launch_and_seed`; draft notes identify fields you must supply or adjust. " + "Upload your own image through the agent image upload when the image is unavailable. Use `dryRun: true` first. " + "Counts exclude the candidate itself and cover both Solana and Hood launches at the venues returned in " + "`coverage`, not Candle's own. `coverage.since` is the returned Solana coverage date; Hood history may differ.";
1656
- var CUSTOM_CURVE_RULE = 'mode "exp-custom" with curve { virtualQuote, threshold } launches a Solana curve with your own V (the virtual ' + "quote it starts with) and G (the quote it raises before graduating), in whole SOL or CNDL as decimal strings, " + "accepted when V is 1 to 100 SOL and G 1 to 250 SOL (66,000 to 6,600,000 and 66,000 to 16,500,000 CNDL), when " + "every price up to 20 x G stays under the ceiling (V + 20G)^2 < V x X at this stakerAllocationBps, when neither " + "is finer than the quote's decimals, and when the dev buy is at most 10% of G; a curve past a limit is " + "CURVE_OUT_OF_RANGE, and at the ceiling it carries maxThresholdRaw, the largest G that fits. " + "Dry-run it first (dryRun: true): the dry run spends nothing and returns curveTerms, the market cap at start and " + "at graduation and the supply sold by graduation. Only where the API has EXP_CURVES and EXP_CUSTOM_CURVES on; " + "elsewhere exp-custom is refused as an unknown mode.";
1786
+ var CUSTOM_CURVE_RULE = 'mode "exp-custom" with curve { virtualQuote, threshold } launches a Solana curve with your own V (the virtual ' + "quote it starts with) and G (the quote it raises before graduating), in whole SOL or CNDL as decimal strings, " + "accepted when V is 1 to 100 SOL and G 1 to 250 SOL (66,000 to 6,600,000 and 66,000 to 16,500,000 CNDL), when " + "every price up to 20 x G stays under the ceiling (V + 20G)^2 < V x X at this stakerAllocationBps, when neither " + "is finer than the quote's decimals, and when the dev buy is at most 10% of G; a curve past a limit is " + "CURVE_OUT_OF_RANGE, and at the ceiling it carries maxThresholdRaw, the largest G that fits. " + "Dry-run it first (dryRun: true): the dry run spends nothing and returns curveTerms, the market cap at start and " + "at graduation and the supply sold by graduation. Only where the API has EXP_CUSTOM_CURVES on; " + "elsewhere exp-custom is refused as an unknown mode.";
1657
1787
  var localProfileTools = new WeakMap;
1658
- var STAKING_DISCLOSURE = "No instant unstake. Read pool.withdrawalTimelockSeconds for the wait. Solana rewards accrue to the holding wallet. Outside the account's main wallet, sCNDL earns no Hood staking rewards; Pro eligibility follows the deployment's tier mode (main-wallet mode excludes it). CNDL in a pending withdrawal counts toward nothing for Pro.";
1788
+ var STAKING_DISCLOSURE = "No instant unstake. Read pool.withdrawalTimelockSeconds for the wait. Solana rewards accrue to the holding wallet. sCNDL in any eligible account wallet counts toward Pro and earns the account's Hood staking reward, except a wallet another account also claims, wallets past the 200-wallet cap, and an account with no EVM wallet. CNDL in a pending withdrawal counts toward nothing for Pro.";
1659
1789
  var stakingWriteShape = {
1660
1790
  payer: z2.enum(["main", "linked"]).optional().describe("main is the embedded wallet; linked names a linked or TEE wallet"),
1661
1791
  linkedWalletId: z2.string().min(1).optional(),
@@ -2234,7 +2364,7 @@ function registerToolSubset(server, options) {
2234
2364
  const allowed = new Set(options.tools);
2235
2365
  const getConfig = options.getConfig;
2236
2366
  const env = options.env ?? {};
2237
- const doFetch = options.fetch ?? ((url, init) => fetch(url, init));
2367
+ const doFetch = keyedRedirectGuard(options.fetch ?? ((url, init) => fetch(url, init)));
2238
2368
  const register = (name, definition, handler) => {
2239
2369
  if (!allowed.has(name))
2240
2370
  return;
@@ -2395,7 +2525,7 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
2395
2525
  }, async (args) => callAndRelay("candle_get_fees", args, getConfig(), doFetch));
2396
2526
  register("candle_claim_fees", {
2397
2527
  title: "Claim a token's creator fees",
2398
- description: "Collect one token's creator fees and pay them to the wallet fixed at launch. There is no destination " + "argument and the tool cannot send funds anywhere else. Candle signs with the embedded wallet, which " + "pays the network fee. Needs launch:write, and the server keeps it off until it is enabled (LAUNCH_DISABLED). " + "Call it with dryRun: true first; a real claim moves real funds into the recipient wallet, which may be " + "the wallet this agent trades from. Not idempotent by id: if the result is lost, read candle_get_fees " + "before claiming again.",
2528
+ description: "Collect one token's creator fees and pay them to the wallet fixed at launch. There is no destination " + "argument and the tool cannot send funds anywhere else. Candle signs with the embedded wallet, which " + "pays the network fee. Needs launch:write. " + "Call it with dryRun: true first; a real claim moves real funds into the recipient wallet, which may be " + "the wallet this agent trades from. Not idempotent by id: if the result is lost, read candle_get_fees " + "before claiming again.",
2399
2529
  inputSchema: claimFeesShape
2400
2530
  }, async (args) => callAndRelay("candle_claim_fees", args, getConfig(), doFetch));
2401
2531
  register("candle_get_wallets", {
@@ -2561,7 +2691,7 @@ async function registerEligibleProfileTool(server, env = process.env, doFetch =
2561
2691
  let eligible = false;
2562
2692
  if (cfg.apiKey && wanted.length > 0) {
2563
2693
  try {
2564
- const res = await doFetch(`${cfg.apiUrl}/api/v1/agent/keys/self/public-profile`, {
2694
+ const res = await keyedRedirectGuard(doFetch)(`${cfg.apiUrl}/api/v1/agent/keys/self/public-profile`, {
2565
2695
  method: "GET",
2566
2696
  headers: { "x-api-key": cfg.apiKey },
2567
2697
  signal: AbortSignal.timeout(15000)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@candledottv/mcp",
3
- "version": "0.10.16",
3
+ "version": "0.10.18",
4
4
  "description": "MCP server for the Candle agent rail: launch tokens, read markets, report activity",
5
5
  "type": "module",
6
6
  "license": "MIT",