@candledottv/mcp 0.10.16 → 0.10.17

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 +130 -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.17";
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%";
@@ -1101,6 +1144,67 @@ async function hyperliquidCloseOrder(fetcher, network, address, params) {
1101
1144
  const size = asked < held ? String(params.size) : String(szi).replace(/^-/, "");
1102
1145
  return { asset, isBuy: signed < 0n, size };
1103
1146
  }
1147
+ var HYPERLIQUID_DEFAULT_SLIPPAGE_BPS = 100;
1148
+ var HYPERLIQUID_MID_DRIFT_BPS = 100;
1149
+ async function hyperliquidOrderMarket(fetcher, network, address, method, params) {
1150
+ const read = async (body) => {
1151
+ const res = await fetcher(HYPERLIQUID_INFO_URLS[network], {
1152
+ method: "POST",
1153
+ headers: { "content-type": "application/json" },
1154
+ body: JSON.stringify(body)
1155
+ });
1156
+ if (!res.ok)
1157
+ throw new Error("Cannot read the market from Hyperliquid");
1158
+ return JSON.parse(await res.text());
1159
+ };
1160
+ let coin = String(params.coin ?? "").toUpperCase();
1161
+ let order;
1162
+ if (method === "modify") {
1163
+ const status = await read({ type: "orderStatus", user: address, oid: params.cloid });
1164
+ const resting = status?.order?.order ?? null;
1165
+ if (!resting || typeof resting.coin !== "string" || decimal(resting.limitPx) === null)
1166
+ throw new Error("No resting order with this cloid on Hyperliquid");
1167
+ coin = resting.coin.toUpperCase();
1168
+ order = { isBuy: resting.side === "B", limitPx: String(resting.limitPx) };
1169
+ }
1170
+ const [meta, contexts] = await read({ type: "metaAndAssetCtxs" });
1171
+ const asset = meta.universe.findIndex((m) => m.name.toUpperCase() === coin);
1172
+ if (asset < 0 || asset >= 1e4)
1173
+ throw new Error("No main-universe market for this coin on Hyperliquid");
1174
+ const context = contexts[asset];
1175
+ const midPx = context?.midPx ?? context?.markPx;
1176
+ if ((decimal(midPx) ?? 0n) <= 0n)
1177
+ throw new Error("No Hyperliquid price for this coin");
1178
+ return { asset, midPx: String(midPx), ...order ? { order } : {} };
1179
+ }
1180
+ function checkOrderMarket(action, intent, market) {
1181
+ const list = action.type === "modify" ? [action.order] : action.orders;
1182
+ if (!Array.isArray(list) || list.length === 0)
1183
+ return "missing requested order";
1184
+ if (list.some((o) => !o || o.a !== market.asset))
1185
+ return "order asset is not the requested coin's on Hyperliquid";
1186
+ const first = list[0];
1187
+ const p = intent.params;
1188
+ if (intent.method === "open" && p.price == null) {
1189
+ const requested = p.slippageBps;
1190
+ const slippage = typeof requested === "number" && Number.isInteger(requested) && requested > 0 ? requested : HYPERLIQUID_DEFAULT_SLIPPAGE_BPS;
1191
+ const bound = BigInt(slippage + HYPERLIQUID_MID_DRIFT_BPS);
1192
+ const px = decimal(first.p) ?? 0n;
1193
+ const mid = decimal(market.midPx) ?? 0n;
1194
+ const within = first.b === true ? px * 10000n <= mid * (10000n + bound) : px * 10000n >= mid * (10000n - bound);
1195
+ if (mid <= 0n || !within)
1196
+ return "market order price is outside its slippage of Hyperliquid's mid";
1197
+ }
1198
+ if (intent.method === "modify") {
1199
+ if (!market.order)
1200
+ return "no resting order on Hyperliquid to check the modify against";
1201
+ if (first.b !== market.order.isBuy)
1202
+ return "modify changes the resting order's side";
1203
+ if (p.price == null && decimal(first.p) !== decimal(market.order.limitPx))
1204
+ return "modify changes the resting order's price";
1205
+ }
1206
+ return null;
1207
+ }
1104
1208
  function verifyPerpsBuild(build, opts) {
1105
1209
  try {
1106
1210
  const network = opts.network ?? "mainnet";
@@ -1128,6 +1232,13 @@ function verifyPerpsBuild(build, opts) {
1128
1232
  if (!order || order.a !== opts.closeOrder.asset || order.b !== opts.closeOrder.isBuy || decimal(order.s) !== decimal(opts.closeOrder.size) || order.r !== true)
1129
1233
  return { ok: false, reason: "close side, asset or size does not match the requested position" };
1130
1234
  }
1235
+ if (opts.market) {
1236
+ if (opts.intent?.method !== "open" && opts.intent?.method !== "modify")
1237
+ return { ok: false, reason: "a market check needs an open or modify intent" };
1238
+ const marketRefusal = checkOrderMarket(action, opts.intent, opts.market);
1239
+ if (marketRefusal)
1240
+ return { ok: false, reason: marketRefusal };
1241
+ }
1131
1242
  if (typed.primary_type === "Agent") {
1132
1243
  const type = action.type;
1133
1244
  if (typeof type !== "string" || !HYPERLIQUID_ALLOWED_ACTION_TYPES.includes(type)) {
@@ -1345,6 +1456,18 @@ async function executePerps(tool, args, cfg, env, fetch2) {
1345
1456
  if (!closeCheck.ok)
1346
1457
  return fail("PERPS_BUILD_REFUSED", `Refused to sign this build: ${closeCheck.reason}. Nothing was signed.`);
1347
1458
  }
1459
+ if (method === "open") {
1460
+ const market = await hyperliquidOrderMarket(fetch2, network, row.address, method, fields);
1461
+ const marketCheck = verifyPerpsBuild(build, {
1462
+ builder,
1463
+ network,
1464
+ address: row.address,
1465
+ intent: { method, params: fields },
1466
+ market
1467
+ });
1468
+ if (!marketCheck.ok)
1469
+ return fail("PERPS_BUILD_REFUSED", `Refused to sign this build: ${marketCheck.reason}. Nothing was signed.`);
1470
+ }
1348
1471
  if (!willSubmit || signerPem === null)
1349
1472
  return { text: JSON.stringify({ ...build, signed: false, submitted: false }) };
1350
1473
  if (!appId || !row.privyWalletId)
@@ -1591,7 +1714,7 @@ var PLAN_CAPABILITY_LABELS = {
1591
1714
  createLinkedWallets: "Create linked wallets",
1592
1715
  importLinkedWallets: "Import linked wallets",
1593
1716
  limitOrders: "Limit orders",
1594
- quant: "Quant on Telegram"
1717
+ quant: "Quant deep analyses (Max)"
1595
1718
  };
1596
1719
  var PLAN_LABELS = { free: "Free", believer: "Believer", pro: "Pro", max: "Max" };
1597
1720
  function planLabel(plan) {
@@ -1653,9 +1776,9 @@ function planTableMarkdown(table) {
1653
1776
 
1654
1777
  // src/tools.ts
1655
1778
  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.";
1779
+ 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
1780
  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.";
1781
+ 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
1782
  var stakingWriteShape = {
1660
1783
  payer: z2.enum(["main", "linked"]).optional().describe("main is the embedded wallet; linked names a linked or TEE wallet"),
1661
1784
  linkedWalletId: z2.string().min(1).optional(),
@@ -2234,7 +2357,7 @@ function registerToolSubset(server, options) {
2234
2357
  const allowed = new Set(options.tools);
2235
2358
  const getConfig = options.getConfig;
2236
2359
  const env = options.env ?? {};
2237
- const doFetch = options.fetch ?? ((url, init) => fetch(url, init));
2360
+ const doFetch = keyedRedirectGuard(options.fetch ?? ((url, init) => fetch(url, init)));
2238
2361
  const register = (name, definition, handler) => {
2239
2362
  if (!allowed.has(name))
2240
2363
  return;
@@ -2395,7 +2518,7 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
2395
2518
  }, async (args) => callAndRelay("candle_get_fees", args, getConfig(), doFetch));
2396
2519
  register("candle_claim_fees", {
2397
2520
  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.",
2521
+ 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
2522
  inputSchema: claimFeesShape
2400
2523
  }, async (args) => callAndRelay("candle_claim_fees", args, getConfig(), doFetch));
2401
2524
  register("candle_get_wallets", {
@@ -2561,7 +2684,7 @@ async function registerEligibleProfileTool(server, env = process.env, doFetch =
2561
2684
  let eligible = false;
2562
2685
  if (cfg.apiKey && wanted.length > 0) {
2563
2686
  try {
2564
- const res = await doFetch(`${cfg.apiUrl}/api/v1/agent/keys/self/public-profile`, {
2687
+ const res = await keyedRedirectGuard(doFetch)(`${cfg.apiUrl}/api/v1/agent/keys/self/public-profile`, {
2565
2688
  method: "GET",
2566
2689
  headers: { "x-api-key": cfg.apiKey },
2567
2690
  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.17",
4
4
  "description": "MCP server for the Candle agent rail: launch tokens, read markets, report activity",
5
5
  "type": "module",
6
6
  "license": "MIT",