@candledottv/mcp 0.5.0 → 0.6.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/README.md +14 -10
- package/dist/index.js +135 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# @candledottv/mcp
|
|
2
2
|
|
|
3
3
|
An MCP (Model Context Protocol) server for the Candle agent rail. It exposes Candle's REST API as
|
|
4
|
-
|
|
4
|
+
fifteen tools over stdio, so an MCP-capable agent can launch tokens (optionally seeded with a dev
|
|
5
5
|
buy in the same call), trade, convert between base assets (including across chains), read market
|
|
6
6
|
and feed data, report on-chain activity, and check an agent profile without hand-rolling HTTP
|
|
7
7
|
calls.
|
|
8
8
|
|
|
9
9
|
## Try it without a key
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
production): `candle_get_market`, `candle_get_feed`, `candle_token_forensics`,
|
|
13
|
-
`candle_get_agent_profile`. Install the
|
|
14
|
-
server with no `env` block at all and those
|
|
11
|
+
Five tools are read-only and need nothing but `CANDLE_API_URL` (which already defaults to
|
|
12
|
+
production): `candle_get_market`, `candle_get_feed`, `candle_token_forensics`,
|
|
13
|
+
`candle_get_agent_profile`, and `candle_resolve_token`. Install the
|
|
14
|
+
server with no `env` block at all and those five work immediately:
|
|
15
15
|
|
|
16
16
|
```json
|
|
17
17
|
{
|
|
@@ -31,7 +31,7 @@ Environment below -- or skip env editing entirely: install the Candle CLI
|
|
|
31
31
|
environment.
|
|
32
32
|
|
|
33
33
|
`CANDLE_MCP_TOOLS` (optional) is a comma-separated allowlist of tool names; only those register.
|
|
34
|
-
Unset means all
|
|
34
|
+
Unset means all fifteen. An unknown name fails startup with the valid names in the message, rather
|
|
35
35
|
than silently registering the wrong surface. `candle mcp --read-only` / `--tools` set this for
|
|
36
36
|
you.
|
|
37
37
|
|
|
@@ -68,9 +68,9 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
|
|
|
68
68
|
non-loopback host. For a trusted local endpoint that is not loopback, such as a devcontainer
|
|
69
69
|
reaching its host; not for anything that leaves the machine.
|
|
70
70
|
- `CANDLE_AGENT_API_KEY` -- an agent API key (`cndl_live_...` / `cndl_test_...`), issued from a
|
|
71
|
-
Candle account's agent settings page.
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
Candle account's agent settings page. Required by every tool except the five read-only ones
|
|
72
|
+
listed above, which includes the account-scoped reads (`candle_get_wallets`,
|
|
73
|
+
`candle_execution_status`, `candle_get_operation`) as well as the writes; those five
|
|
74
74
|
work without it, so the server is useful the moment it is installed and only asks for a key when
|
|
75
75
|
you try to write. `candle_trade` additionally needs the key's `swap:write` scope server-side,
|
|
76
76
|
which is opt-in only and never granted by omission, see `docs/mcp-launch-and-seed.md` in the
|
|
@@ -93,6 +93,10 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
|
|
|
93
93
|
| `candle_swap` | Swap between base assets | `POST /api/v1/agent/swap` | `CANDLE_AGENT_API_KEY` (`swap:write`) |
|
|
94
94
|
| `candle_transfer` | Transfer an asset | `POST /api/v1/agent/transfer` | `CANDLE_AGENT_API_KEY` (`transfer:write`) |
|
|
95
95
|
| `candle_sweep` | Sweep a wallet to one destination | One `POST /api/v1/agent/transfer` per asset, `amountRaw: "max"` | `CANDLE_AGENT_API_KEY` (`transfer:write`) |
|
|
96
|
+
| `candle_resolve_token` | Turn a bare mint or contract address into Candle's market for it | `GET /api/v1/markets/:chain/:mint` | none |
|
|
97
|
+
| `candle_get_wallets` | The account's embedded wallets, one per chain, with delegation state | `GET /api/v1/agent/wallets/embedded` | `CANDLE_AGENT_API_KEY` |
|
|
98
|
+
| `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` |
|
|
99
|
+
| `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` |
|
|
96
100
|
|
|
97
101
|
### Transfers and sweeps
|
|
98
102
|
|
|
@@ -109,7 +113,7 @@ explicitly. Assets with nothing spendable report `empty`; a failed asset never s
|
|
|
109
113
|
## Errors
|
|
110
114
|
|
|
111
115
|
This package never reinterprets an error body, and that body is not one uniform shape across all
|
|
112
|
-
|
|
116
|
+
fifteen tools:
|
|
113
117
|
|
|
114
118
|
- `candle_launch_token`, `candle_get_market`, and `candle_get_feed` hit endpoints that use the
|
|
115
119
|
structured envelope `{ success: false, error: { code, message, ... } }`. Branch on `error.code`.
|
package/dist/index.js
CHANGED
|
@@ -42,9 +42,6 @@ function resolveConfig(env = process.env) {
|
|
|
42
42
|
return apiKey ? { apiUrl, apiKey } : { apiUrl };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// src/orchestrate.ts
|
|
46
|
-
import { randomUUID } from "node:crypto";
|
|
47
|
-
|
|
48
45
|
// src/convert.ts
|
|
49
46
|
var QUOTE_DECIMALS = {
|
|
50
47
|
sol: 9,
|
|
@@ -88,6 +85,7 @@ function percentOfBalance(balanceRaw, percent) {
|
|
|
88
85
|
}
|
|
89
86
|
|
|
90
87
|
// src/orchestrate.ts
|
|
88
|
+
import { randomUUID } from "node:crypto";
|
|
91
89
|
function requireApiKey(cfg) {
|
|
92
90
|
if (!cfg.apiKey) {
|
|
93
91
|
throw new Error("CANDLE_AGENT_API_KEY is required for this tool. Set it in the environment or MCP client config.");
|
|
@@ -385,6 +383,48 @@ async function executeSweep(args, cfg, doFetch) {
|
|
|
385
383
|
...transferred === 0 && failed > 0 ? { isError: true } : {}
|
|
386
384
|
};
|
|
387
385
|
}
|
|
386
|
+
async function resolveToken(args, cfg, doFetch) {
|
|
387
|
+
const chain = chainForMint(args.mint);
|
|
388
|
+
const read = await readMarket(args.mint, cfg, doFetch, { mint: args.mint, chain });
|
|
389
|
+
if ("err" in read)
|
|
390
|
+
return read.err;
|
|
391
|
+
return { text: JSON.stringify({ success: true, chain, mint: args.mint, market: read.market }, null, 2) };
|
|
392
|
+
}
|
|
393
|
+
async function executionStatus(cfg, doFetch) {
|
|
394
|
+
const apiKey = requireApiKey(cfg);
|
|
395
|
+
const get = async (path) => {
|
|
396
|
+
const res = await doFetch(`${base(cfg)}${path}`, { method: "GET", headers: headers(apiKey) });
|
|
397
|
+
const text = await res.text();
|
|
398
|
+
let body;
|
|
399
|
+
try {
|
|
400
|
+
body = JSON.parse(text);
|
|
401
|
+
} catch {
|
|
402
|
+
body = text;
|
|
403
|
+
}
|
|
404
|
+
return { ok: res.ok, status: res.status, body };
|
|
405
|
+
};
|
|
406
|
+
const [wallets, tier, limits] = await Promise.all([
|
|
407
|
+
get("/api/v1/agent/wallets/embedded"),
|
|
408
|
+
get("/api/v1/agent/tier"),
|
|
409
|
+
get("/api/v1/agent/keys/self/limits")
|
|
410
|
+
]);
|
|
411
|
+
const unreadable = [
|
|
412
|
+
["wallets", wallets],
|
|
413
|
+
["tier", tier],
|
|
414
|
+
["limits", limits]
|
|
415
|
+
].filter(([, r]) => !r.ok);
|
|
416
|
+
return {
|
|
417
|
+
text: JSON.stringify({
|
|
418
|
+
success: true,
|
|
419
|
+
ready: unreadable.length === 0 ? true : undefined,
|
|
420
|
+
unreadable: unreadable.length > 0 ? unreadable.map(([name]) => name) : undefined,
|
|
421
|
+
wallets: wallets.body,
|
|
422
|
+
tier: tier.body,
|
|
423
|
+
limits: limits.body
|
|
424
|
+
}, null, 2),
|
|
425
|
+
...unreadable.length > 0 ? { isError: true } : {}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
388
428
|
|
|
389
429
|
// src/tools.ts
|
|
390
430
|
var TOOL_NAMES = [
|
|
@@ -398,7 +438,11 @@ var TOOL_NAMES = [
|
|
|
398
438
|
"candle_launch_and_seed",
|
|
399
439
|
"candle_swap",
|
|
400
440
|
"candle_transfer",
|
|
401
|
-
"candle_sweep"
|
|
441
|
+
"candle_sweep",
|
|
442
|
+
"candle_get_wallets",
|
|
443
|
+
"candle_resolve_token",
|
|
444
|
+
"candle_execution_status",
|
|
445
|
+
"candle_get_operation"
|
|
402
446
|
];
|
|
403
447
|
function resolveToolAllowlist(env) {
|
|
404
448
|
const raw = env.CANDLE_MCP_TOOLS?.trim();
|
|
@@ -423,6 +467,25 @@ function jsonHeaders(apiKey) {
|
|
|
423
467
|
headers2["x-api-key"] = apiKey;
|
|
424
468
|
return headers2;
|
|
425
469
|
}
|
|
470
|
+
function swapBody(args) {
|
|
471
|
+
const { amount, amountRaw, ...rest } = args;
|
|
472
|
+
const hasAmount = typeof amount === "string" && amount.length > 0;
|
|
473
|
+
const hasRaw = typeof amountRaw === "string" && amountRaw.length > 0;
|
|
474
|
+
if (hasAmount && hasRaw) {
|
|
475
|
+
throw new Error("Pass exactly one of amount or amountRaw, not both.");
|
|
476
|
+
}
|
|
477
|
+
if (!hasAmount && !hasRaw) {
|
|
478
|
+
throw new Error('Pass an amount, e.g. amount: "0.5".');
|
|
479
|
+
}
|
|
480
|
+
if (hasRaw)
|
|
481
|
+
return { ...rest, amountRaw };
|
|
482
|
+
const from = String(rest.from ?? "").toLowerCase();
|
|
483
|
+
const decimals = QUOTE_DECIMALS[from];
|
|
484
|
+
if (decimals === undefined) {
|
|
485
|
+
throw new Error(`Unknown decimals for base asset "${from}". Pass amountRaw instead.`);
|
|
486
|
+
}
|
|
487
|
+
return { ...rest, amountRaw: decimalToRaw(amount, decimals) };
|
|
488
|
+
}
|
|
426
489
|
function buildRequest(name, args, cfg) {
|
|
427
490
|
const base2 = cfg.apiUrl.replace(/\/$/, "");
|
|
428
491
|
switch (name) {
|
|
@@ -475,7 +538,20 @@ function buildRequest(name, args, cfg) {
|
|
|
475
538
|
const apiKey = requireApiKey2(cfg);
|
|
476
539
|
return {
|
|
477
540
|
url: `${base2}/api/v1/agent/swap`,
|
|
478
|
-
init: { method: "POST", headers: jsonHeaders(apiKey), body: JSON.stringify(args) }
|
|
541
|
+
init: { method: "POST", headers: jsonHeaders(apiKey), body: JSON.stringify(swapBody(args)) }
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
case "candle_get_operation": {
|
|
545
|
+
const apiKey = requireApiKey2(cfg);
|
|
546
|
+
const { clientId, kind } = args;
|
|
547
|
+
const path = kind === "launch" ? `/api/v1/launch/headless/jobs/${encodeURIComponent(clientId)}` : `/api/v1/trade/agent/jobs/${encodeURIComponent(clientId)}`;
|
|
548
|
+
return { url: `${base2}${path}`, init: { method: "GET", headers: jsonHeaders(apiKey) } };
|
|
549
|
+
}
|
|
550
|
+
case "candle_get_wallets": {
|
|
551
|
+
const apiKey = requireApiKey2(cfg);
|
|
552
|
+
return {
|
|
553
|
+
url: `${base2}/api/v1/agent/wallets/embedded`,
|
|
554
|
+
init: { method: "GET", headers: jsonHeaders(apiKey) }
|
|
479
555
|
};
|
|
480
556
|
}
|
|
481
557
|
case "candle_transfer": {
|
|
@@ -532,10 +608,18 @@ var reportActivityShape = {
|
|
|
532
608
|
var getAgentProfileShape = {
|
|
533
609
|
idOrWallet: z.string().describe("Candle username or wallet address")
|
|
534
610
|
};
|
|
611
|
+
var getOperationShape = {
|
|
612
|
+
clientId: z.string().describe("The clientTradeId or clientLaunchId the write used, or that the tool echoed back to you"),
|
|
613
|
+
kind: z.enum(["trade", "launch"]).describe("Which rail the id belongs to. Required: ids are caller-chosen strings with no shape to dispatch on")
|
|
614
|
+
};
|
|
615
|
+
var resolveTokenShape = {
|
|
616
|
+
mint: z.string().describe("Token mint (Solana, base58) or contract address (Hood, 0x-prefixed)")
|
|
617
|
+
};
|
|
535
618
|
var swapShape = {
|
|
536
619
|
from: z.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).describe("Base asset to spend"),
|
|
537
620
|
to: z.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).describe("Base asset to receive; must differ from `from`"),
|
|
538
|
-
|
|
621
|
+
amount: z.string().optional().describe('Decimal amount of `from` to spend, e.g. "0.5". Preferred. Pass exactly one of amount or amountRaw.'),
|
|
622
|
+
amountRaw: z.string().optional().describe("Raw base units of `from`, as a positive integer string. Kept for callers that already " + "compute raw units; new callers should use `amount`."),
|
|
539
623
|
maxSlippageBps: z.number().optional().describe("Slippage bound in bps, 0-10000. Server defaults to 100 (1%)"),
|
|
540
624
|
clientSwapId: z.string().optional().describe("Optional dedup key. Only coalesces a duplicate that arrives while the first call is still " + "in flight; one arriving after it settled will swap again")
|
|
541
625
|
};
|
|
@@ -605,9 +689,52 @@ function registerTools(server, env = process.env) {
|
|
|
605
689
|
description: "Read a Candle user's public agent profile: whether agent features are enabled and launch counts.",
|
|
606
690
|
inputSchema: getAgentProfileShape
|
|
607
691
|
}, async (args) => callAndRelay("candle_get_agent_profile", args, cfg));
|
|
692
|
+
register("candle_get_operation", {
|
|
693
|
+
title: "What happened to a write",
|
|
694
|
+
description: "Look up a trade or launch by the id its write used, and find out whether it landed. " + `Reads only; moves nothing.
|
|
695
|
+
|
|
696
|
+
` + "Call this after a timeout, after a restart, or any time you hold an id and do not know " + `the outcome. Do NOT re-send the write to find out.
|
|
697
|
+
|
|
698
|
+
` + `Four answers, four different next moves:
|
|
699
|
+
` + "- `confirmed`/`failed`: it is over. `signature` proves the first, `errorCode` explains " + `the second.
|
|
700
|
+
` + "- `built`/`submitted`: still in flight. Wait and ask again.\n" + "- 404 (`JOB_NOT_FOUND`): Candle never saw this id, so the write never reached the rail " + "and nothing moved. The original request is safe to send again exactly as it was. This " + `is a definite answer, not a failure to retry.
|
|
701
|
+
|
|
702
|
+
` + "Amounts come back RAW, deliberately: the answer you need after a timeout is the outcome, " + "and you already know what you asked for.",
|
|
703
|
+
inputSchema: getOperationShape
|
|
704
|
+
}, async (args) => callAndRelay("candle_get_operation", args, cfg));
|
|
705
|
+
register("candle_get_wallets", {
|
|
706
|
+
title: "List the wallets Candle executes with",
|
|
707
|
+
description: "The account's EMBEDDED wallets, one per chain, with their delegation state. These are " + "the wallets candle_trade, candle_swap and candle_transfer spend from, so this is how an " + "agent finds its own funding addresses. Reads only; moves nothing. Not the same as the " + "account's LINKED wallets, which are the owner's own wallets and are not spent from here. " + "Balances are not included: read a specific one with the market and balance endpoints.",
|
|
708
|
+
inputSchema: {}
|
|
709
|
+
}, async () => callAndRelay("candle_get_wallets", {}, cfg));
|
|
710
|
+
register("candle_resolve_token", {
|
|
711
|
+
title: "Resolve a contract address to a token",
|
|
712
|
+
description: "Turn a bare contract address or mint into Candle's market for it: chain, symbol, " + "decimals, quote asset, and whether Candle can trade it. Start here when a human gives " + "you an address and nothing else. The chain is read off the address's own shape and is " + "not guessed, so it does not need to be supplied. Reads only; moves nothing. A 404 means " + "Candle has no market for that address, which is an answer, not a failure to retry.",
|
|
713
|
+
inputSchema: resolveTokenShape
|
|
714
|
+
}, async (args) => {
|
|
715
|
+
const result = await resolveToken(args, cfg, fetch);
|
|
716
|
+
return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
|
|
717
|
+
});
|
|
718
|
+
register("candle_execution_status", {
|
|
719
|
+
title: "Can this key execute right now",
|
|
720
|
+
description: "One call before trading: the embedded wallets to spend from, the tier that decides what " + "may be traded, and this key's own spend limits. Reads only; moves nothing. Call it when " + "a run starts, or after an authorization error, rather than inferring readiness from a " + "failed trade. If a read could not be completed the tool says which one and does NOT " + "claim the account is unready: an unreachable endpoint and a missing tier are different " + "problems with different fixes.",
|
|
721
|
+
inputSchema: {}
|
|
722
|
+
}, async () => {
|
|
723
|
+
const result = await executionStatus(cfg, fetch);
|
|
724
|
+
return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
|
|
725
|
+
});
|
|
608
726
|
register("candle_swap", {
|
|
609
727
|
title: "Convert between base assets",
|
|
610
|
-
description: "Convert one base asset into another through the account's own embedded wallets. MOVES " +
|
|
728
|
+
description: "Convert one base asset into another through the account's own embedded wallets. MOVES " + `REAL FUNDS.
|
|
729
|
+
|
|
730
|
+
` + "SOL, USDC and CNDL are on Solana. ETH and USDG are on Hood. A pair drawn from ONE of " + "those groups settles on that chain in a single transaction. A pair spanning both is a " + `BRIDGE, and this is how a Hood wallet gets funded before launching or trading there.
|
|
731
|
+
|
|
732
|
+
` + `A bridge behaves differently and the difference matters:
|
|
733
|
+
` + `- It is several transactions, not one, and it takes time rather than settling on the call.
|
|
734
|
+
` + "- A confirmed source transaction is NOT proof the destination was credited. Read the " + "returned status before treating the funds as arrived; the response carries the venue's " + `own status URLs for the cross-chain fill.
|
|
735
|
+
` + "- Do NOT re-send after a timeout. `clientSwapId` only coalesces a duplicate that arrives " + "while the first call is still in flight; once the first has settled, a second call with " + `the same id bridges AGAIN. If a bridge times out, check its status rather than retrying.
|
|
736
|
+
|
|
737
|
+
` + 'Amounts are decimal (`amount`, e.g. "0.5"); `amountRaw` still accepts raw base units for ' + "callers that already compute them. Test-environment keys are refused: every leg settles " + "on a live venue.",
|
|
611
738
|
inputSchema: swapShape
|
|
612
739
|
}, async (args) => callAndRelay("candle_swap", args, cfg));
|
|
613
740
|
register("candle_transfer", {
|
|
@@ -642,7 +769,7 @@ function registerTools(server, env = process.env) {
|
|
|
642
769
|
}
|
|
643
770
|
|
|
644
771
|
// src/version.ts
|
|
645
|
-
var SERVER_VERSION = "0.
|
|
772
|
+
var SERVER_VERSION = "0.6.1";
|
|
646
773
|
|
|
647
774
|
// src/server.ts
|
|
648
775
|
function createCandleMcpServer(env = process.env) {
|