@candledottv/mcp 0.10.10 → 0.10.12

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 +40 -5
  2. package/dist/index.js +302 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,7 +1,7 @@
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
- twenty-nine tools over stdio, so an MCP-capable agent can launch tokens (optionally seeded with a dev
4
+ thirty-one 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.
@@ -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 twenty-nine. An unknown name fails startup with the valid names in the message, rather
34
+ Unset means all thirty-one. 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
 
@@ -79,6 +79,10 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
79
79
  - `CANDLE_KEY_SIGNER_PEM_FILE` -- the perps write tools only: the path to the key signer's PEM
80
80
  (`candle tee signer new --out <pem>` writes one). The relay's authorization is signed with it;
81
81
  without it those tools refuse before anything is built.
82
+ - `CANDLE_IMAGE_DIR` -- the launch tools only: an absolute path to a directory of images you mean
83
+ to publish. Set, `candle_launch_token` and `candle_launch_and_seed` accept `imagePath` for a
84
+ file inside it; unset, they take `imageUrl` only and the server reads no image file. See
85
+ [Launching with an image file](#launching-with-an-image-file).
82
86
  - `CANDLE_HYPERLIQUID_BUILDER` / `CANDLE_HYPERLIQUID_NETWORK` -- the perps tools: pin Candle's
83
87
  Hyperliquid builder address for the pre-sign check, and set `testnet` to trade testnet.
84
88
  - `CANDLE_API_KEY` -- alias for `CANDLE_AGENT_API_KEY`, the same variable name the Candle CLI uses
@@ -88,7 +92,7 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
88
92
 
89
93
  | Tool | Description | REST call | Auth |
90
94
  | --- | --- | --- | --- |
91
- | `candle_launch_token` | Launch a token on Candle | `POST /api/v1/launch/headless` (or `/dry-run` when `dryRun: true`) | `CANDLE_AGENT_API_KEY` |
95
+ | `candle_launch_token` | Launch a token on Candle | `POST /api/v1/launch/headless` (or `/dry-run` when `dryRun: true`); with `imagePath`, `POST /api/v1/uploads/agent-image` first | `CANDLE_AGENT_API_KEY` |
92
96
  | `candle_get_market` | Get market state | `GET /api/v1/markets/:chain/:mint` | none |
93
97
  | `candle_get_feed` | Get a token feed | `GET /api/v1/markets/feed?bucket=...` | none |
94
98
  | `candle_token_forensics` | Deployer history, deploy-window buyers, sourced token safety flags | `GET /api/v1/markets/:chain/:mint/forensics` | none |
@@ -96,7 +100,7 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
96
100
  | `candle_get_agent_profile` | Get an agent profile | `GET /api/v1/users/:idOrWallet/agent` | none |
97
101
  | `candle_get_plans` | Plans: prices, fees, limits and what each can do, as JSON and a Markdown table | `GET /api/v1/agent/plans` | none |
98
102
  | `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`) |
99
- | `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` |
103
+ | `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`; with `imagePath`, `POST /api/v1/uploads/agent-image` first | `CANDLE_AGENT_API_KEY` |
100
104
  | `candle_swap` | Swap between base assets | `POST /api/v1/agent/swap` | `CANDLE_AGENT_API_KEY` (`swap:write`) |
101
105
  | `candle_transfer` | Transfer an asset | `POST /api/v1/agent/transfer` | `CANDLE_AGENT_API_KEY`, Withdraw (`transfer:write`) |
102
106
  | `candle_sweep` | Sweep a wallet to one destination | One `POST /api/v1/agent/transfer` per asset, `amountRaw: "max"` | `CANDLE_AGENT_API_KEY`, Withdraw (`transfer:write`) |
@@ -109,6 +113,8 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
109
113
  | `candle_get_portfolio` | What the account's embedded and TEE wallets hold on Solana and Hood (Hood in `hood`), with prices; unpriced holdings say why, never zero | `GET /api/v1/agent/portfolio` | `CANDLE_AGENT_API_KEY` (`account:read`) |
110
114
  | `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` |
111
115
  | `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` |
116
+ | `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`) |
117
+ | `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`) |
112
118
  | `candle_perps_setup` | Approve Candle's Hyperliquid builder fee once; report the account's mode and balance | `POST /api/v1/agent/perps/setup`, then relay sign and Hyperliquid submit | `CANDLE_AGENT_API_KEY` (`perps:write`), `CANDLE_KEY_SIGNER_PEM_FILE` |
113
119
  | `candle_perps_open` | Open or add to a perps position, with optional take-profit and stop-loss | `POST /api/v1/agent/perps/open`, checked here, relay-signed, submitted to Hyperliquid | `CANDLE_AGENT_API_KEY` (`perps:write`), `CANDLE_KEY_SIGNER_PEM_FILE` |
114
120
  | `candle_perps_close` | Close all or part of a perps position, reduce-only | `POST /api/v1/agent/perps/close`, the same | `CANDLE_AGENT_API_KEY` (`perps:write`), `CANDLE_KEY_SIGNER_PEM_FILE` |
@@ -118,6 +124,35 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
118
124
  | `candle_perps_orders` | Open Hyperliquid orders and every action Candle built | `GET /api/v1/agent/perps/orders` | `CANDLE_AGENT_API_KEY` |
119
125
  | `candle_perps_positions` | Perps positions and account value, read live | `GET /api/v1/agent/perps/positions` | `CANDLE_AGENT_API_KEY` |
120
126
 
127
+ ### Launching with an image file
128
+
129
+ A launch takes its image as `imageUrl`, an `https://` URL. With `CANDLE_IMAGE_DIR` set to an
130
+ absolute directory path, `candle_launch_token` and `candle_launch_and_seed` also accept
131
+ `imagePath`: an absolute path to a PNG, JPEG, GIF or WebP file of at most 10MB inside that
132
+ directory. The server uploads it through `POST /api/v1/uploads/agent-image` and launches with the
133
+ URL that comes back; the launch routes never see a path. Pass exactly one of `imageUrl` and
134
+ `imagePath`.
135
+
136
+ - **The directory is the operator's grant.** A file is read only when its real path is inside the
137
+ real path of `CANDLE_IMAGE_DIR`; a link that leads outside is refused, and every path outside
138
+ gets the same sentence (`IMAGE_OUTSIDE_DIR`) whether or not anything is there. Keep only images
139
+ you mean to publish in it.
140
+ - **An upload is public and permanent, on a dry run too.** `dryRun: true` uploads as well, because
141
+ the dry run fetches the image it validates. The URL cannot be withdrawn, and a square image
142
+ keeps its original bytes, metadata such as EXIF and GPS data included.
143
+ - **Checked before any request.** The type comes from the file's first bytes
144
+ (`IMAGE_TYPE_UNSUPPORTED`), over 10MB is `IMAGE_TOO_LARGE`, a missing or non-regular file is
145
+ `IMAGE_UNREADABLE`, and a `CANDLE_IMAGE_DIR` that is not an existing directory (or is the
146
+ filesystem root) refuses every `imagePath` with `IMAGE_DIR_INVALID`.
147
+ - **The result carries the `imageUrl` used.** Pass that on a retry, or after a dry run, instead of
148
+ uploading the file again.
149
+ - The upload needs `launch:write`, spends no funds and does not count against the daily launch
150
+ cap; the plan's uploads-per-minute limit applies. `bannerUrl` is always a URL.
151
+
152
+ The hosted server at `mcp.candle.tv` has no `imagePath`: it cannot read your disk. There, run
153
+ `candle image upload <path>` (Candle CLI 0.11.24 or later, with a `launch:write` key) and pass the
154
+ URL it prints as `imageUrl`.
155
+
121
156
  ### Transfers and sweeps
122
157
 
123
158
  `candle_transfer` moves one asset from the account's embedded wallet: to any of the account's
@@ -133,7 +168,7 @@ explicitly. Assets with nothing spendable report `empty`; a failed asset never s
133
168
  ## Errors
134
169
 
135
170
  This package never reinterprets an error body, and that body is not one uniform shape across all
136
- twenty-nine tools:
171
+ thirty-one tools:
137
172
 
138
173
  - `candle_launch_token`, `candle_get_market`, and `candle_get_feed` hit endpoints that use the
139
174
  structured envelope `{ success: false, error: { code, message, ... } }`. Branch on `error.code`.
package/dist/index.js CHANGED
@@ -105,11 +105,13 @@ function percentOfBalance(balanceRaw, percent) {
105
105
  return result.toString();
106
106
  }
107
107
 
108
- // src/orchestrate.ts
109
- import { randomUUID } from "node:crypto";
108
+ // src/image.ts
109
+ import { constants } from "node:fs";
110
+ import { lstat, open, realpath } from "node:fs/promises";
111
+ import { dirname, isAbsolute, relative, sep } from "node:path";
110
112
 
111
113
  // src/version.ts
112
- var SERVER_VERSION = "0.10.10";
114
+ var SERVER_VERSION = "0.10.12";
113
115
 
114
116
  // src/update-notice.ts
115
117
  var PLAIN_VERSION = /^\d+\.\d+\.\d+$/;
@@ -143,7 +145,212 @@ function updateAvailable() {
143
145
  };
144
146
  }
145
147
 
148
+ // src/image.ts
149
+ var MAX_IMAGE_BYTES = 10 * 1024 * 1024;
150
+ var OUTSIDE_IMAGE_DIR = "Image files must be inside the configured CANDLE_IMAGE_DIR.";
151
+ var NOTHING_UPLOADED = "Nothing was uploaded.";
152
+ var READ_CHUNK_BYTES = 256 * 1024;
153
+
154
+ class ImageRefusal extends Error {
155
+ code;
156
+ constructor(code, message) {
157
+ super(message);
158
+ this.code = code;
159
+ }
160
+ }
161
+ function configuredImageDir(env) {
162
+ return env.CANDLE_IMAGE_DIR?.trim() || undefined;
163
+ }
164
+ var nodeFs = { realpath, lstat, open };
165
+ function sniffImageType(bytes) {
166
+ const at = (offset, signature) => signature.every((byte, i) => bytes[offset + i] === byte);
167
+ if (at(0, [137, 80, 78, 71, 13, 10, 26, 10]))
168
+ return "image/png";
169
+ if (at(0, [255, 216, 255]))
170
+ return "image/jpeg";
171
+ if (at(0, [71, 73, 70, 56]) && (bytes[4] === 55 || bytes[4] === 57) && bytes[5] === 97)
172
+ return "image/gif";
173
+ if (at(0, [82, 73, 70, 70]) && at(8, [87, 69, 66, 80]))
174
+ return "image/webp";
175
+ return;
176
+ }
177
+ function isInside(dir, path) {
178
+ const rel = relative(dir, path);
179
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
180
+ }
181
+ function errno(error) {
182
+ return error?.code;
183
+ }
184
+ async function realImageDir(imageDir, fs) {
185
+ const invalid = () => new ImageRefusal("IMAGE_DIR_INVALID", `CANDLE_IMAGE_DIR must be an absolute path to an existing directory, and not the filesystem root. ${NOTHING_UPLOADED}`);
186
+ if (!isAbsolute(imageDir))
187
+ throw invalid();
188
+ try {
189
+ const real = await fs.realpath(imageDir);
190
+ if (dirname(real) === real || !(await fs.lstat(real, { bigint: true })).isDirectory())
191
+ throw invalid();
192
+ return real;
193
+ } catch {
194
+ throw invalid();
195
+ }
196
+ }
197
+ async function missingInside(dirReal, path, fs) {
198
+ const absent = (error) => errno(error) === "ENOENT" || errno(error) === "ENOTDIR";
199
+ for (let current = path;; ) {
200
+ try {
201
+ await fs.lstat(current, { bigint: true });
202
+ return false;
203
+ } catch (error) {
204
+ if (!absent(error))
205
+ return false;
206
+ }
207
+ const parent = dirname(current);
208
+ if (parent === current)
209
+ return false;
210
+ try {
211
+ const real = await fs.realpath(parent);
212
+ return real === dirReal || isInside(dirReal, real);
213
+ } catch (error) {
214
+ if (!absent(error))
215
+ return false;
216
+ current = parent;
217
+ }
218
+ }
219
+ }
220
+ async function readImageInDir(imageDir, imagePath, fs = nodeFs) {
221
+ if (!isAbsolute(imagePath))
222
+ throw new ImageRefusal("MCP_VALIDATION", `imagePath must be an absolute path inside CANDLE_IMAGE_DIR; this server's working directory is not something a caller can know. ${NOTHING_UPLOADED}`);
223
+ const dirReal = await realImageDir(imageDir, fs);
224
+ const outside = () => new ImageRefusal("IMAGE_OUTSIDE_DIR", OUTSIDE_IMAGE_DIR);
225
+ const unreadable = (why) => new ImageRefusal("IMAGE_UNREADABLE", `Could not read the image at imagePath: ${why}. ${NOTHING_UPLOADED}`);
226
+ const tooLarge = () => new ImageRefusal("IMAGE_TOO_LARGE", `The file at imagePath is over 10MB, the largest image Candle hosts. ${NOTHING_UPLOADED}`);
227
+ let fileReal;
228
+ try {
229
+ fileReal = await fs.realpath(imagePath);
230
+ } catch {
231
+ throw await missingInside(dirReal, imagePath, fs) ? unreadable("there is no file at that path") : outside();
232
+ }
233
+ if (!isInside(dirReal, fileReal))
234
+ throw outside();
235
+ let handle;
236
+ try {
237
+ handle = await fs.open(fileReal, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
238
+ } catch (error) {
239
+ throw errno(error) === "ELOOP" ? outside() : unreadable("it could not be opened");
240
+ }
241
+ let bytes;
242
+ try {
243
+ const opened = await handle.stat({ bigint: true });
244
+ let named;
245
+ try {
246
+ if (await fs.realpath(fileReal) !== fileReal || !isInside(await realImageDir(imageDir, fs), fileReal))
247
+ throw outside();
248
+ named = await fs.lstat(fileReal, { bigint: true });
249
+ } catch {
250
+ throw outside();
251
+ }
252
+ if (named.dev !== opened.dev || named.ino !== opened.ino)
253
+ throw outside();
254
+ if (!opened.isFile())
255
+ throw unreadable("it is not a regular file");
256
+ const chunks = [];
257
+ let total = 0;
258
+ try {
259
+ while (total <= MAX_IMAGE_BYTES) {
260
+ const want = Math.min(READ_CHUNK_BYTES, MAX_IMAGE_BYTES + 1 - total);
261
+ const chunk = new Uint8Array(want);
262
+ const { bytesRead } = await handle.read(chunk, 0, want, null);
263
+ if (bytesRead === 0)
264
+ break;
265
+ chunks.push(chunk.subarray(0, bytesRead));
266
+ total += bytesRead;
267
+ }
268
+ } catch {
269
+ throw unreadable("the read failed");
270
+ }
271
+ if (total > MAX_IMAGE_BYTES)
272
+ throw tooLarge();
273
+ bytes = new Uint8Array(total);
274
+ let offset = 0;
275
+ for (const chunk of chunks) {
276
+ bytes.set(chunk, offset);
277
+ offset += chunk.length;
278
+ }
279
+ } finally {
280
+ await handle.close().catch(() => {});
281
+ }
282
+ const contentType = sniffImageType(bytes);
283
+ if (!contentType)
284
+ throw new ImageRefusal("IMAGE_TYPE_UNSUPPORTED", `The file at imagePath is not a PNG, JPEG, GIF or WebP image. The type is read from the file's first bytes, not its name. ${NOTHING_UPLOADED}`);
285
+ return { bytes, contentType };
286
+ }
287
+ async function resolveLaunchImage(args, imageDir, cfg, doFetch, fs = nodeFs) {
288
+ const { imagePath, ...rest } = args;
289
+ const hasPath = typeof imagePath === "string" && imagePath.length > 0;
290
+ const hasUrl = typeof rest.imageUrl === "string" && rest.imageUrl.length > 0;
291
+ const invalid = (message) => ({ refused: { code: "MCP_VALIDATION", message } });
292
+ if (imageDir === undefined) {
293
+ if (imagePath !== undefined)
294
+ return invalid(`imagePath is not available on this server: no CANDLE_IMAGE_DIR is configured. Pass imageUrl, an https URL. ${NOTHING_UPLOADED}`);
295
+ return { args: rest };
296
+ }
297
+ if (hasPath && hasUrl)
298
+ return invalid(`Pass exactly one of imageUrl or imagePath, not both. ${NOTHING_UPLOADED}`);
299
+ if (!hasPath && !hasUrl)
300
+ return invalid("Pass imageUrl (an https URL) or imagePath (an absolute path to an image file inside CANDLE_IMAGE_DIR).");
301
+ if (!hasPath)
302
+ return { args: rest };
303
+ if (!cfg.apiKey)
304
+ return invalid(`CANDLE_AGENT_API_KEY is required to upload an image; the key needs the launch:write scope. ${NOTHING_UPLOADED}`);
305
+ let image;
306
+ try {
307
+ image = await readImageInDir(imageDir, imagePath, fs);
308
+ } catch (error) {
309
+ if (error instanceof ImageRefusal)
310
+ return { refused: { code: error.code, message: error.message } };
311
+ throw error;
312
+ }
313
+ let res;
314
+ try {
315
+ res = await doFetch(`${cfg.apiUrl.replace(/\/$/, "")}/api/v1/uploads/agent-image`, {
316
+ method: "POST",
317
+ headers: { "Content-Type": image.contentType, "x-api-key": cfg.apiKey },
318
+ body: image.bytes
319
+ });
320
+ } catch (error) {
321
+ return {
322
+ refused: {
323
+ code: "MCP_TRANSPORT",
324
+ message: `The image upload failed in transit (${error instanceof Error ? error.message : String(error)}). ` + "No launch was attempted. The upload may or may not have reached Candle; if it did, the file is public."
325
+ }
326
+ };
327
+ }
328
+ noteVersionHeaders(res);
329
+ const text = await res.text();
330
+ if (!res.ok)
331
+ return { uploadFailed: text };
332
+ let uploaded;
333
+ try {
334
+ uploaded = JSON.parse(text)?.imageUrl;
335
+ } catch {
336
+ uploaded = undefined;
337
+ }
338
+ if (typeof uploaded !== "string" || !uploaded.startsWith("https://"))
339
+ return {
340
+ refused: {
341
+ code: "MCP_TRANSPORT",
342
+ message: "Candle did not return an https URL for the uploaded image. No launch was attempted."
343
+ }
344
+ };
345
+ return { args: { ...rest, imageUrl: uploaded }, uploaded };
346
+ }
347
+ function imageRefusalBody(refused, extra) {
348
+ return JSON.stringify({ ...extra, success: false, error: { code: refused.code, message: refused.message } });
349
+ }
350
+ var UPLOADED_IMAGE_NOTE = "imagePath was uploaded to imageUrl, which is public and permanent. To retry, or to launch after a dry run, pass this imageUrl instead of imagePath, so the file is not uploaded again.";
351
+
146
352
  // src/orchestrate.ts
353
+ import { randomUUID } from "node:crypto";
147
354
  function requireApiKey(cfg) {
148
355
  if (!cfg.apiKey) {
149
356
  throw new Error("CANDLE_AGENT_API_KEY is required for this tool. Set it in the environment or MCP client config.");
@@ -391,10 +598,10 @@ async function executeTrade(args, cfg, doFetch) {
391
598
  const wrapped = JSON.stringify({ clientTradeId, resolved, api: posted.body });
392
599
  return posted.ok ? { text: wrapped } : { text: wrapped, isError: true };
393
600
  }
394
- async function executeLaunchAndSeed(args, cfg, doFetch) {
601
+ async function executeLaunchAndSeed(args, cfg, doFetch, imageDir, imageFs) {
395
602
  const apiKey = requireApiKey(cfg);
396
603
  const clientLaunchId = args.clientLaunchId ?? randomUUID();
397
- const { devBuy, dryRun, buyAmount: _rawBuyAmount, ...launchFields } = args;
604
+ const { devBuy, dryRun, buyAmount: _rawBuyAmount, ...imageFields } = args;
398
605
  let buyAmount;
399
606
  if (devBuy !== undefined) {
400
607
  const q = quoteIdDecimals(args.quoteAsset, args.chain, { clientLaunchId });
@@ -405,19 +612,34 @@ async function executeLaunchAndSeed(args, cfg, doFetch) {
405
612
  return converted.err;
406
613
  buyAmount = converted.raw;
407
614
  }
615
+ const image = await resolveLaunchImage(imageFields, imageDir, cfg, doFetch, imageFs);
616
+ if ("refused" in image)
617
+ return { text: imageRefusalBody(image.refused, { clientLaunchId }), isError: true };
618
+ if ("uploadFailed" in image) {
619
+ let api;
620
+ try {
621
+ api = JSON.parse(image.uploadFailed);
622
+ } catch {
623
+ api = image.uploadFailed;
624
+ }
625
+ return { text: JSON.stringify({ clientLaunchId, api }), isError: true };
626
+ }
627
+ const uploaded = image.uploaded === undefined ? {} : { imageUrl: image.uploaded, imageNote: UPLOADED_IMAGE_NOTE };
408
628
  const path = dryRun ? "/api/v1/launch/headless/dry-run" : "/api/v1/launch/headless";
409
629
  const posted = await postJson(`${base(cfg)}${path}`, apiKey, {
410
- ...launchFields,
630
+ ...image.args,
411
631
  clientLaunchId,
412
632
  ...buyAmount !== undefined ? { buyAmount } : {}
413
633
  }, doFetch);
414
- if ("thrown" in posted)
415
- return transportError("clientLaunchId", clientLaunchId, posted.thrown);
634
+ if ("thrown" in posted) {
635
+ const failed = transportError("clientLaunchId", clientLaunchId, posted.thrown);
636
+ return image.uploaded === undefined ? failed : { ...failed, text: JSON.stringify({ ...JSON.parse(failed.text), ...uploaded }) };
637
+ }
416
638
  if (!posted.ok) {
417
- return { text: JSON.stringify({ clientLaunchId, api: posted.body }), isError: true };
639
+ return { text: JSON.stringify({ clientLaunchId, ...uploaded, api: posted.body }), isError: true };
418
640
  }
419
641
  if (dryRun) {
420
- return { text: JSON.stringify({ clientLaunchId, dryRun: true, api: posted.body }) };
642
+ return { text: JSON.stringify({ clientLaunchId, dryRun: true, ...uploaded, api: posted.body }) };
421
643
  }
422
644
  const launch = posted.body;
423
645
  let market = null;
@@ -438,7 +660,7 @@ async function executeLaunchAndSeed(args, cfg, doFetch) {
438
660
  note = "launch confirmed; the follow-up market read failed, read it via candle_get_market";
439
661
  }
440
662
  return {
441
- text: JSON.stringify({ clientLaunchId, launch, market, ...note ? { note } : {} })
663
+ text: JSON.stringify({ clientLaunchId, ...uploaded, launch, market, ...note ? { note } : {} })
442
664
  };
443
665
  }
444
666
  var SWEEP_BASE_ASSETS = {
@@ -1450,6 +1672,8 @@ var TOOL_NAMES = [
1450
1672
  "candle_resolve_token",
1451
1673
  "candle_execution_status",
1452
1674
  "candle_get_operation",
1675
+ "candle_get_fees",
1676
+ "candle_claim_fees",
1453
1677
  "candle_perps_setup",
1454
1678
  "candle_perps_open",
1455
1679
  "candle_perps_close",
@@ -1572,6 +1796,27 @@ function buildRequest(name, args, cfg) {
1572
1796
  const path = kind === "launch" ? `/api/v1/launch/headless/jobs/${encodeURIComponent(clientId)}` : `/api/v1/trade/agent/jobs/${encodeURIComponent(clientId)}`;
1573
1797
  return { url: `${base2}${path}`, init: { method: "GET", headers: jsonHeaders(apiKey) } };
1574
1798
  }
1799
+ case "candle_get_fees": {
1800
+ const apiKey = requireApiKey2(cfg);
1801
+ const { cursor, limit } = args;
1802
+ const params = new URLSearchParams;
1803
+ if (cursor !== undefined)
1804
+ params.set("cursor", cursor);
1805
+ if (limit !== undefined)
1806
+ params.set("limit", String(limit));
1807
+ return {
1808
+ url: `${base2}/api/v1/agent/fees${params.size ? `?${params}` : ""}`,
1809
+ init: { method: "GET", headers: jsonHeaders(apiKey) }
1810
+ };
1811
+ }
1812
+ case "candle_claim_fees": {
1813
+ const apiKey = requireApiKey2(cfg);
1814
+ const { mint, dryRun } = args;
1815
+ return {
1816
+ url: `${base2}/api/v1/agent/fees/${encodeURIComponent(mint)}/claim`,
1817
+ init: { method: "POST", headers: jsonHeaders(apiKey), body: JSON.stringify({ dryRun: dryRun === true }) }
1818
+ };
1819
+ }
1575
1820
  case "candle_get_wallets": {
1576
1821
  const apiKey = requireApiKey2(cfg);
1577
1822
  return {
@@ -1707,6 +1952,14 @@ var getOperationShape = {
1707
1952
  clientId: z2.string().describe("The clientTradeId or clientLaunchId the write used, or that the tool echoed back to you"),
1708
1953
  kind: z2.enum(["trade", "launch"]).describe("Which rail the id belongs to. Required: ids are caller-chosen strings with no shape to dispatch on")
1709
1954
  };
1955
+ var getFeesShape = {
1956
+ cursor: z2.string().optional().describe("The previous page's continueCursor, verbatim"),
1957
+ limit: z2.number().optional().describe("Tokens per page, 1 to 50 (default 20). Each one costs chain reads")
1958
+ };
1959
+ var claimFeesShape = {
1960
+ mint: z2.string().describe("The launched token: a Solana mint or a Hood token address, as candle_get_fees lists it"),
1961
+ dryRun: z2.boolean().optional().describe("Check and read only. Sends nothing and writes nothing. Use it first to see what would be claimed")
1962
+ };
1710
1963
  var resolveTokenShape = {
1711
1964
  mint: z2.string().describe("Token mint (Solana, base58) or contract address (Hood, 0x-prefixed)")
1712
1965
  };
@@ -1743,11 +1996,18 @@ var tradeShape = {
1743
1996
  paper: z2.preprocess((value) => value === "true" || value === 1 || value === "1" ? true : value === "false" || value === 0 || value === "0" ? false : value, z2.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."))
1744
1997
  };
1745
1998
  var { buyAmount: _rawBuyAmount, ...seedableLaunchShape } = launchTokenShape;
1746
- var launchAndSeedShape = {
1747
- ...seedableLaunchShape,
1999
+ var seedShape = {
1748
2000
  clientLaunchId: z2.string().optional().describe("Idempotency key. Auto-generated when omitted and echoed in the result."),
1749
2001
  devBuy: z2.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 on solana and sent as a follow-up " + "transaction on hood. Paid from the account's embedded wallet. 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.")
1750
2002
  };
2003
+ var launchAndSeedShape = { ...seedableLaunchShape, ...seedShape };
2004
+ var localImageShape = {
2005
+ imageUrl: launchTokenShape.imageUrl.optional().describe(`${launchTokenShape.imageUrl.description}. Pass this or imagePath, never both`),
2006
+ imagePath: z2.string().optional().describe("An absolute image path inside CANDLE_IMAGE_DIR. The file is uploaded and becomes public and permanent, " + "including on a dry run. PNG, JPEG, GIF or WebP, at most 10MB, read from the file's first bytes. " + "Uploading spends no funds and does not count against the launch cap; only the upload limit applies. " + "Pass this or imageUrl, never both. The result carries the imageUrl used: pass that on a retry, or " + "after a dry run, instead of uploading the file again. bannerUrl takes no file")
2007
+ };
2008
+ var localLaunchTokenShape = { ...launchTokenShape, ...localImageShape };
2009
+ var localLaunchAndSeedShape = { ...seedableLaunchShape, ...localImageShape, ...seedShape };
2010
+ var IMAGE_PATH_DISCLOSURE = " With imagePath, the image file is uploaded first and becomes public and permanent, on a dry run too; " + "nothing about a dry run keeps that file private.";
1751
2011
  var transferShape = {
1752
2012
  chain: z2.enum(["solana", "hood"]).describe("Which chain the transfer executes on"),
1753
2013
  asset: z2.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).optional().describe("A base asset key. Pass exactly one of asset or mint."),
@@ -1779,11 +2039,24 @@ function registerToolSubset(server, options) {
1779
2039
  return;
1780
2040
  server.registerTool(name, { ...definition, ...options.metadata?.[name] }, handler);
1781
2041
  };
2042
+ const imageDir = configuredImageDir(env);
1782
2043
  register("candle_launch_token", {
1783
2044
  title: "Launch a token on Candle",
1784
- description: "Launch a new token via the Candle headless launch API, from the account's embedded wallet. Works on every plan, Free included. Set dryRun: true to validate without spending anything.",
1785
- inputSchema: launchTokenShape
1786
- }, async (args) => callAndRelay("candle_launch_token", args, getConfig(), doFetch));
2045
+ description: "Launch a new token via the Candle headless launch API, from the account's embedded wallet. Works on every plan, Free included. Set dryRun: true to validate without spending anything." + (imageDir ? IMAGE_PATH_DISCLOSURE : ""),
2046
+ inputSchema: imageDir ? localLaunchTokenShape : launchTokenShape
2047
+ }, async (args) => {
2048
+ const image = await resolveLaunchImage(args, imageDir, getConfig(), doFetch);
2049
+ if ("refused" in image) {
2050
+ return { content: [{ type: "text", text: imageRefusalBody(image.refused) }], isError: true };
2051
+ }
2052
+ if ("uploadFailed" in image)
2053
+ return { content: [{ type: "text", text: image.uploadFailed }], isError: true };
2054
+ const relayed = await callAndRelay("candle_launch_token", image.args, getConfig(), doFetch);
2055
+ if (image.uploaded === undefined)
2056
+ return relayed;
2057
+ const uploaded = JSON.stringify({ imageUrl: image.uploaded, note: UPLOADED_IMAGE_NOTE });
2058
+ return { ...relayed, content: [...relayed.content, { type: "text", text: uploaded }] };
2059
+ });
1787
2060
  register("candle_get_market", {
1788
2061
  title: "Get market state",
1789
2062
  description: "Read Candle and indexed external markets, including indexed-but-not-routable tokens. No key needed. " + EXTERNAL_MARKET_DISCLOSURE + " " + "Read candleLaunched, launchpad, venue and trade.routable; jupiterOk and discovery flags are distinct. " + "Routability is stored eligibility, not a quote or permission. General quotes use POST /api/v1/trade/agent/quote. " + "Curve quotes and lifecycle describe Candle launches. MARKET_NOT_FOUND is a legacy code: read " + "error.routing.reason, error.discovery and sibling error.retryable. A curve-only 404 does not mean untradeable.",
@@ -1846,6 +2119,16 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
1846
2119
  ` + "Amounts come back RAW, deliberately: the answer you need after a timeout is the outcome, " + "and you already know what you asked for.",
1847
2120
  inputSchema: getOperationShape
1848
2121
  }, async (args) => callAndRelay("candle_get_operation", args, getConfig(), doFetch));
2122
+ register("candle_get_fees", {
2123
+ title: "Read the creator fees this account's tokens can pay",
2124
+ description: "The tokens launched by the account's own wallets, with what each could pay now in creator fees " + "(raw units from direct chain reads) and `claimable: true` or the `reason` it is not. Reads only; " + "moves nothing. It returns no earned or paid-out totals. An unreadable token is marked, never " + "shown as zero. Paged: pass `continueCursor` back as `cursor`.",
2125
+ inputSchema: getFeesShape
2126
+ }, async (args) => callAndRelay("candle_get_fees", args, getConfig(), doFetch));
2127
+ register("candle_claim_fees", {
2128
+ title: "Claim a token's creator fees",
2129
+ 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.",
2130
+ inputSchema: claimFeesShape
2131
+ }, async (args) => callAndRelay("candle_claim_fees", args, getConfig(), doFetch));
1849
2132
  register("candle_get_wallets", {
1850
2133
  title: "List the wallets Candle executes with",
1851
2134
  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.",
@@ -1947,10 +2230,10 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
1947
2230
  });
1948
2231
  register("candle_launch_and_seed", {
1949
2232
  title: "Launch a token and seed it",
1950
- description: "Launch a new token from the account's embedded wallet, with an optional dev-buy seed (in the " + "launch transaction on solana, a best-effort follow-up on hood). Works on every plan, Free included (this is not the atomic launch). 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.",
1951
- inputSchema: launchAndSeedShape
2233
+ description: "Launch a new token from the account's embedded wallet, with an optional dev-buy seed (in the " + "launch transaction on solana, a best-effort follow-up on hood). Works on every plan, Free included (this is not the atomic launch). 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." + (imageDir ? IMAGE_PATH_DISCLOSURE : ""),
2234
+ inputSchema: imageDir ? localLaunchAndSeedShape : launchAndSeedShape
1952
2235
  }, async (args) => {
1953
- const result = await executeLaunchAndSeed(args, getConfig(), doFetch);
2236
+ const result = await executeLaunchAndSeed(args, getConfig(), doFetch, imageDir);
1954
2237
  return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
1955
2238
  });
1956
2239
  const perpsTool = (tool) => async (args) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@candledottv/mcp",
3
- "version": "0.10.10",
3
+ "version": "0.10.12",
4
4
  "description": "MCP server for the Candle agent rail: launch tokens, read markets, report activity",
5
5
  "type": "module",
6
6
  "license": "MIT",