@candledottv/mcp 0.8.0 → 0.9.1
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.
- package/dist/index.js +90 -32
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -106,7 +106,7 @@ function percentOfBalance(balanceRaw, percent) {
|
|
|
106
106
|
import { randomUUID } from "node:crypto";
|
|
107
107
|
|
|
108
108
|
// src/version.ts
|
|
109
|
-
var SERVER_VERSION = "0.
|
|
109
|
+
var SERVER_VERSION = "0.9.1";
|
|
110
110
|
|
|
111
111
|
// src/update-notice.ts
|
|
112
112
|
var PLAIN_VERSION = /^\d+\.\d+\.\d+$/;
|
|
@@ -224,6 +224,23 @@ async function readMarket(mint, cfg, doFetch, extra) {
|
|
|
224
224
|
return { status: res.status, err: relayRead(text, extra) };
|
|
225
225
|
return { market: JSON.parse(text).market ?? {} };
|
|
226
226
|
}
|
|
227
|
+
function isPaperFlag(value) {
|
|
228
|
+
return value === true || value === "true" || value === 1 || value === "1";
|
|
229
|
+
}
|
|
230
|
+
function heldPaperPosition(positions, mint) {
|
|
231
|
+
return positions.find((p) => p.mint === mint && p.amountRaw !== "0");
|
|
232
|
+
}
|
|
233
|
+
async function readPaperInventory(cfg, doFetch, extra) {
|
|
234
|
+
const res = await doFetch(`${base(cfg)}/api/v1/trade/agent/paper/inventory`, {
|
|
235
|
+
method: "GET",
|
|
236
|
+
headers: headers(cfg.apiKey)
|
|
237
|
+
});
|
|
238
|
+
const text = await res.text();
|
|
239
|
+
if (!res.ok)
|
|
240
|
+
return { err: relayRead(text, extra) };
|
|
241
|
+
const body = JSON.parse(text);
|
|
242
|
+
return { positions: body.positions ?? [] };
|
|
243
|
+
}
|
|
227
244
|
async function executeTrade(args, cfg, doFetch) {
|
|
228
245
|
const apiKey = requireApiKey(cfg);
|
|
229
246
|
const clientTradeId = args.clientTradeId ?? randomUUID();
|
|
@@ -237,7 +254,8 @@ async function executeTrade(args, cfg, doFetch) {
|
|
|
237
254
|
return errText("percent is only valid on sells; buys take a quote-asset amount", { clientTradeId });
|
|
238
255
|
}
|
|
239
256
|
let amountRaw;
|
|
240
|
-
let resolved;
|
|
257
|
+
let resolved = {};
|
|
258
|
+
let paper = isPaperFlag(args.paper);
|
|
241
259
|
try {
|
|
242
260
|
if (args.side === "buy") {
|
|
243
261
|
const amount = args.amount;
|
|
@@ -264,17 +282,36 @@ async function executeTrade(args, cfg, doFetch) {
|
|
|
264
282
|
resolved = { amountDecimal: amount, decimals, amountRaw };
|
|
265
283
|
} else if (args.amount !== undefined) {
|
|
266
284
|
const read = await readMarket(args.mint, cfg, doFetch, { clientTradeId });
|
|
267
|
-
|
|
285
|
+
let decimals;
|
|
286
|
+
if ("market" in read) {
|
|
287
|
+
if (typeof read.market.decimals !== "number") {
|
|
288
|
+
return errText(`could not resolve decimals for mint ${args.mint}; pass a raw-ready amount via the SDK instead`, { clientTradeId });
|
|
289
|
+
}
|
|
290
|
+
decimals = read.market.decimals;
|
|
291
|
+
} else if (read.status === 404) {
|
|
292
|
+
const inventory = await readPaperInventory(cfg, doFetch, { clientTradeId });
|
|
293
|
+
if ("err" in inventory) {
|
|
294
|
+
return paper ? inventory.err : read.err;
|
|
295
|
+
}
|
|
296
|
+
const held = heldPaperPosition(inventory.positions, args.mint);
|
|
297
|
+
if (typeof held?.tokenDecimals !== "number") {
|
|
298
|
+
if (paper) {
|
|
299
|
+
return errText(`could not resolve decimals for mint ${args.mint}; no Candle market and no paper position with a known scale. Buy it in paper first, or pass a raw amount via the SDK`, { clientTradeId });
|
|
300
|
+
}
|
|
301
|
+
return read.err;
|
|
302
|
+
}
|
|
303
|
+
decimals = held.tokenDecimals;
|
|
304
|
+
paper = true;
|
|
305
|
+
} else {
|
|
268
306
|
return read.err;
|
|
269
|
-
const decimals = read.market.decimals;
|
|
270
|
-
if (typeof decimals !== "number") {
|
|
271
|
-
return errText(`could not resolve decimals for mint ${args.mint}; pass a raw-ready amount via the SDK instead`, { clientTradeId });
|
|
272
307
|
}
|
|
273
308
|
const converted = rawOrError(args.amount, decimals, { clientTradeId });
|
|
274
309
|
if ("err" in converted)
|
|
275
310
|
return converted.err;
|
|
276
311
|
amountRaw = converted.raw;
|
|
277
312
|
resolved = { amountDecimal: args.amount, decimals, amountRaw };
|
|
313
|
+
} else if (paper) {
|
|
314
|
+
resolved = { percent: args.percent };
|
|
278
315
|
} else {
|
|
279
316
|
const percent = args.percent;
|
|
280
317
|
const walletsRes = await doFetch(`${base(cfg)}/api/v1/agent/wallets/embedded`, {
|
|
@@ -287,23 +324,37 @@ async function executeTrade(args, cfg, doFetch) {
|
|
|
287
324
|
const walletsBody = JSON.parse(walletsText);
|
|
288
325
|
const chain = chainForMint(args.mint);
|
|
289
326
|
const address = chain === "hood" ? walletsBody.wallets?.evm?.address : walletsBody.wallets?.solana?.address;
|
|
327
|
+
let walletEmpty;
|
|
290
328
|
if (!address) {
|
|
291
|
-
|
|
329
|
+
walletEmpty = errText(`percent sells need an embedded ${chain === "hood" ? "EVM" : "Solana"} wallet, and this account has none`, { clientTradeId });
|
|
330
|
+
} else {
|
|
331
|
+
const balRes = await doFetch(`${base(cfg)}/api/v1/tokens/${encodeURIComponent(args.mint)}/balance/${encodeURIComponent(address)}`, {
|
|
332
|
+
method: "GET",
|
|
333
|
+
headers: headers()
|
|
334
|
+
});
|
|
335
|
+
const balText = await balRes.text();
|
|
336
|
+
if (!balRes.ok)
|
|
337
|
+
return relayRead(balText, { clientTradeId });
|
|
338
|
+
const balBody = JSON.parse(balText);
|
|
339
|
+
const balance = balBody.payload?.balance;
|
|
340
|
+
if (balance) {
|
|
341
|
+
amountRaw = percentOfBalance(balance, percent);
|
|
342
|
+
resolved = { percent, balanceRaw: balance, amountRaw };
|
|
343
|
+
} else {
|
|
344
|
+
walletEmpty = errText(`the embedded wallet ${address} holds no ${args.mint}; nothing to sell`, {
|
|
345
|
+
clientTradeId
|
|
346
|
+
});
|
|
347
|
+
}
|
|
292
348
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const balance = balBody.payload?.balance;
|
|
302
|
-
if (!balance) {
|
|
303
|
-
return errText(`the embedded wallet ${address} holds no ${args.mint}; nothing to sell`, { clientTradeId });
|
|
349
|
+
if (walletEmpty) {
|
|
350
|
+
const inventory = await readPaperInventory(cfg, doFetch, { clientTradeId });
|
|
351
|
+
if (!("err" in inventory) && heldPaperPosition(inventory.positions, args.mint)) {
|
|
352
|
+
paper = true;
|
|
353
|
+
resolved = { percent };
|
|
354
|
+
} else {
|
|
355
|
+
return walletEmpty;
|
|
356
|
+
}
|
|
304
357
|
}
|
|
305
|
-
amountRaw = percentOfBalance(balance, percent);
|
|
306
|
-
resolved = { percent, balanceRaw: balance, amountRaw };
|
|
307
358
|
}
|
|
308
359
|
} catch (err) {
|
|
309
360
|
return errText(err instanceof Error ? err.message : String(err), { clientTradeId });
|
|
@@ -312,10 +363,11 @@ async function executeTrade(args, cfg, doFetch) {
|
|
|
312
363
|
clientTradeId,
|
|
313
364
|
mint: args.mint,
|
|
314
365
|
side: args.side,
|
|
315
|
-
amountRaw,
|
|
366
|
+
...amountRaw !== undefined ? { amountRaw } : { percent: args.percent },
|
|
316
367
|
payer: { type: "main" },
|
|
317
368
|
...args.quoteAsset !== undefined ? { quoteAsset: args.quoteAsset } : {},
|
|
318
|
-
...args.maxSlippageBps !== undefined ? { maxSlippageBps: args.maxSlippageBps } : {}
|
|
369
|
+
...args.maxSlippageBps !== undefined ? { maxSlippageBps: args.maxSlippageBps } : {},
|
|
370
|
+
...paper ? { paper: true } : {}
|
|
319
371
|
}, doFetch);
|
|
320
372
|
if ("thrown" in posted)
|
|
321
373
|
return transportError("clientTradeId", clientTradeId, posted.thrown);
|
|
@@ -749,10 +801,11 @@ var tradeShape = {
|
|
|
749
801
|
mint: z.string().describe("Token mint (solana) or contract address (hood)"),
|
|
750
802
|
side: z.enum(["buy", "sell"]),
|
|
751
803
|
amount: z.string().optional().describe("Decimal amount. Buys: how much of THIS TOKEN'S OWN quote asset to spend (SOL for a " + 'SOL-launched token, USDC for a USDC-quoted one, and so on: e.g. "0.5"). Sells: how many ' + "TOKENS to sell. Pass exactly one of amount or percent."),
|
|
752
|
-
percent: z.number().optional().describe("Sells only: sell this percent (integer 1-100) of the wallet's
|
|
804
|
+
percent: z.number().optional().describe("Sells only: sell this percent (integer 1-100) of the holding. Live trades size against the " + "embedded wallet. Paper trades (`paper: true`) size against this key's paper inventory -- " + "the position a previous paper buy credited -- because paper never moves the live wallet."),
|
|
753
805
|
quoteAsset: z.string().optional().describe('What the wallet spends on a buy or receives on a sell: "sol", "usdc" or "cndl" on Solana, ' + '"eth" or "usdg" on Hood. Safe to pass through from candle_quote. On Solana it applies only ' + "to an arbitrary mint Candle never launched (Pro/Max) and is ignored for a Candle token, " + "whose quote comes from the token itself. On Hood it is the settlement asset of a DEX " + "trade; a USDG buy adds an approval transaction an ETH buy does not. It is not the route: " + "the cheapest path to the asset is chosen separately. Defaults to sol / ETH settlement."),
|
|
754
806
|
maxSlippageBps: z.number().optional().describe("Max slippage in basis points; API default applies when omitted"),
|
|
755
|
-
clientTradeId: z.string().optional().describe("Idempotency key. Auto-generated when omitted and echoed in the result. Retrying with the " + "SAME id is safe (idempotent replay); a new id is a SECOND trade.")
|
|
807
|
+
clientTradeId: z.string().optional().describe("Idempotency key. Auto-generated when omitted and echoed in the result. Retrying with the " + "SAME id is safe (idempotent replay); a new id is a SECOND trade."),
|
|
808
|
+
paper: z.preprocess((value) => value === "true" || value === 1 || value === "1" ? true : value === "false" || value === 0 || value === "0" ? false : value, z.boolean().optional().describe("Rehearse instead of trading. The request passes every admission rule a live trade passes " + "-- the same planner, spend gate, key cap and loss limits -- and records the quote, but " + "nothing is ever broadcast and no funds move. Use it to check that a strategy is admitted " + "before risking anything on it. A paper fill is optimistic by construction: it books the " + "quoted price, so the gap between a paper arm and a live one IS the execution cost. " + "A sell of a mint this key already paper-bought also closes that paper book when the " + "live wallet is empty, even if this flag is omitted."))
|
|
756
809
|
};
|
|
757
810
|
var { buyAmount: _rawBuyAmount, ...seedableLaunchShape } = launchTokenShape;
|
|
758
811
|
var launchAndSeedShape = {
|
|
@@ -795,9 +848,9 @@ function registerTools(server, env = process.env) {
|
|
|
795
848
|
}, async (args) => callAndRelay("candle_get_market", args, cfg));
|
|
796
849
|
register("candle_token_forensics", {
|
|
797
850
|
title: "Token forensics",
|
|
798
|
-
description: `Gate a buy before making it:
|
|
851
|
+
description: `Gate a buy before making it: who launched it (resolved on-chain; pump.fun's shared updateAuthority is never the developer), their went-to-zero rate and last coins, who bought in the deploy window (the creator's own wallets are marked disclosed; strangers in the same slot are the bundle signal), holder concentration, same-funder insider share, same-funder deployer cluster, and a risk tier (LOW/MODERATE/HIGH/CRITICAL) with per-factor reasons. Every measurement carries a coverage note -- 'unavailable' is not 'clean'. No key needed.
|
|
799
852
|
|
|
800
|
-
MARKET_NOT_FOUND means Candle has no market for that token and this could not run. That is also not 'clean': report that you could not check it, rather than reporting the token as safe. That refusal now carries error.coverage -- covered:false, a reason ('external_launchpad' when the token launched somewhere else, 'unknown_mint' when nobody has indexed it), the launchpad when known, and every check that consequently did not run. Read it instead of guessing. Most of the feed answers
|
|
853
|
+
MARKET_NOT_FOUND means Candle has no market for that token and this could not run. That is also not 'clean': report that you could not check it, rather than reporting the token as safe. That refusal now carries error.coverage -- covered:false, a reason ('external_launchpad' when the token launched somewhere else, 'unknown_mint' when nobody has indexed it), the launchpad when known, and every check that consequently did not run. Read it instead of guessing. Most of the feed now answers with a partial report instead.`,
|
|
801
854
|
inputSchema: tokenForensicsShape
|
|
802
855
|
}, async (args) => callAndRelay("candle_token_forensics", args, cfg));
|
|
803
856
|
register("candle_get_feed", {
|
|
@@ -911,6 +964,8 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
|
|
|
911
964
|
|
|
912
965
|
` + "Arguments: `mint` and `side` are required. Amounts are DECIMAL, never raw base units " + '(amount: "0.5", not lamports). Omitting the amount on a sell sells the whole ' + `position.
|
|
913
966
|
|
|
967
|
+
` + "Pass `paper: true` to rehearse: every admission rule runs and the quote is recorded, but " + "nothing broadcasts and no funds move. A paper buy credits this key's paper inventory, " + "including for external Solana mints routed through Jupiter. A later sell by amount or " + "percent closes that book without reading the live wallet and without MARKET_NOT_FOUND " + "-- including when `paper` is omitted on the exit, as long as the paper position exists. " + "Do this before the first live trade of a new strategy, and whenever you are unsure a " + `trade would be admitted at all.
|
|
968
|
+
|
|
914
969
|
` + `After the call:
|
|
915
970
|
` + "- A timeout is not a failure. Retry with the SAME clientTradeId from the result -- it " + `coalesces the duplicate. A NEW id is a SECOND trade, and that is how you double-spend.
|
|
916
971
|
` + "- If you no longer hold the result, do not re-send to find out what happened. Ask " + "candle_get_operation with the clientTradeId; a 404 there means the trade never reached " + "the rail and nothing moved.",
|
|
@@ -936,16 +991,19 @@ START HERE — five tools need NO credential. Call these first to confirm the se
|
|
|
936
991
|
candle_get_market price, market cap, volume, curve state for one token
|
|
937
992
|
candle_get_feed the roster: hot streak, new pairs, graduated, blue chip
|
|
938
993
|
candle_resolve_token a ticker or partial name -> mint address + chain
|
|
939
|
-
candle_token_forensics call this before quoting or buying
|
|
994
|
+
candle_token_forensics call this before quoting or buying. Returns the on-chain developer (never a launchpad shared authority), their went-to-zero rate and last coins, who bought in the deploy window (strangers in the same slot are the bundle signal), holder concentration, same-funder insider share, same-funder cluster, and a risk tier LOW/MODERATE/HIGH/CRITICAL with per-factor reasons
|
|
940
995
|
candle_get_agent_profile your own tier, caps and verified activity
|
|
941
996
|
|
|
942
997
|
COVERAGE — read this before you treat an error as a broken server.
|
|
943
998
|
candle_get_feed indexes the wider market (pump.fun, pons.family and other external launchpads).
|
|
944
|
-
candle_get_market
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
999
|
+
candle_get_market answers for tokens that have a CANDLE market. candle_token_forensics also
|
|
1000
|
+
answers for Solana tokens the feed already knows, with a partial report: on-chain developer
|
|
1001
|
+
(never a launchpad shared authority), went-to-zero record, holder concentration, same-funder
|
|
1002
|
+
insiders and cluster. Deploy-window stays unavailable without a Candle launch record. Hood
|
|
1003
|
+
tokens Candle did not launch, and unknown mints, still come back MARKET_NOT_FOUND. That is a
|
|
1004
|
+
coverage boundary, not a fault and not a reason to retry, re-auth, or tell the human the
|
|
1005
|
+
integration is down. Report MARKET_NOT_FOUND as "Candle has no market for this token, so I
|
|
1006
|
+
could not run forensics on it" and let the human decide.
|
|
949
1007
|
|
|
950
1008
|
Never let a MARKET_NOT_FOUND stand in for a clean bill of health. The same rule governs the
|
|
951
1009
|
coverage note on every forensics measurement: "unavailable" is NOT "clean" — say so rather than
|