@candledottv/mcp 0.3.0

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -0
  3. package/dist/index.js +599 -0
  4. package/package.json +56 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Candle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # @candledottv/mcp
2
+
3
+ An MCP (Model Context Protocol) server for the Candle agent rail. It exposes Candle's REST API as
4
+ ten tools over stdio, so an MCP-capable agent can launch tokens (optionally seeded with a dev
5
+ buy in the same call), trade, convert between base assets (including across chains), read market
6
+ and feed data, report on-chain activity, and check an agent profile without hand-rolling HTTP
7
+ calls.
8
+
9
+ ## Try it without a key
10
+
11
+ Three tools are read-only and need nothing but `CANDLE_API_URL` (which already defaults to
12
+ production): `candle_get_market`, `candle_get_feed`, and `candle_get_agent_profile`. Install the
13
+ server with no `env` block at all and those three work immediately:
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "candle": {
19
+ "command": "npx",
20
+ "args": ["-y", "@candledottv/mcp"]
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ Add `CANDLE_AGENT_API_KEY` only once you're ready to launch, trade, or report activity, see
27
+ Environment below -- or skip env editing entirely: after `npx @candledottv/cli auth login`,
28
+ `candle mcp` launches this server with the stored key and API URL in its environment.
29
+
30
+ `CANDLE_MCP_TOOLS` (optional) is a comma-separated allowlist of tool names; only those register.
31
+ Unset means all eight. An unknown name fails startup with the valid names in the message, rather
32
+ than silently registering the wrong surface. `candle mcp --read-only` / `--tools` set this for
33
+ you.
34
+
35
+ Most tools are a thin wrapper: a one-request mapping onto `apps/api`
36
+ (`POST /api/v1/launch/headless`, `GET /api/v1/markets/:chain/:mint`, `GET /api/v1/markets/feed`,
37
+ `POST /api/v1/activity/report`, `GET /api/v1/users/:idOrWallet/agent`) that hands the response body
38
+ straight back to the caller, unchanged, error responses included. `candle_trade` and
39
+ `candle_launch_and_seed` are the two exceptions: each is a small orchestration (a decimal-to-raw
40
+ conversion, an idempotency key, a follow-up read) on top of the same REST surface, see Errors
41
+ below.
42
+
43
+ Both take decimal amounts, never raw base units, and resolve the scale themselves:
44
+
45
+ - A `candle_trade` **buy** is denominated in the token's OWN quote asset, whatever it was launched
46
+ against (SOL for a SOL-launched token, USDC or CNDL for those quote pairs). The tool reads the
47
+ market first and converts against its `quoteDecimals`; the `quoteAsset` field applies only to a
48
+ mint Candle never launched, the arbitrary-token path a Pro or Max key trades through Jupiter. A
49
+ **sell** is denominated in tokens and converts against the market's own `decimals`.
50
+ - A `candle_launch_and_seed` `devBuy` is denominated in the quote asset that launch selects, since
51
+ there the caller genuinely picks the new token's quote pair: `quoteAsset` if given, otherwise
52
+ SOL on Solana and ETH on Hood.
53
+
54
+ For a full walkthrough of `candle_launch_and_seed` and `candle_trade`, including the keyless read
55
+ tools, getting a key, funding the embedded wallet, and idempotent retries, see
56
+ `docs/mcp-launch-and-seed.md` in the `candle-monorepo` repo.
57
+
58
+ ## Environment
59
+
60
+ - `CANDLE_API_URL` -- base URL of the Candle API. Defaults to `https://api.alpha.candle.tv` (the alpha deployment; production does not serve the agent API yet). Set it to
61
+ `http://localhost:3001` when developing against a local API.
62
+ - `CANDLE_AGENT_API_KEY` -- an agent API key (`cndl_live_...` / `cndl_test_...`), issued from a
63
+ Candle account's agent settings page. Only required by `candle_launch_token`,
64
+ `candle_report_activity`, `candle_trade`, `candle_launch_and_seed`, and `candle_swap`; the three
65
+ read-only tools
66
+ work without it, so the server is useful the moment it is installed and only asks for a key when
67
+ you try to write. `candle_trade` additionally needs the key's `swap:write` scope server-side,
68
+ which is opt-in only and never granted by omission, see `docs/mcp-launch-and-seed.md` in the
69
+ `candle-monorepo` repo.
70
+ - `CANDLE_API_KEY` -- alias for `CANDLE_AGENT_API_KEY`, the same variable name the Candle CLI uses
71
+ for this credential. Set either one; if both are set, `CANDLE_AGENT_API_KEY` takes precedence.
72
+
73
+ ## Tools
74
+
75
+ | Tool | Description | REST call | Auth |
76
+ | --- | --- | --- | --- |
77
+ | `candle_launch_token` | Launch a token on Candle | `POST /api/v1/launch/headless` (or `/dry-run` when `dryRun: true`) | `CANDLE_AGENT_API_KEY` |
78
+ | `candle_get_market` | Get market state | `GET /api/v1/markets/:chain/:mint` | none |
79
+ | `candle_get_feed` | Get a token feed | `GET /api/v1/markets/feed?bucket=...` | none |
80
+ | `candle_report_activity` | Report on-chain activity | `POST /api/v1/activity/report` | `CANDLE_AGENT_API_KEY` |
81
+ | `candle_get_agent_profile` | Get an agent profile | `GET /api/v1/users/:idOrWallet/agent` | none |
82
+ | `candle_trade` | Buy or sell a token | Reads the market for its decimals (or wallet balance, for a percent sell) then `POST /api/v1/trade/agent/build` | `CANDLE_AGENT_API_KEY` (`swap:write`) |
83
+ | `candle_launch_and_seed` | Launch a token and seed it | `POST /api/v1/launch/headless` (or `/dry-run`), then a follow-up `GET /api/v1/markets/:chain/:mint` | `CANDLE_AGENT_API_KEY` |
84
+ | `candle_swap` | Swap between base assets | `POST /api/v1/agent/swap` | `CANDLE_AGENT_API_KEY` (`swap:write`) |
85
+ | `candle_transfer` | Transfer an asset | `POST /api/v1/agent/transfer` | `CANDLE_AGENT_API_KEY` (`transfer:write`) |
86
+ | `candle_sweep` | Sweep a wallet to one destination | One `POST /api/v1/agent/transfer` per asset, `amountRaw: "max"` | `CANDLE_AGENT_API_KEY` (`transfer:write`) |
87
+
88
+ ### Transfers and sweeps
89
+
90
+ `candle_transfer` moves one asset from the account's embedded wallet: to any of the account's
91
+ OWN wallets freely (any asset, `amountRaw` in RAW base units or `"max"` for the spendable
92
+ balance), or to an address the OWNER pre-approved as a withdrawal address in the Candle console
93
+ (base assets only, bounded by the account's spend caps and the key's transaction limit).
94
+ Anything else is refused before signing -- an agent key can never approve its own destination.
95
+
96
+ `candle_sweep` is the whole-wallet loop: one `"max"` transfer per asset on the chosen chain,
97
+ tokens before the native asset (the native asset pays the fees), plus any `mints` named
98
+ explicitly. Assets with nothing spendable report `empty`; a failed asset never stops the rest.
99
+
100
+ ## Errors
101
+
102
+ This package never reinterprets an error body, and that body is not one uniform shape across all
103
+ ten tools:
104
+
105
+ - `candle_launch_token`, `candle_get_market`, and `candle_get_feed` hit endpoints that use the
106
+ structured envelope `{ success: false, error: { code, message, ... } }`. Branch on `error.code`.
107
+ - `candle_report_activity` relays `apps/api/src/routes/activity.ts`'s own plain error shape
108
+ verbatim: `{ error: true, payload: string }`.
109
+ - `candle_get_agent_profile` relays `apps/api/src/routes/users.ts`'s own plain error shape
110
+ verbatim: `{ error: string }`.
111
+ - `candle_trade` and `candle_launch_and_seed` wrap the underlying REST body instead of relaying it
112
+ bare, in one of two shapes depending on how far the call got:
113
+ - A failure caught before any REST request goes out (e.g. passing both `amount` and `percent`,
114
+ an unrecognized `quoteAsset`, or a devBuy conversion
115
+ error) returns
116
+ `{ clientTradeId | clientLaunchId, success: false, error: { code: "MCP_VALIDATION", message } }`.
117
+ - A pre-request READ that comes back non-ok (an expired key on the wallet read, a 500 on the
118
+ market read) is relayed verbatim instead: `{ clientTradeId | clientLaunchId, success: false,
119
+ api }`, where `api` is that read's own body. The tool never reinterprets it as "you have no
120
+ embedded wallet" or "this token has no decimals".
121
+ - A request that dies in transit, a rejected connection or a body that is not JSON, returns
122
+ `{ clientTradeId | clientLaunchId, success: false, error: { code: "MCP_TRANSPORT", message,
123
+ retryable: true } }`. It means undetermined, not failed: the call may or may not have reached
124
+ Candle. Retry it with the SAME id and the same body, which either replays the original result
125
+ or runs it for the first time. A new id would be a second trade or launch.
126
+ - Once the underlying request is actually sent, the shape is
127
+ `{ clientTradeId | clientLaunchId, resolved?, api | launch, market?, note? }`. `candle_trade`
128
+ always returns `{ clientTradeId, resolved, api }`: `resolved` is the decimal-to-raw (or
129
+ percent-to-raw) conversion this tool computed before calling the trade endpoint, and `api` is
130
+ that endpoint's own response body verbatim, success or error. `candle_launch_and_seed` returns
131
+ `{ clientLaunchId, api }` for a dry run or a failed launch, and
132
+ `{ clientLaunchId, launch, market, note? }` for a confirmed launch: `launch` is the launch
133
+ endpoint's own response body, `market` is a best-effort follow-up market read (`null` when
134
+ that read failed), and `note` is present only alongside a failed follow-up read, pointing at
135
+ `candle_get_market` to fetch it separately.
136
+ - Either way, both tools echo their idempotency id (`clientTradeId` / `clientLaunchId`) at the
137
+ top level, so a caller can always find it to retry safely: retrying with the SAME id is a safe
138
+ replay, a new id is a second trade or launch. See `docs/mcp-launch-and-seed.md` in the
139
+ `candle-monorepo` repo for the full idempotent-retry rule.
140
+
141
+ ## MCP client config
142
+
143
+ Add this to your MCP client (Claude Desktop, Claude Code, Cursor, or anything else that speaks
144
+ stdio MCP). No checkout and no build required:
145
+
146
+ ```json
147
+ {
148
+ "mcpServers": {
149
+ "candle": {
150
+ "command": "npx",
151
+ "args": ["-y", "@candledottv/mcp"],
152
+ "env": {
153
+ "CANDLE_AGENT_API_KEY": "cndl_live_..."
154
+ }
155
+ }
156
+ }
157
+ }
158
+ ```
159
+
160
+ `CANDLE_API_URL` is omitted on purpose: it already defaults to production. Drop the `env` block
161
+ entirely if you only want the read-only tools.
162
+
163
+ ## Running it directly
164
+
165
+ ```bash
166
+ npx -y @candledottv/mcp
167
+ ```
168
+
169
+ The server speaks JSON-RPC over stdio, so running it in a terminal is only useful for smoke
170
+ testing. It is meant to be spawned by an MCP client.
171
+
172
+ ## Development
173
+
174
+ From a monorepo checkout:
175
+
176
+ ```bash
177
+ bun run src/index.ts # run from source against CANDLE_API_URL
178
+ bun test # request-building, version parity, and config-default guards
179
+ bun run typecheck # tsc --noEmit
180
+ bun run build # bundle to dist/index.js for publishing
181
+ ```
182
+
183
+ `bun run build` targets node and leaves `@modelcontextprotocol/sdk` and `zod` as external
184
+ dependencies, so npm installs and dedupes them normally. The published `bin` is the built
185
+ `dist/index.js` with a `#!/usr/bin/env node` shebang, not the TypeScript source: MCP clients spawn
186
+ this on machines that have node and may not have bun.
187
+
188
+ `@modelcontextprotocol/sdk` is pinned to `1.22.0` rather than the newest 1.x release: starting at
189
+ `1.23.0`, the SDK's zod v3/v4 compatibility types (`server/zod-compat.ts`) trip a TypeScript
190
+ `TS2589` ("Type instantiation is excessively deep") once more than a couple of `registerTool`
191
+ calls with multi-field zod input shapes coexist in one file, which this package's tools
192
+ always will. `1.22.0` predates that rewrite and type-checks cleanly with the exact same tool
193
+ code. Re-check this pin when bumping the SDK.
package/dist/index.js ADDED
@@ -0,0 +1,599 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+
7
+ // src/tools.ts
8
+ import { z } from "zod";
9
+
10
+ // src/client.ts
11
+ var DEFAULT_API_URL = "https://api.alpha.candle.tv";
12
+ function resolveConfig() {
13
+ const apiUrl = process.env.CANDLE_API_URL?.trim() || DEFAULT_API_URL;
14
+ const apiKey = process.env.CANDLE_AGENT_API_KEY?.trim() || process.env.CANDLE_API_KEY?.trim();
15
+ return apiKey ? { apiUrl, apiKey } : { apiUrl };
16
+ }
17
+
18
+ // src/orchestrate.ts
19
+ import { randomUUID } from "node:crypto";
20
+
21
+ // src/convert.ts
22
+ var QUOTE_DECIMALS = {
23
+ sol: 9,
24
+ usdc: 6,
25
+ cndl: 6,
26
+ eth: 18,
27
+ usdg: 6
28
+ };
29
+ function defaultQuoteId(chain) {
30
+ return chain === "hood" ? "eth" : "sol";
31
+ }
32
+ var DECIMAL_RE = /^\d+(\.\d+)?$/;
33
+ function decimalToRaw(amount, decimals) {
34
+ if (!Number.isInteger(decimals) || decimals < 0) {
35
+ throw new Error(`decimals must be a non-negative integer, got ${decimals}`);
36
+ }
37
+ if (!DECIMAL_RE.test(amount)) {
38
+ throw new Error(`amount must be a plain positive decimal string, got "${amount}"`);
39
+ }
40
+ const [whole, fraction = ""] = amount.split(".");
41
+ if (fraction.length > decimals) {
42
+ throw new Error(`amount "${amount}" has more fraction digits than the asset's ${decimals} decimals`);
43
+ }
44
+ const raw = BigInt(whole + fraction.padEnd(decimals, "0"));
45
+ if (raw === 0n)
46
+ throw new Error("amount must be greater than zero");
47
+ return raw.toString();
48
+ }
49
+ function percentOfBalance(balanceRaw, percent) {
50
+ if (!Number.isInteger(percent) || percent < 1 || percent > 100) {
51
+ throw new Error(`percent must be an integer between 1 and 100, got ${percent}`);
52
+ }
53
+ if (!/^\d+$/.test(balanceRaw)) {
54
+ throw new Error(`balanceRaw must be a raw integer string, got "${balanceRaw}"`);
55
+ }
56
+ const result = BigInt(balanceRaw) * BigInt(percent) / 100n;
57
+ if (result === 0n) {
58
+ throw new Error(`${percent}% of the balance ${balanceRaw} floors to zero raw units; nothing to sell`);
59
+ }
60
+ return result.toString();
61
+ }
62
+
63
+ // src/orchestrate.ts
64
+ function requireApiKey(cfg) {
65
+ if (!cfg.apiKey) {
66
+ throw new Error("CANDLE_AGENT_API_KEY is required for this tool. Set it in the environment or MCP client config.");
67
+ }
68
+ return cfg.apiKey;
69
+ }
70
+ function headers(apiKey) {
71
+ const h = { "Content-Type": "application/json" };
72
+ if (apiKey)
73
+ h["x-api-key"] = apiKey;
74
+ return h;
75
+ }
76
+ function base(cfg) {
77
+ return cfg.apiUrl.replace(/\/$/, "");
78
+ }
79
+ function errText(message, extra) {
80
+ return {
81
+ text: JSON.stringify({ ...extra, success: false, error: { code: "MCP_VALIDATION", message } }),
82
+ isError: true
83
+ };
84
+ }
85
+ function relayRead(body, extra) {
86
+ let api;
87
+ try {
88
+ api = JSON.parse(body);
89
+ } catch {
90
+ api = body;
91
+ }
92
+ return { text: JSON.stringify({ ...extra, success: false, api }), isError: true };
93
+ }
94
+ function transportError(idKey, id, thrown) {
95
+ const detail = thrown instanceof Error ? thrown.message : String(thrown);
96
+ return {
97
+ text: JSON.stringify({
98
+ [idKey]: id,
99
+ success: false,
100
+ error: {
101
+ code: "MCP_TRANSPORT",
102
+ message: `The request failed in transit (${detail}). It may or may not have reached Candle. ` + `Retry with THIS SAME ${idKey} ("${id}") and the same body: the same id replays the ` + "original result instead of executing a second time, while a NEW id is a second, independent " + "trade or launch.",
103
+ retryable: true
104
+ }
105
+ }),
106
+ isError: true
107
+ };
108
+ }
109
+ function chainForMint(mint) {
110
+ return mint.startsWith("0x") ? "hood" : "solana";
111
+ }
112
+ function quoteIdDecimals(quoteAsset, chain, extra) {
113
+ const quote = quoteAsset ?? defaultQuoteId(chain);
114
+ const decimals = QUOTE_DECIMALS[quote];
115
+ if (decimals === undefined) {
116
+ return {
117
+ err: errText(`unknown quoteAsset "${quote}"; expected one of ${Object.keys(QUOTE_DECIMALS).join(", ")}`, extra)
118
+ };
119
+ }
120
+ return { decimals };
121
+ }
122
+ function rawOrError(amount, decimals, extra) {
123
+ try {
124
+ return { raw: decimalToRaw(amount, decimals) };
125
+ } catch (err) {
126
+ return { err: errText(err instanceof Error ? err.message : String(err), extra) };
127
+ }
128
+ }
129
+ async function postJson(url, apiKey, payload, doFetch) {
130
+ try {
131
+ const res = await doFetch(url, { method: "POST", headers: headers(apiKey), body: JSON.stringify(payload) });
132
+ return { ok: res.ok, body: JSON.parse(await res.text()) };
133
+ } catch (thrown) {
134
+ return { thrown };
135
+ }
136
+ }
137
+ async function readMarket(mint, cfg, doFetch, extra) {
138
+ const res = await doFetch(`${base(cfg)}/api/v1/markets/${chainForMint(mint)}/${mint}`, {
139
+ method: "GET",
140
+ headers: headers()
141
+ });
142
+ const text = await res.text();
143
+ if (!res.ok)
144
+ return { status: res.status, err: relayRead(text, extra) };
145
+ return { market: JSON.parse(text).market ?? {} };
146
+ }
147
+ async function executeTrade(args, cfg, doFetch) {
148
+ const apiKey = requireApiKey(cfg);
149
+ const clientTradeId = args.clientTradeId ?? randomUUID();
150
+ if (args.amount !== undefined && args.percent !== undefined) {
151
+ return errText("pass exactly one of amount or percent, not both", { clientTradeId });
152
+ }
153
+ if (args.amount === undefined && args.percent === undefined) {
154
+ return errText("pass exactly one of amount or percent", { clientTradeId });
155
+ }
156
+ if (args.percent !== undefined && args.side !== "sell") {
157
+ return errText("percent is only valid on sells; buys take a quote-asset amount", { clientTradeId });
158
+ }
159
+ let amountRaw;
160
+ let resolved;
161
+ try {
162
+ if (args.side === "buy") {
163
+ const amount = args.amount;
164
+ const read = await readMarket(args.mint, cfg, doFetch, { clientTradeId });
165
+ let decimals;
166
+ if ("market" in read) {
167
+ const quoteDecimals = read.market.quoteDecimals;
168
+ if (typeof quoteDecimals !== "number") {
169
+ return errText(`could not resolve the quote decimals for mint ${args.mint}; a buy is denominated in that token's own quote asset and this market does not report its scale. Read the market with candle_get_market, then trade a raw amount via the SDK instead`, { clientTradeId });
170
+ }
171
+ decimals = quoteDecimals;
172
+ } else if (read.status === 404) {
173
+ const q = quoteIdDecimals(args.quoteAsset, chainForMint(args.mint), { clientTradeId });
174
+ if ("err" in q)
175
+ return q.err;
176
+ decimals = q.decimals;
177
+ } else {
178
+ return read.err;
179
+ }
180
+ const converted = rawOrError(amount, decimals, { clientTradeId });
181
+ if ("err" in converted)
182
+ return converted.err;
183
+ amountRaw = converted.raw;
184
+ resolved = { amountDecimal: amount, decimals, amountRaw };
185
+ } else if (args.amount !== undefined) {
186
+ const read = await readMarket(args.mint, cfg, doFetch, { clientTradeId });
187
+ if (!("market" in read))
188
+ return read.err;
189
+ const decimals = read.market.decimals;
190
+ if (typeof decimals !== "number") {
191
+ return errText(`could not resolve decimals for mint ${args.mint}; pass a raw-ready amount via the SDK instead`, { clientTradeId });
192
+ }
193
+ const converted = rawOrError(args.amount, decimals, { clientTradeId });
194
+ if ("err" in converted)
195
+ return converted.err;
196
+ amountRaw = converted.raw;
197
+ resolved = { amountDecimal: args.amount, decimals, amountRaw };
198
+ } else {
199
+ const percent = args.percent;
200
+ const walletsRes = await doFetch(`${base(cfg)}/api/v1/agent/wallets/embedded`, {
201
+ method: "GET",
202
+ headers: headers(apiKey)
203
+ });
204
+ const walletsText = await walletsRes.text();
205
+ if (!walletsRes.ok)
206
+ return relayRead(walletsText, { clientTradeId });
207
+ const walletsBody = JSON.parse(walletsText);
208
+ const chain = chainForMint(args.mint);
209
+ const address = chain === "hood" ? walletsBody.wallets?.evm?.address : walletsBody.wallets?.solana?.address;
210
+ if (!address) {
211
+ return errText(`percent sells need an embedded ${chain === "hood" ? "EVM" : "Solana"} wallet, and this account has none`, { clientTradeId });
212
+ }
213
+ const balRes = await doFetch(`${base(cfg)}/api/v1/tokens/${args.mint}/balance/${address}`, {
214
+ method: "GET",
215
+ headers: headers()
216
+ });
217
+ const balText = await balRes.text();
218
+ if (!balRes.ok)
219
+ return relayRead(balText, { clientTradeId });
220
+ const balBody = JSON.parse(balText);
221
+ const balance = balBody.payload?.balance;
222
+ if (!balance) {
223
+ return errText(`the embedded wallet ${address} holds no ${args.mint}; nothing to sell`, { clientTradeId });
224
+ }
225
+ amountRaw = percentOfBalance(balance, percent);
226
+ resolved = { percent, balanceRaw: balance, amountRaw };
227
+ }
228
+ } catch (err) {
229
+ return errText(err instanceof Error ? err.message : String(err), { clientTradeId });
230
+ }
231
+ const posted = await postJson(`${base(cfg)}/api/v1/trade/agent/build`, apiKey, {
232
+ clientTradeId,
233
+ mint: args.mint,
234
+ side: args.side,
235
+ amountRaw,
236
+ payer: { type: "main" },
237
+ ...args.quoteAsset !== undefined ? { quoteAsset: args.quoteAsset } : {},
238
+ ...args.maxSlippageBps !== undefined ? { maxSlippageBps: args.maxSlippageBps } : {}
239
+ }, doFetch);
240
+ if ("thrown" in posted)
241
+ return transportError("clientTradeId", clientTradeId, posted.thrown);
242
+ const wrapped = JSON.stringify({ clientTradeId, resolved, api: posted.body });
243
+ return posted.ok ? { text: wrapped } : { text: wrapped, isError: true };
244
+ }
245
+ async function executeLaunchAndSeed(args, cfg, doFetch) {
246
+ const apiKey = requireApiKey(cfg);
247
+ const clientLaunchId = args.clientLaunchId ?? randomUUID();
248
+ const { devBuy, dryRun, buyAmount: _rawBuyAmount, ...launchFields } = args;
249
+ let buyAmount;
250
+ if (devBuy !== undefined) {
251
+ const q = quoteIdDecimals(args.quoteAsset, args.chain, { clientLaunchId });
252
+ if ("err" in q)
253
+ return q.err;
254
+ const converted = rawOrError(devBuy, q.decimals, { clientLaunchId });
255
+ if ("err" in converted)
256
+ return converted.err;
257
+ buyAmount = converted.raw;
258
+ }
259
+ const path = dryRun ? "/api/v1/launch/headless/dry-run" : "/api/v1/launch/headless";
260
+ const posted = await postJson(`${base(cfg)}${path}`, apiKey, {
261
+ ...launchFields,
262
+ clientLaunchId,
263
+ ...buyAmount !== undefined ? { buyAmount } : {}
264
+ }, doFetch);
265
+ if ("thrown" in posted)
266
+ return transportError("clientLaunchId", clientLaunchId, posted.thrown);
267
+ if (!posted.ok) {
268
+ return { text: JSON.stringify({ clientLaunchId, api: posted.body }), isError: true };
269
+ }
270
+ if (dryRun) {
271
+ return { text: JSON.stringify({ clientLaunchId, dryRun: true, api: posted.body }) };
272
+ }
273
+ const launch = posted.body;
274
+ let market = null;
275
+ let note;
276
+ try {
277
+ const marketRes = await doFetch(`${base(cfg)}/api/v1/markets/${launch.chain ?? "solana"}/${launch.mint}`, {
278
+ method: "GET",
279
+ headers: headers()
280
+ });
281
+ if (marketRes.ok) {
282
+ market = JSON.parse(await marketRes.text()).market ?? null;
283
+ } else {
284
+ note = "launch confirmed; the follow-up market read failed, read it via candle_get_market";
285
+ }
286
+ } catch {
287
+ note = "launch confirmed; the follow-up market read failed, read it via candle_get_market";
288
+ }
289
+ return {
290
+ text: JSON.stringify({ clientLaunchId, launch, market, ...note ? { note } : {} })
291
+ };
292
+ }
293
+ var SWEEP_BASE_ASSETS = {
294
+ solana: ["USDC", "CNDL", "SOL"],
295
+ hood: ["USDG", "ETH"]
296
+ };
297
+ async function executeSweep(args, cfg, doFetch) {
298
+ const apiKey = cfg.apiKey;
299
+ if (!apiKey) {
300
+ throw new Error("CANDLE_AGENT_API_KEY is required for this tool. Set it in the environment or MCP client config.");
301
+ }
302
+ const base2 = cfg.apiUrl.replace(/\/$/, "");
303
+ const baseAssets = SWEEP_BASE_ASSETS[args.chain];
304
+ const targets = [
305
+ ...baseAssets.slice(0, -1).map((value) => ({ field: "asset", value })),
306
+ ...(args.mints ?? []).map((value) => ({ field: "mint", value })),
307
+ { field: "asset", value: baseAssets[baseAssets.length - 1] }
308
+ ];
309
+ const results = [];
310
+ for (const target of targets) {
311
+ let body;
312
+ let ok = false;
313
+ try {
314
+ const res = await doFetch(`${base2}/api/v1/agent/transfer`, {
315
+ method: "POST",
316
+ headers: { "Content-Type": "application/json", "x-api-key": apiKey },
317
+ body: JSON.stringify({
318
+ chain: args.chain,
319
+ [target.field]: target.value,
320
+ amountRaw: "max",
321
+ to: args.to
322
+ })
323
+ });
324
+ ok = res.ok;
325
+ const text = await res.text();
326
+ try {
327
+ body = JSON.parse(text);
328
+ } catch {
329
+ body = text;
330
+ }
331
+ } catch (err) {
332
+ results.push({ asset: target.value, status: "error", error: String(err) });
333
+ continue;
334
+ }
335
+ if (ok) {
336
+ const payload = body;
337
+ results.push({
338
+ asset: target.value,
339
+ status: "transferred",
340
+ amountRaw: payload.amountRaw,
341
+ signature: payload.signature
342
+ });
343
+ continue;
344
+ }
345
+ const code = body?.error?.code ?? "";
346
+ if (code === "TRANSFER_AMOUNT_UNAVAILABLE") {
347
+ results.push({ asset: target.value, status: "empty" });
348
+ } else {
349
+ results.push({ asset: target.value, status: "error", error: body });
350
+ }
351
+ }
352
+ const transferred = results.filter((r) => r.status === "transferred").length;
353
+ const failed = results.filter((r) => r.status === "error").length;
354
+ return {
355
+ text: JSON.stringify({ chain: args.chain, to: args.to, transferred, failed, results }),
356
+ ...transferred === 0 && failed > 0 ? { isError: true } : {}
357
+ };
358
+ }
359
+
360
+ // src/tools.ts
361
+ var TOOL_NAMES = [
362
+ "candle_launch_token",
363
+ "candle_get_market",
364
+ "candle_get_feed",
365
+ "candle_report_activity",
366
+ "candle_get_agent_profile",
367
+ "candle_trade",
368
+ "candle_launch_and_seed",
369
+ "candle_swap",
370
+ "candle_transfer",
371
+ "candle_sweep"
372
+ ];
373
+ function resolveToolAllowlist(env) {
374
+ const raw = env.CANDLE_MCP_TOOLS?.trim();
375
+ if (!raw)
376
+ return new Set(TOOL_NAMES);
377
+ const requested = raw.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
378
+ const unknown = requested.filter((name) => !TOOL_NAMES.includes(name));
379
+ if (requested.length === 0 || unknown.length > 0) {
380
+ throw new Error(`CANDLE_MCP_TOOLS contains unknown tool name(s): ${unknown.join(", ") || "(none given)"}. Valid names: ${TOOL_NAMES.join(", ")}`);
381
+ }
382
+ return new Set(requested);
383
+ }
384
+ function requireApiKey2(cfg) {
385
+ if (!cfg.apiKey) {
386
+ throw new Error("CANDLE_AGENT_API_KEY is required for this tool. Set it in the environment or MCP client config.");
387
+ }
388
+ return cfg.apiKey;
389
+ }
390
+ function jsonHeaders(apiKey) {
391
+ const headers2 = { "Content-Type": "application/json" };
392
+ if (apiKey)
393
+ headers2["x-api-key"] = apiKey;
394
+ return headers2;
395
+ }
396
+ function buildRequest(name, args, cfg) {
397
+ const base2 = cfg.apiUrl.replace(/\/$/, "");
398
+ switch (name) {
399
+ case "candle_launch_token": {
400
+ const apiKey = requireApiKey2(cfg);
401
+ const { dryRun, ...body } = args;
402
+ const path = dryRun ? "/api/v1/launch/headless/dry-run" : "/api/v1/launch/headless";
403
+ return {
404
+ url: `${base2}${path}`,
405
+ init: { method: "POST", headers: jsonHeaders(apiKey), body: JSON.stringify(body) }
406
+ };
407
+ }
408
+ case "candle_get_market": {
409
+ const { chain, mint } = args;
410
+ return { url: `${base2}/api/v1/markets/${chain}/${mint}`, init: { method: "GET", headers: jsonHeaders() } };
411
+ }
412
+ case "candle_get_feed": {
413
+ const { bucket, chain } = args;
414
+ const query = new URLSearchParams({ bucket, ...chain ? { chain } : {} });
415
+ return {
416
+ url: `${base2}/api/v1/markets/feed?${query.toString()}`,
417
+ init: { method: "GET", headers: jsonHeaders() }
418
+ };
419
+ }
420
+ case "candle_report_activity": {
421
+ const apiKey = requireApiKey2(cfg);
422
+ return {
423
+ url: `${base2}/api/v1/activity/report`,
424
+ init: { method: "POST", headers: jsonHeaders(apiKey), body: JSON.stringify(args) }
425
+ };
426
+ }
427
+ case "candle_get_agent_profile": {
428
+ const { idOrWallet } = args;
429
+ return { url: `${base2}/api/v1/users/${idOrWallet}/agent`, init: { method: "GET", headers: jsonHeaders() } };
430
+ }
431
+ case "candle_swap": {
432
+ const apiKey = requireApiKey2(cfg);
433
+ return {
434
+ url: `${base2}/api/v1/agent/swap`,
435
+ init: { method: "POST", headers: jsonHeaders(apiKey), body: JSON.stringify(args) }
436
+ };
437
+ }
438
+ case "candle_transfer": {
439
+ const apiKey = requireApiKey2(cfg);
440
+ return {
441
+ url: `${base2}/api/v1/agent/transfer`,
442
+ init: { method: "POST", headers: jsonHeaders(apiKey), body: JSON.stringify(args) }
443
+ };
444
+ }
445
+ }
446
+ }
447
+ async function callAndRelay(name, args, cfg) {
448
+ const { url, init } = buildRequest(name, args, cfg);
449
+ const res = await fetch(url, init);
450
+ const text = await res.text();
451
+ return {
452
+ content: [{ type: "text", text }],
453
+ ...res.ok ? {} : { isError: true }
454
+ };
455
+ }
456
+ var launchTokenShape = {
457
+ clientLaunchId: z.string().describe("Caller-chosen idempotency key, unique per account"),
458
+ name: z.string().describe("Token name"),
459
+ symbol: z.string().describe("Token symbol"),
460
+ imageUrl: z.string().describe("https URL to the token image. Must be roughly SQUARE (aspect ratio at most 1.5:1): it " + "renders as a small circle/square avatar everywhere. Share cards, OG images and banners " + "are rejected with IMAGE_WRONG_SHAPE -- pass those as bannerUrl instead"),
461
+ bannerUrl: z.string().optional().describe("Optional https URL to WIDE artwork for the token page's banner strip (wider than 1.5:1, " + "e.g. 1200x630). This is where a share card or OG image belongs. A square image here is " + "rejected with BANNER_WRONG_SHAPE. Omit it and the strip falls back to imageUrl"),
462
+ chain: z.string().optional().describe('"solana" or "hood"; defaults to solana'),
463
+ quoteAsset: z.string().optional().describe("Quote asset symbol; defaults per chain"),
464
+ mode: z.string().optional().describe('"open" or "exclusive"; defaults to open. "test-open" / "test-exclusive" launch the ' + "low-threshold test curves (~1/80 economics) where the API has ENABLE_TEST_CURVES on"),
465
+ stakerAllocationBps: z.number().optional().describe("Staker allocation in basis points"),
466
+ dexVersion: z.string().nullable().optional().describe('"v3" or "v4"; required for hood launches'),
467
+ buyAmount: z.union([z.string(), z.number()]).optional().describe("Initial buy, in quote base units (string or number)"),
468
+ description: z.string().optional(),
469
+ socials: z.record(z.string()).optional().describe("Social links keyed by platform"),
470
+ visibility: z.string().optional().describe('"production", "test", "local", or "hidden"'),
471
+ dryRun: z.boolean().optional().describe("Validate without executing the launch")
472
+ };
473
+ var getMarketShape = {
474
+ chain: z.string().describe('"solana" or "hood"'),
475
+ mint: z.string().describe("Token mint (solana) or contract address (hood)")
476
+ };
477
+ var getFeedShape = {
478
+ bucket: z.enum(["new", "graduated", "onfire", "bluechip"]),
479
+ chain: z.string().optional().describe("Optional chain filter")
480
+ };
481
+ var reportActivityShape = {
482
+ chain: z.string().describe('"solana" or "hood"'),
483
+ signature: z.string().describe("Transaction signature/hash to verify and record")
484
+ };
485
+ var getAgentProfileShape = {
486
+ idOrWallet: z.string().describe("Candle username or wallet address")
487
+ };
488
+ var swapShape = {
489
+ from: z.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).describe("Base asset to spend"),
490
+ to: z.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).describe("Base asset to receive; must differ from `from`"),
491
+ amountRaw: z.string().describe("Raw base units of `from` to spend, as a positive integer string"),
492
+ maxSlippageBps: z.number().optional().describe("Slippage bound in bps, 0-10000. Server defaults to 100 (1%)"),
493
+ 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")
494
+ };
495
+ var tradeShape = {
496
+ mint: z.string().describe("Token mint (solana) or contract address (hood)"),
497
+ side: z.enum(["buy", "sell"]),
498
+ 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."),
499
+ percent: z.number().optional().describe("Sells only: sell this percent (integer 1-100) of the wallet's holding, on either chain."),
500
+ quoteAsset: z.string().optional().describe('Quote asset ("sol", "usdc", "cndl") for an arbitrary Solana mint Candle never launched ' + "(Pro/Max only); defaults to sol. Ignored for a Candle-launched token, whose quote comes " + "from the token itself, so it never changes how a buy amount is interpreted there."),
501
+ maxSlippageBps: z.number().optional().describe("Max slippage in basis points; API default applies when omitted"),
502
+ 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.")
503
+ };
504
+ var { buyAmount: _rawBuyAmount, ...seedableLaunchShape } = launchTokenShape;
505
+ var launchAndSeedShape = {
506
+ ...seedableLaunchShape,
507
+ clientLaunchId: z.string().optional().describe("Idempotency key. Auto-generated when omitted and echoed in the result."),
508
+ devBuy: z.string().optional().describe('Seed buy in DECIMAL units of the quote asset this launch selects (e.g. "0.25" SOL, or ' + "ETH on hood), bundled into the launch transaction itself. Follows quoteAsset, which " + "defaults to sol on solana and eth on hood. Capped by the platform dev-buy ceiling; for " + "a larger seed, launch then follow with candle_trade.")
509
+ };
510
+ var transferShape = {
511
+ chain: z.enum(["solana", "hood"]).describe("Which chain the transfer executes on"),
512
+ asset: z.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).optional().describe("A base asset key. Pass exactly one of asset or mint."),
513
+ mint: z.string().optional().describe("An arbitrary token: SPL mint (Solana) or ERC-20 contract (Hood). Own-wallet destinations only; withdrawals to approved addresses are base-assets-only."),
514
+ amountRaw: z.string().describe('RAW base units as a decimal string (lamports, wei, token raw units), or "max" to sweep the spendable balance. NOT a human decimal: 1 SOL is "1000000000".'),
515
+ to: z.string().describe("Destination address. Must be one of the account's own wallets, or an address the OWNER pre-approved as a withdrawal address in the Candle console -- anything else is refused before signing."),
516
+ clientTransferId: z.string().optional().describe("Caller-chosen idempotency key for safe retries")
517
+ };
518
+ var sweepShape = {
519
+ chain: z.enum(["solana", "hood"]).describe("Which chain to sweep"),
520
+ to: z.string().describe("Destination address, same rules as candle_transfer's `to`"),
521
+ mints: z.array(z.string()).optional().describe("Extra token mints/contracts to sweep besides the chain's base assets (own-wallet destinations only).")
522
+ };
523
+ function registerTools(server, env = process.env) {
524
+ const cfg = resolveConfig();
525
+ const allowed = resolveToolAllowlist(env);
526
+ const register = (name, ...rest) => {
527
+ if (!allowed.has(name))
528
+ return;
529
+ return server.registerTool(name, ...rest);
530
+ };
531
+ register("candle_launch_token", {
532
+ title: "Launch a token on Candle",
533
+ description: "Launch a new token via the Candle headless launch API. Set dryRun: true to validate without spending anything.",
534
+ inputSchema: launchTokenShape
535
+ }, async (args) => callAndRelay("candle_launch_token", args, cfg));
536
+ register("candle_get_market", {
537
+ title: "Get market state",
538
+ description: "Read the current market state for a token: lifecycle, pool address, whether buys are open.",
539
+ inputSchema: getMarketShape
540
+ }, async (args) => callAndRelay("candle_get_market", args, cfg));
541
+ register("candle_get_feed", {
542
+ title: "Get a token feed",
543
+ description: "Read one of the trade page's public feeds: new, graduated, onfire, or bluechip.",
544
+ inputSchema: getFeedShape
545
+ }, async (args) => callAndRelay("candle_get_feed", args, cfg));
546
+ register("candle_report_activity", {
547
+ title: "Report on-chain activity",
548
+ description: "Report a client-executed transaction (transfer, swap, stake) so Candle records and verifies it.",
549
+ inputSchema: reportActivityShape
550
+ }, async (args) => callAndRelay("candle_report_activity", args, cfg));
551
+ register("candle_get_agent_profile", {
552
+ title: "Get an agent profile",
553
+ description: "Read a Candle user's public agent profile: whether agent features are enabled and launch counts.",
554
+ inputSchema: getAgentProfileShape
555
+ }, async (args) => callAndRelay("candle_get_agent_profile", args, cfg));
556
+ register("candle_swap", {
557
+ title: "Convert between base assets",
558
+ description: "Convert one base asset into another through the account's own embedded wallets. MOVES " + "REAL FUNDS. A pair spanning the Solana side (SOL/USDC/CNDL) and the Hood side (ETH/USDG) " + "routes through the bridge, so this is how a Hood wallet gets funded before launching or " + "trading on hood. Amounts are RAW base units, not decimal. Test-environment keys are " + "refused: every leg settles on a live venue.",
559
+ inputSchema: swapShape
560
+ }, async (args) => callAndRelay("candle_swap", args, cfg));
561
+ register("candle_transfer", {
562
+ title: "Transfer an asset",
563
+ description: "Move an asset from the account's embedded wallet to one of the account's own wallets, or to an owner-approved withdrawal address. amountRaw 'max' sweeps the spendable balance of that asset.",
564
+ inputSchema: transferShape
565
+ }, async (args) => callAndRelay("candle_transfer", args, cfg));
566
+ register("candle_sweep", {
567
+ title: "Sweep a wallet",
568
+ description: "Sweep the embedded wallet on one chain to a destination: every base asset (plus any explicitly named mints), one transfer per asset with amountRaw 'max'. Assets with nothing spendable are reported as empty, not errors.",
569
+ inputSchema: sweepShape
570
+ }, async (args) => {
571
+ const result = await executeSweep(args, cfg, fetch);
572
+ return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
573
+ });
574
+ register("candle_trade", {
575
+ title: "Buy or sell a token",
576
+ description: "Execute a buy or sell through the Candle trade rail. MOVES REAL FUNDS: the payer is the " + "account's embedded (main) wallet, executed server-side via delegation. Amounts are " + "decimal (never raw base units). Retry a timeout with the SAME clientTradeId from the " + "result; a new id is a second trade.",
577
+ inputSchema: tradeShape
578
+ }, async (args) => {
579
+ const result = await executeTrade(args, cfg, fetch);
580
+ return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
581
+ });
582
+ register("candle_launch_and_seed", {
583
+ title: "Launch a token and seed it",
584
+ description: "Launch a new token with an optional dev-buy seed bundled into the launch itself, then " + "return the fresh market state and token links in one result. MOVES REAL FUNDS unless " + "dryRun. Seeds above the platform dev-buy ceiling are rejected (DEV_BUY_TOO_HIGH); " + "launch, then top up with candle_trade.",
585
+ inputSchema: launchAndSeedShape
586
+ }, async (args) => {
587
+ const result = await executeLaunchAndSeed(args, cfg, fetch);
588
+ return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
589
+ });
590
+ }
591
+
592
+ // src/version.ts
593
+ var SERVER_VERSION = "0.3.0";
594
+
595
+ // src/index.ts
596
+ var server = new McpServer({ name: "candle-mcp", version: SERVER_VERSION });
597
+ registerTools(server);
598
+ var transport = new StdioServerTransport;
599
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@candledottv/mcp",
3
+ "version": "0.3.0",
4
+ "description": "MCP server for the Candle agent rail: launch tokens, read markets, report activity",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://docs.candle.tv/developers/mcp-server",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/candledottv/agentic.git",
11
+ "directory": "packages/mcp"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/candledottv/agentic/issues"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "candle",
20
+ "solana",
21
+ "token-launch",
22
+ "ai-agent",
23
+ "agent"
24
+ ],
25
+ "bin": {
26
+ "candle-mcp": "./dist/index.js"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "registry": "https://registry.npmjs.org"
39
+ },
40
+ "scripts": {
41
+ "build": "bun build src/index.ts --target=node --format=esm --packages=external --outfile=dist/index.js",
42
+ "prepublishOnly": "bun run build",
43
+ "test": "bun test",
44
+ "typecheck": "tsc --noEmit",
45
+ "lint": "biome check .",
46
+ "clean": "rm -rf dist"
47
+ },
48
+ "dependencies": {
49
+ "@modelcontextprotocol/sdk": "1.22.0",
50
+ "zod": "^3.23.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/bun": "^1.1.14",
54
+ "typescript": "^5.7.2"
55
+ }
56
+ }