@candledottv/mcp 0.10.19 → 0.10.20

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 (2) hide show
  1. package/dist/index.js +106 -46
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -151,7 +151,7 @@ import { lstat, open, realpath } from "node:fs/promises";
151
151
  import { dirname, isAbsolute, relative, sep } from "node:path";
152
152
 
153
153
  // src/version.ts
154
- var SERVER_VERSION = "0.10.19";
154
+ var SERVER_VERSION = "0.10.20";
155
155
 
156
156
  // src/update-notice.ts
157
157
  var PLAIN_VERSION = /^\d+\.\d+\.\d+$/;
@@ -257,13 +257,13 @@ async function missingInside(dirReal, path, fs) {
257
257
  }
258
258
  }
259
259
  }
260
- async function readImageInDir(imageDir, imagePath, fs = nodeFs) {
260
+ async function readImageInDir(imageDir, imagePath, fs = nodeFs, field = "imagePath") {
261
261
  if (!isAbsolute(imagePath))
262
- 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}`);
262
+ throw new ImageRefusal("MCP_VALIDATION", `${field} must be an absolute path inside CANDLE_IMAGE_DIR; this server's working directory is not something a caller can know. ${NOTHING_UPLOADED}`);
263
263
  const dirReal = await realImageDir(imageDir, fs);
264
264
  const outside = () => new ImageRefusal("IMAGE_OUTSIDE_DIR", OUTSIDE_IMAGE_DIR);
265
- const unreadable = (why) => new ImageRefusal("IMAGE_UNREADABLE", `Could not read the image at imagePath: ${why}. ${NOTHING_UPLOADED}`);
266
- const tooLarge = () => new ImageRefusal("IMAGE_TOO_LARGE", `The file at imagePath is over 10MB, the largest image Candle hosts. ${NOTHING_UPLOADED}`);
265
+ const unreadable = (why) => new ImageRefusal("IMAGE_UNREADABLE", `Could not read the image at ${field}: ${why}. ${NOTHING_UPLOADED}`);
266
+ const tooLarge = () => new ImageRefusal("IMAGE_TOO_LARGE", `The file at ${field} is over 10MB, the largest image Candle hosts. ${NOTHING_UPLOADED}`);
267
267
  let fileReal;
268
268
  try {
269
269
  fileReal = await fs.realpath(imagePath);
@@ -321,47 +321,22 @@ async function readImageInDir(imageDir, imagePath, fs = nodeFs) {
321
321
  }
322
322
  const contentType = sniffImageType(bytes);
323
323
  if (!contentType)
324
- 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}`);
324
+ throw new ImageRefusal("IMAGE_TYPE_UNSUPPORTED", `The file at ${field} is not a PNG, JPEG, GIF or WebP image. The type is read from the file's first bytes, not its name. ${NOTHING_UPLOADED}`);
325
325
  return { bytes, contentType };
326
326
  }
327
- async function resolveLaunchImage(args, imageDir, cfg, doFetch, fs = nodeFs) {
328
- const { imagePath, ...rest } = args;
329
- const hasPath = typeof imagePath === "string" && imagePath.length > 0;
330
- const hasUrl = typeof rest.imageUrl === "string" && rest.imageUrl.length > 0;
331
- const invalid = (message) => ({ refused: { code: "MCP_VALIDATION", message } });
332
- if (imageDir === undefined) {
333
- if (imagePath !== undefined)
334
- return invalid(`imagePath is not available on this server: no CANDLE_IMAGE_DIR is configured. Pass imageUrl, an https URL. ${NOTHING_UPLOADED}`);
335
- return { args: rest };
336
- }
337
- if (hasPath && hasUrl)
338
- return invalid(`Pass exactly one of imageUrl or imagePath, not both. ${NOTHING_UPLOADED}`);
339
- if (!hasPath && !hasUrl)
340
- return invalid("Pass imageUrl (an https URL) or imagePath (an absolute path to an image file inside CANDLE_IMAGE_DIR).");
341
- if (!hasPath)
342
- return { args: rest };
343
- if (!cfg.apiKey)
344
- return invalid(`CANDLE_AGENT_API_KEY is required to upload an image; the key needs the launch:write scope. ${NOTHING_UPLOADED}`);
345
- let image;
346
- try {
347
- image = await readImageInDir(imageDir, imagePath, fs);
348
- } catch (error) {
349
- if (error instanceof ImageRefusal)
350
- return { refused: { code: error.code, message: error.message } };
351
- throw error;
352
- }
327
+ async function uploadLocalImage(image, kind, cfg, apiKey, doFetch) {
353
328
  let res;
354
329
  try {
355
- res = await doFetch(`${cfg.apiUrl.replace(/\/$/, "")}/api/v1/uploads/agent-image`, {
330
+ res = await doFetch(`${cfg.apiUrl.replace(/\/$/, "")}/api/v1/uploads/agent-image${kind === "banner" ? "?kind=banner" : ""}`, {
356
331
  method: "POST",
357
- headers: { "Content-Type": image.contentType, "x-api-key": cfg.apiKey },
332
+ headers: { "Content-Type": image.contentType, "x-api-key": apiKey },
358
333
  body: image.bytes
359
334
  });
360
335
  } catch (error) {
361
336
  return {
362
337
  refused: {
363
338
  code: "MCP_TRANSPORT",
364
- 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."
339
+ message: `The ${kind} 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."
365
340
  }
366
341
  };
367
342
  }
@@ -379,15 +354,92 @@ async function resolveLaunchImage(args, imageDir, cfg, doFetch, fs = nodeFs) {
379
354
  return {
380
355
  refused: {
381
356
  code: "MCP_TRANSPORT",
382
- message: "Candle did not return an https URL for the uploaded image. No launch was attempted."
357
+ message: `Candle did not return an https URL for the uploaded ${kind}. No launch was attempted.`
383
358
  }
384
359
  };
385
- return { args: { ...rest, imageUrl: uploaded }, uploaded };
360
+ return { url: uploaded };
361
+ }
362
+ async function resolveLaunchImage(args, imageDir, cfg, doFetch, fs = nodeFs) {
363
+ const { imagePath, bannerPath, ...rest } = args;
364
+ const hasPath = typeof imagePath === "string" && imagePath.length > 0;
365
+ const hasUrl = typeof rest.imageUrl === "string" && rest.imageUrl.length > 0;
366
+ const hasBannerPath = typeof bannerPath === "string" && bannerPath.length > 0;
367
+ const hasBannerUrl = typeof rest.bannerUrl === "string" && rest.bannerUrl.length > 0;
368
+ const invalid = (message) => ({ refused: { code: "MCP_VALIDATION", message } });
369
+ if (imageDir === undefined) {
370
+ if (imagePath !== undefined)
371
+ return invalid(`imagePath is not available on this server: no CANDLE_IMAGE_DIR is configured. Pass imageUrl, an https URL. ${NOTHING_UPLOADED}`);
372
+ if (bannerPath !== undefined)
373
+ return invalid(`bannerPath is not available on this server: no CANDLE_IMAGE_DIR is configured. Pass bannerUrl, an https URL. ${NOTHING_UPLOADED}`);
374
+ return { args: rest };
375
+ }
376
+ if (hasPath && hasUrl)
377
+ return invalid(`Pass exactly one of imageUrl or imagePath, not both. ${NOTHING_UPLOADED}`);
378
+ if (hasBannerPath && hasBannerUrl)
379
+ return invalid(`Pass at most one of bannerUrl or bannerPath, not both. ${NOTHING_UPLOADED}`);
380
+ if (!hasPath && !hasUrl)
381
+ return invalid("Pass imageUrl (an https URL) or imagePath (an absolute path to an image file inside CANDLE_IMAGE_DIR).");
382
+ if (!hasPath && !hasBannerPath)
383
+ return { args: rest };
384
+ if (!cfg.apiKey)
385
+ return invalid(`CANDLE_AGENT_API_KEY is required to upload an image; the key needs the launch:write scope. ${NOTHING_UPLOADED}`);
386
+ const read = async (path, field) => {
387
+ try {
388
+ return await readImageInDir(imageDir, path, fs, field);
389
+ } catch (error) {
390
+ if (error instanceof ImageRefusal)
391
+ return { refused: { code: error.code, message: error.message } };
392
+ throw error;
393
+ }
394
+ };
395
+ let image;
396
+ let banner;
397
+ if (hasPath) {
398
+ const result = await read(imagePath, "imagePath");
399
+ if ("refused" in result)
400
+ return result;
401
+ image = result;
402
+ }
403
+ if (hasBannerPath) {
404
+ const result = await read(bannerPath, "bannerPath");
405
+ if ("refused" in result)
406
+ return result;
407
+ banner = result;
408
+ }
409
+ const launchArgs = { ...rest };
410
+ let uploaded;
411
+ let uploadedBanner;
412
+ if (image) {
413
+ const result = await uploadLocalImage(image, "image", cfg, cfg.apiKey, doFetch);
414
+ if (!("url" in result))
415
+ return result;
416
+ uploaded = result.url;
417
+ launchArgs.imageUrl = uploaded;
418
+ }
419
+ if (banner) {
420
+ const result = await uploadLocalImage(banner, "banner", cfg, cfg.apiKey, doFetch);
421
+ if (!("url" in result)) {
422
+ if (uploaded === undefined)
423
+ return result;
424
+ const why = "refused" in result ? result.refused.message : result.uploadFailed;
425
+ return invalid(`The banner upload failed: ${why} The image was already uploaded to ${uploaded}; pass that as imageUrl on the retry. No launch was attempted.`);
426
+ }
427
+ uploadedBanner = result.url;
428
+ launchArgs.bannerUrl = uploadedBanner;
429
+ }
430
+ return { args: launchArgs, ...uploaded ? { uploaded } : {}, ...uploadedBanner ? { uploadedBanner } : {} };
386
431
  }
387
432
  function imageRefusalBody(refused, extra) {
388
433
  return JSON.stringify({ ...extra, success: false, error: { code: refused.code, message: refused.message } });
389
434
  }
390
435
  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.";
436
+ var UPLOADED_BANNER_NOTE = "bannerPath was uploaded to bannerUrl, which is public and permanent. To retry, or to launch after a dry run, pass this bannerUrl instead of bannerPath, so the file is not uploaded again.";
437
+ function uploadedFields(image) {
438
+ return {
439
+ ...image.uploaded !== undefined ? { imageUrl: image.uploaded, imageNote: UPLOADED_IMAGE_NOTE } : {},
440
+ ...image.uploadedBanner !== undefined ? { bannerUrl: image.uploadedBanner, bannerNote: UPLOADED_BANNER_NOTE } : {}
441
+ };
442
+ }
391
443
 
392
444
  // src/orchestrate.ts
393
445
  import { randomUUID } from "node:crypto";
@@ -665,7 +717,7 @@ async function executeLaunchAndSeed(args, cfg, doFetch, imageDir, imageFs) {
665
717
  }
666
718
  return { text: JSON.stringify({ clientLaunchId, api }), isError: true };
667
719
  }
668
- const uploaded = image.uploaded === undefined ? {} : { imageUrl: image.uploaded, imageNote: UPLOADED_IMAGE_NOTE };
720
+ const uploaded = uploadedFields(image);
669
721
  const path = dryRun ? "/api/v1/launch/headless/dry-run" : "/api/v1/launch/headless";
670
722
  const posted = await postJson(`${base(cfg)}${path}`, apiKey, {
671
723
  ...image.args,
@@ -674,7 +726,7 @@ async function executeLaunchAndSeed(args, cfg, doFetch, imageDir, imageFs) {
674
726
  }, doFetch);
675
727
  if ("thrown" in posted) {
676
728
  const failed = transportError("clientLaunchId", clientLaunchId, posted.thrown);
677
- return image.uploaded === undefined ? failed : { ...failed, text: JSON.stringify({ ...JSON.parse(failed.text), ...uploaded }) };
729
+ return Object.keys(uploaded).length === 0 ? failed : { ...failed, text: JSON.stringify({ ...JSON.parse(failed.text), ...uploaded }) };
678
730
  }
679
731
  if (!posted.ok) {
680
732
  return { text: JSON.stringify({ clientLaunchId, ...uploaded, api: posted.body }), isError: true };
@@ -4280,7 +4332,8 @@ async function executePerpsDeposit(args, cfg, env, fetch2) {
4280
4332
  asset,
4281
4333
  amountRaw,
4282
4334
  ...typeof args.maxSlippageBps === "number" ? { maxSlippageBps: args.maxSlippageBps } : {},
4283
- ...hood ? {} : { perpsWalletId: account.id }
4335
+ ...hood ? {} : { perpsWalletId: account.id },
4336
+ ...willSubmit ? {} : { preview: true }
4284
4337
  });
4285
4338
  if (build.job !== undefined)
4286
4339
  return { text: JSON.stringify(build) };
@@ -4289,6 +4342,8 @@ async function executePerpsDeposit(args, cfg, env, fetch2) {
4289
4342
  return fail("PERPS_BUILD_REFUSED", `Refused to sign this deposit: ${problem}. Nothing was signed.`);
4290
4343
  if (!willSubmit || signerPem === null)
4291
4344
  return { text: JSON.stringify({ ...build, signed: false, submitted: false }) };
4345
+ if (build.preview === true || typeof build.depositId !== "string")
4346
+ return fail("PERPS_BUILD_REFUSED", "Refused to sign this deposit: Candle answered a preview. Nothing was signed.");
4292
4347
  if (!appId || !source.privyWalletId)
4293
4348
  return fail("SIGNER_UNAVAILABLE", "The server did not return its relay identifiers.");
4294
4349
  const pem = signerPem;
@@ -4945,7 +5000,7 @@ var launchTokenShape = {
4945
5000
  name: z4.string().describe("Token name"),
4946
5001
  symbol: z4.string().describe("Token symbol"),
4947
5002
  imageUrl: z4.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"),
4948
- bannerUrl: z4.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"),
5003
+ bannerUrl: z4.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: send the wide original, not a cropped copy. Omit it and the strip " + "falls back to imageUrl"),
4949
5004
  chain: z4.string().optional().describe('"solana" or "hood"; defaults to solana'),
4950
5005
  quoteAsset: z4.string().optional().describe("Quote asset symbol; defaults per chain"),
4951
5006
  mode: z4.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. " + '"exp-custom" launches a Solana curve with your own V and G, given in curve, where the API has it ' + "switched on"),
@@ -5075,11 +5130,13 @@ var seedShape = {
5075
5130
  var launchAndSeedShape = { ...seedableLaunchShape, ...seedShape };
5076
5131
  var localImageShape = {
5077
5132
  imageUrl: launchTokenShape.imageUrl.optional().describe(`${launchTokenShape.imageUrl.description}. Pass this or imagePath, never both`),
5078
- imagePath: z4.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")
5133
+ imagePath: z4.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. This is the square token image: Candle crops " + "it to a square, so a wide banner belongs in bannerPath, not here"),
5134
+ bannerUrl: launchTokenShape.bannerUrl.describe(`${launchTokenShape.bannerUrl.description}. Pass this or bannerPath, never both`),
5135
+ bannerPath: z4.string().optional().describe("An absolute path, inside CANDLE_IMAGE_DIR, to WIDE banner artwork for the token page strip (wider than " + "1.5:1, e.g. 2171x724 or 1200x630). The file is uploaded uncropped and becomes public and permanent, " + "including on a dry run; PNG, JPEG, GIF or WebP, at most 10MB. A square image is refused with " + "BANNER_WRONG_SHAPE: send the wide original, not a cropped copy. Pass this or bannerUrl, never both. " + "The result carries the bannerUrl used: pass that on a retry instead of uploading the file again")
5079
5136
  };
5080
5137
  var localLaunchTokenShape = { ...launchTokenShape, ...localImageShape };
5081
5138
  var localLaunchAndSeedShape = { ...seedableLaunchShape, ...localImageShape, ...seedShape };
5082
- 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.";
5139
+ var IMAGE_PATH_DISCLOSURE = " With imagePath or bannerPath, the file is uploaded first and becomes public and permanent, on a dry run too; " + "nothing about a dry run keeps that file private. imagePath is the square token image (Candle crops it); " + "a wide banner goes in bannerPath, which is not cropped.";
5083
5140
  var transferShape = {
5084
5141
  chain: z4.enum(["solana", "hood"]).describe("Which chain the transfer executes on"),
5085
5142
  asset: z4.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).optional().describe("A base asset key. Pass exactly one of asset or mint."),
@@ -5134,9 +5191,12 @@ function registerToolSubset(server, options) {
5134
5191
  if ("uploadFailed" in image)
5135
5192
  return { content: [{ type: "text", text: image.uploadFailed }], isError: true };
5136
5193
  const relayed = await callAndRelay("candle_launch_token", image.args, getConfig(), doFetch);
5137
- if (image.uploaded === undefined)
5194
+ if (image.uploaded === undefined && image.uploadedBanner === undefined)
5138
5195
  return relayed;
5139
- const uploaded = JSON.stringify({ imageUrl: image.uploaded, note: UPLOADED_IMAGE_NOTE });
5196
+ const uploaded = JSON.stringify({
5197
+ ...image.uploaded !== undefined ? { imageUrl: image.uploaded, note: UPLOADED_IMAGE_NOTE } : {},
5198
+ ...image.uploadedBanner !== undefined ? { bannerUrl: image.uploadedBanner, bannerNote: UPLOADED_BANNER_NOTE } : {}
5199
+ });
5140
5200
  return { ...relayed, content: [...relayed.content, { type: "text", text: uploaded }] };
5141
5201
  });
5142
5202
  register("candle_get_market", {
@@ -5503,7 +5563,7 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
5503
5563
  }, perpsTool("candle_perps_reduce_only"));
5504
5564
  register("candle_perps_deposit", {
5505
5565
  title: "Deposit to Hyperliquid",
5506
- description: "MOVES REAL FUNDS unless submit is false. Deposit onto the key's Hyperliquid account through Relay: SOL or " + "USDC from its Solana TEE wallet, or ETH or USDG from its Hood TEE wallet, credited as Hyperliquid perps " + "USDC at the key's EVM TEE wallet's own address (Candle resolves it; no recipient is taken). Refused below a " + "floor that covers Hyperliquid's 1 USDC first-deposit charge. No Candle fee. Needs swap:write and a raw cap " + "on the asset, and CANDLE_KEY_SIGNER_PEM_FILE to sign. There is no withdrawal from Hyperliquid yet.",
5566
+ description: "MOVES REAL FUNDS unless submit is false. Deposit onto the key's Hyperliquid account through Relay: SOL or " + "USDC from its Solana TEE wallet, or ETH or USDG from its Hood TEE wallet, credited as Hyperliquid perps " + "USDC at the key's EVM TEE wallet's own address (Candle resolves it; no recipient is taken). Refused below a " + "floor that covers Hyperliquid's 1 USDC first-deposit charge. No Candle fee. Needs swap:write (any cap on " + "the asset still applies), and CANDLE_KEY_SIGNER_PEM_FILE to sign. There is no withdrawal from Hyperliquid yet.",
5507
5567
  inputSchema: perpsDepositShape
5508
5568
  }, async (args) => {
5509
5569
  const result = await executePerpsDeposit(args, getConfig(), env, doFetch);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@candledottv/mcp",
3
- "version": "0.10.19",
3
+ "version": "0.10.20",
4
4
  "description": "MCP server for the Candle agent rail: launch tokens, read markets, report activity",
5
5
  "type": "module",
6
6
  "license": "MIT",