@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.
- package/dist/index.js +106 -46
- 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.
|
|
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",
|
|
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
|
|
266
|
-
const tooLarge = () => new ImageRefusal("IMAGE_TOO_LARGE", `The file at
|
|
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
|
|
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
|
|
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":
|
|
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
|
|
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:
|
|
357
|
+
message: `Candle did not return an https URL for the uploaded ${kind}. No launch was attempted.`
|
|
383
358
|
}
|
|
384
359
|
};
|
|
385
|
-
return {
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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({
|
|
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
|
|
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);
|