@candledottv/mcp 0.4.0 → 0.6.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 (3) hide show
  1. package/README.md +10 -3
  2. package/dist/index.js +200 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -25,8 +25,10 @@ server with no `env` block at all and those three work immediately:
25
25
  ```
26
26
 
27
27
  Add `CANDLE_AGENT_API_KEY` only once you're ready to launch, trade, or report activity, see
28
- Environment below -- or skip env editing entirely: after `npx @candledottv/cli auth login`,
29
- `candle mcp` launches this server with the stored key and API URL in its environment.
28
+ Environment below -- or skip env editing entirely: install the Candle CLI
29
+ (`curl -fsSL https://candle.tv/install.sh | bash` or `brew install candledottv/tap/candle`), run
30
+ `candle auth login`, and `candle mcp` launches this server with the stored key and API URL in its
31
+ environment.
30
32
 
31
33
  `CANDLE_MCP_TOOLS` (optional) is a comma-separated allowlist of tool names; only those register.
32
34
  Unset means all eight. An unknown name fails startup with the valid names in the message, rather
@@ -59,7 +61,12 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
59
61
  ## Environment
60
62
 
61
63
  - `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
62
- `http://localhost:3001` when developing against a local API.
64
+ `http://localhost:3001` when developing against a local API. A cleartext `http://` URL pointing
65
+ at a NON-loopback host is refused at startup, since every write tool sends `x-api-key`; loopback
66
+ (`localhost`, `127.0.0.0/8`, `::1`) needs no opt-in.
67
+ - `CANDLE_ALLOW_INSECURE_HTTP` -- set to any non-empty value to allow an `http://` API URL to a
68
+ non-loopback host. For a trusted local endpoint that is not loopback, such as a devcontainer
69
+ reaching its host; not for anything that leaves the machine.
63
70
  - `CANDLE_AGENT_API_KEY` -- an agent API key (`cndl_live_...` / `cndl_test_...`), issued from a
64
71
  Candle account's agent settings page. Only required by `candle_launch_token`,
65
72
  `candle_report_activity`, `candle_trade`, `candle_launch_and_seed`, and `candle_swap`; the three
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/index.ts
3
+ // src/server.ts
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
 
@@ -9,15 +9,39 @@ import { z } from "zod";
9
9
 
10
10
  // src/client.ts
11
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();
12
+ function isLoopbackHost(hostname) {
13
+ const host = hostname.toLowerCase();
14
+ if (host === "localhost" || host.endsWith(".localhost"))
15
+ return true;
16
+ if (host === "::1" || host === "[::1]")
17
+ return true;
18
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
19
+ }
20
+ function assertTransportSecurity(apiUrl, env) {
21
+ let parsed;
22
+ try {
23
+ parsed = new URL(apiUrl);
24
+ } catch {
25
+ throw new Error(`CANDLE_API_URL is not a valid URL: ${JSON.stringify(apiUrl)}`);
26
+ }
27
+ if (parsed.protocol === "https:")
28
+ return;
29
+ if (parsed.protocol !== "http:") {
30
+ throw new Error(`CANDLE_API_URL must be http or https, got ${parsed.protocol.replace(":", "")}`);
31
+ }
32
+ if (isLoopbackHost(parsed.hostname))
33
+ return;
34
+ if (env.CANDLE_ALLOW_INSECURE_HTTP?.trim())
35
+ return;
36
+ throw new Error(`Refusing to send credentials in the clear to ${parsed.origin}. Set CANDLE_API_URL to an https:// ` + "URL, or set CANDLE_ALLOW_INSECURE_HTTP=1 if this really is a trusted local endpoint.");
37
+ }
38
+ function resolveConfig(env = process.env) {
39
+ const apiUrl = env.CANDLE_API_URL?.trim() || DEFAULT_API_URL;
40
+ assertTransportSecurity(apiUrl, env);
41
+ const apiKey = env.CANDLE_AGENT_API_KEY?.trim() || env.CANDLE_API_KEY?.trim();
15
42
  return apiKey ? { apiUrl, apiKey } : { apiUrl };
16
43
  }
17
44
 
18
- // src/orchestrate.ts
19
- import { randomUUID } from "node:crypto";
20
-
21
45
  // src/convert.ts
22
46
  var QUOTE_DECIMALS = {
23
47
  sol: 9,
@@ -61,6 +85,7 @@ function percentOfBalance(balanceRaw, percent) {
61
85
  }
62
86
 
63
87
  // src/orchestrate.ts
88
+ import { randomUUID } from "node:crypto";
64
89
  function requireApiKey(cfg) {
65
90
  if (!cfg.apiKey) {
66
91
  throw new Error("CANDLE_AGENT_API_KEY is required for this tool. Set it in the environment or MCP client config.");
@@ -135,7 +160,7 @@ async function postJson(url, apiKey, payload, doFetch) {
135
160
  }
136
161
  }
137
162
  async function readMarket(mint, cfg, doFetch, extra) {
138
- const res = await doFetch(`${base(cfg)}/api/v1/markets/${chainForMint(mint)}/${mint}`, {
163
+ const res = await doFetch(`${base(cfg)}/api/v1/markets/${chainForMint(mint)}/${encodeURIComponent(mint)}`, {
139
164
  method: "GET",
140
165
  headers: headers()
141
166
  });
@@ -210,7 +235,7 @@ async function executeTrade(args, cfg, doFetch) {
210
235
  if (!address) {
211
236
  return errText(`percent sells need an embedded ${chain === "hood" ? "EVM" : "Solana"} wallet, and this account has none`, { clientTradeId });
212
237
  }
213
- const balRes = await doFetch(`${base(cfg)}/api/v1/tokens/${args.mint}/balance/${address}`, {
238
+ const balRes = await doFetch(`${base(cfg)}/api/v1/tokens/${encodeURIComponent(args.mint)}/balance/${encodeURIComponent(address)}`, {
214
239
  method: "GET",
215
240
  headers: headers()
216
241
  });
@@ -274,7 +299,9 @@ async function executeLaunchAndSeed(args, cfg, doFetch) {
274
299
  let market = null;
275
300
  let note;
276
301
  try {
277
- const marketRes = await doFetch(`${base(cfg)}/api/v1/markets/${launch.chain ?? "solana"}/${launch.mint}`, {
302
+ if (launch.mint === undefined)
303
+ throw new Error("the launch response carried no mint");
304
+ const marketRes = await doFetch(`${base(cfg)}/api/v1/markets/${encodeURIComponent(launch.chain ?? "solana")}/${encodeURIComponent(launch.mint)}`, {
278
305
  method: "GET",
279
306
  headers: headers()
280
307
  });
@@ -356,6 +383,48 @@ async function executeSweep(args, cfg, doFetch) {
356
383
  ...transferred === 0 && failed > 0 ? { isError: true } : {}
357
384
  };
358
385
  }
386
+ async function resolveToken(args, cfg, doFetch) {
387
+ const chain = chainForMint(args.mint);
388
+ const read = await readMarket(args.mint, cfg, doFetch, { mint: args.mint, chain });
389
+ if ("err" in read)
390
+ return read.err;
391
+ return { text: JSON.stringify({ success: true, chain, mint: args.mint, market: read.market }, null, 2) };
392
+ }
393
+ async function executionStatus(cfg, doFetch) {
394
+ const apiKey = requireApiKey(cfg);
395
+ const get = async (path) => {
396
+ const res = await doFetch(`${base(cfg)}${path}`, { method: "GET", headers: headers(apiKey) });
397
+ const text = await res.text();
398
+ let body;
399
+ try {
400
+ body = JSON.parse(text);
401
+ } catch {
402
+ body = text;
403
+ }
404
+ return { ok: res.ok, status: res.status, body };
405
+ };
406
+ const [wallets, tier, limits] = await Promise.all([
407
+ get("/api/v1/agent/wallets/embedded"),
408
+ get("/api/v1/agent/tier"),
409
+ get("/api/v1/agent/keys/self/limits")
410
+ ]);
411
+ const unreadable = [
412
+ ["wallets", wallets],
413
+ ["tier", tier],
414
+ ["limits", limits]
415
+ ].filter(([, r]) => !r.ok);
416
+ return {
417
+ text: JSON.stringify({
418
+ success: true,
419
+ ready: unreadable.length === 0 ? true : undefined,
420
+ unreadable: unreadable.length > 0 ? unreadable.map(([name]) => name) : undefined,
421
+ wallets: wallets.body,
422
+ tier: tier.body,
423
+ limits: limits.body
424
+ }, null, 2),
425
+ ...unreadable.length > 0 ? { isError: true } : {}
426
+ };
427
+ }
359
428
 
360
429
  // src/tools.ts
361
430
  var TOOL_NAMES = [
@@ -369,7 +438,11 @@ var TOOL_NAMES = [
369
438
  "candle_launch_and_seed",
370
439
  "candle_swap",
371
440
  "candle_transfer",
372
- "candle_sweep"
441
+ "candle_sweep",
442
+ "candle_get_wallets",
443
+ "candle_resolve_token",
444
+ "candle_execution_status",
445
+ "candle_get_operation"
373
446
  ];
374
447
  function resolveToolAllowlist(env) {
375
448
  const raw = env.CANDLE_MCP_TOOLS?.trim();
@@ -394,6 +467,25 @@ function jsonHeaders(apiKey) {
394
467
  headers2["x-api-key"] = apiKey;
395
468
  return headers2;
396
469
  }
470
+ function swapBody(args) {
471
+ const { amount, amountRaw, ...rest } = args;
472
+ const hasAmount = typeof amount === "string" && amount.length > 0;
473
+ const hasRaw = typeof amountRaw === "string" && amountRaw.length > 0;
474
+ if (hasAmount && hasRaw) {
475
+ throw new Error("Pass exactly one of amount or amountRaw, not both.");
476
+ }
477
+ if (!hasAmount && !hasRaw) {
478
+ throw new Error('Pass an amount, e.g. amount: "0.5".');
479
+ }
480
+ if (hasRaw)
481
+ return { ...rest, amountRaw };
482
+ const from = String(rest.from ?? "").toLowerCase();
483
+ const decimals = QUOTE_DECIMALS[from];
484
+ if (decimals === undefined) {
485
+ throw new Error(`Unknown decimals for base asset "${from}". Pass amountRaw instead.`);
486
+ }
487
+ return { ...rest, amountRaw: decimalToRaw(amount, decimals) };
488
+ }
397
489
  function buildRequest(name, args, cfg) {
398
490
  const base2 = cfg.apiUrl.replace(/\/$/, "");
399
491
  switch (name) {
@@ -408,12 +500,15 @@ function buildRequest(name, args, cfg) {
408
500
  }
409
501
  case "candle_get_market": {
410
502
  const { chain, mint } = args;
411
- return { url: `${base2}/api/v1/markets/${chain}/${mint}`, init: { method: "GET", headers: jsonHeaders() } };
503
+ return {
504
+ url: `${base2}/api/v1/markets/${encodeURIComponent(chain)}/${encodeURIComponent(mint)}`,
505
+ init: { method: "GET", headers: jsonHeaders() }
506
+ };
412
507
  }
413
508
  case "candle_token_forensics": {
414
509
  const { chain, mint } = args;
415
510
  return {
416
- url: `${base2}/api/v1/markets/${chain}/${mint}/forensics`,
511
+ url: `${base2}/api/v1/markets/${encodeURIComponent(chain)}/${encodeURIComponent(mint)}/forensics`,
417
512
  init: { method: "GET", headers: jsonHeaders() }
418
513
  };
419
514
  }
@@ -434,13 +529,29 @@ function buildRequest(name, args, cfg) {
434
529
  }
435
530
  case "candle_get_agent_profile": {
436
531
  const { idOrWallet } = args;
437
- return { url: `${base2}/api/v1/users/${idOrWallet}/agent`, init: { method: "GET", headers: jsonHeaders() } };
532
+ return {
533
+ url: `${base2}/api/v1/users/${encodeURIComponent(idOrWallet)}/agent`,
534
+ init: { method: "GET", headers: jsonHeaders() }
535
+ };
438
536
  }
439
537
  case "candle_swap": {
440
538
  const apiKey = requireApiKey2(cfg);
441
539
  return {
442
540
  url: `${base2}/api/v1/agent/swap`,
443
- init: { method: "POST", headers: jsonHeaders(apiKey), body: JSON.stringify(args) }
541
+ init: { method: "POST", headers: jsonHeaders(apiKey), body: JSON.stringify(swapBody(args)) }
542
+ };
543
+ }
544
+ case "candle_get_operation": {
545
+ const apiKey = requireApiKey2(cfg);
546
+ const { clientId, kind } = args;
547
+ const path = kind === "launch" ? `/api/v1/launch/headless/jobs/${encodeURIComponent(clientId)}` : `/api/v1/trade/agent/jobs/${encodeURIComponent(clientId)}`;
548
+ return { url: `${base2}${path}`, init: { method: "GET", headers: jsonHeaders(apiKey) } };
549
+ }
550
+ case "candle_get_wallets": {
551
+ const apiKey = requireApiKey2(cfg);
552
+ return {
553
+ url: `${base2}/api/v1/agent/wallets/embedded`,
554
+ init: { method: "GET", headers: jsonHeaders(apiKey) }
444
555
  };
445
556
  }
446
557
  case "candle_transfer": {
@@ -497,10 +608,18 @@ var reportActivityShape = {
497
608
  var getAgentProfileShape = {
498
609
  idOrWallet: z.string().describe("Candle username or wallet address")
499
610
  };
611
+ var getOperationShape = {
612
+ clientId: z.string().describe("The clientTradeId or clientLaunchId the write used, or that the tool echoed back to you"),
613
+ kind: z.enum(["trade", "launch"]).describe("Which rail the id belongs to. Required: ids are caller-chosen strings with no shape to dispatch on")
614
+ };
615
+ var resolveTokenShape = {
616
+ mint: z.string().describe("Token mint (Solana, base58) or contract address (Hood, 0x-prefixed)")
617
+ };
500
618
  var swapShape = {
501
619
  from: z.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).describe("Base asset to spend"),
502
620
  to: z.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).describe("Base asset to receive; must differ from `from`"),
503
- amountRaw: z.string().describe("Raw base units of `from` to spend, as a positive integer string"),
621
+ amount: z.string().optional().describe('Decimal amount of `from` to spend, e.g. "0.5". Preferred. Pass exactly one of amount or amountRaw.'),
622
+ amountRaw: z.string().optional().describe("Raw base units of `from`, as a positive integer string. Kept for callers that already " + "compute raw units; new callers should use `amount`."),
504
623
  maxSlippageBps: z.number().optional().describe("Slippage bound in bps, 0-10000. Server defaults to 100 (1%)"),
505
624
  clientSwapId: z.string().optional().describe("Optional dedup key. Only coalesces a duplicate that arrives while the first call is still " + "in flight; one arriving after it settled will swap again")
506
625
  };
@@ -533,7 +652,7 @@ var sweepShape = {
533
652
  mints: z.array(z.string()).optional().describe("Extra token mints/contracts to sweep besides the chain's base assets (own-wallet destinations only).")
534
653
  };
535
654
  function registerTools(server, env = process.env) {
536
- const cfg = resolveConfig();
655
+ const cfg = resolveConfig(env);
537
656
  const allowed = resolveToolAllowlist(env);
538
657
  const register = (name, ...rest) => {
539
658
  if (!allowed.has(name))
@@ -570,9 +689,52 @@ function registerTools(server, env = process.env) {
570
689
  description: "Read a Candle user's public agent profile: whether agent features are enabled and launch counts.",
571
690
  inputSchema: getAgentProfileShape
572
691
  }, async (args) => callAndRelay("candle_get_agent_profile", args, cfg));
692
+ register("candle_get_operation", {
693
+ title: "What happened to a write",
694
+ description: "Look up a trade or launch by the id its write used, and find out whether it landed. " + `Reads only; moves nothing.
695
+
696
+ ` + "Call this after a timeout, after a restart, or any time you hold an id and do not know " + `the outcome. Do NOT re-send the write to find out.
697
+
698
+ ` + `Four answers, four different next moves:
699
+ ` + "- `confirmed`/`failed`: it is over. `signature` proves the first, `errorCode` explains " + `the second.
700
+ ` + "- `built`/`submitted`: still in flight. Wait and ask again.\n" + "- 404 (`JOB_NOT_FOUND`): Candle never saw this id, so the write never reached the rail " + "and nothing moved. The original request is safe to send again exactly as it was. This " + `is a definite answer, not a failure to retry.
701
+
702
+ ` + "Amounts come back RAW, deliberately: the answer you need after a timeout is the outcome, " + "and you already know what you asked for.",
703
+ inputSchema: getOperationShape
704
+ }, async (args) => callAndRelay("candle_get_operation", args, cfg));
705
+ register("candle_get_wallets", {
706
+ title: "List the wallets Candle executes with",
707
+ description: "The account's EMBEDDED wallets, one per chain, with their delegation state. These are " + "the wallets candle_trade, candle_swap and candle_transfer spend from, so this is how an " + "agent finds its own funding addresses. Reads only; moves nothing. Not the same as the " + "account's LINKED wallets, which are the owner's own wallets and are not spent from here. " + "Balances are not included: read a specific one with the market and balance endpoints.",
708
+ inputSchema: {}
709
+ }, async () => callAndRelay("candle_get_wallets", {}, cfg));
710
+ register("candle_resolve_token", {
711
+ title: "Resolve a contract address to a token",
712
+ description: "Turn a bare contract address or mint into Candle's market for it: chain, symbol, " + "decimals, quote asset, and whether Candle can trade it. Start here when a human gives " + "you an address and nothing else. The chain is read off the address's own shape and is " + "not guessed, so it does not need to be supplied. Reads only; moves nothing. A 404 means " + "Candle has no market for that address, which is an answer, not a failure to retry.",
713
+ inputSchema: resolveTokenShape
714
+ }, async (args) => {
715
+ const result = await resolveToken(args, cfg, fetch);
716
+ return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
717
+ });
718
+ register("candle_execution_status", {
719
+ title: "Can this key execute right now",
720
+ description: "One call before trading: the embedded wallets to spend from, the tier that decides what " + "may be traded, and this key's own spend limits. Reads only; moves nothing. Call it when " + "a run starts, or after an authorization error, rather than inferring readiness from a " + "failed trade. If a read could not be completed the tool says which one and does NOT " + "claim the account is unready: an unreachable endpoint and a missing tier are different " + "problems with different fixes.",
721
+ inputSchema: {}
722
+ }, async () => {
723
+ const result = await executionStatus(cfg, fetch);
724
+ return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
725
+ });
573
726
  register("candle_swap", {
574
727
  title: "Convert between base assets",
575
- 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.",
728
+ description: "Convert one base asset into another through the account's own embedded wallets. MOVES " + `REAL FUNDS.
729
+
730
+ ` + "SOL, USDC and CNDL are on Solana. ETH and USDG are on Hood. A pair drawn from ONE of " + "those groups settles on that chain in a single transaction. A pair spanning both is a " + `BRIDGE, and this is how a Hood wallet gets funded before launching or trading there.
731
+
732
+ ` + `A bridge behaves differently and the difference matters:
733
+ ` + `- It is several transactions, not one, and it takes time rather than settling on the call.
734
+ ` + "- A confirmed source transaction is NOT proof the destination was credited. Read the " + "returned status before treating the funds as arrived; the response carries the venue's " + `own status URLs for the cross-chain fill.
735
+ ` + "- Do NOT re-send after a timeout. `clientSwapId` only coalesces a duplicate that arrives " + "while the first call is still in flight; once the first has settled, a second call with " + `the same id bridges AGAIN. If a bridge times out, check its status rather than retrying.
736
+
737
+ ` + 'Amounts are decimal (`amount`, e.g. "0.5"); `amountRaw` still accepts raw base units for ' + "callers that already compute them. Test-environment keys are refused: every leg settles " + "on a live venue.",
576
738
  inputSchema: swapShape
577
739
  }, async (args) => callAndRelay("candle_swap", args, cfg));
578
740
  register("candle_transfer", {
@@ -607,10 +769,25 @@ function registerTools(server, env = process.env) {
607
769
  }
608
770
 
609
771
  // src/version.ts
610
- var SERVER_VERSION = "0.4.0";
772
+ var SERVER_VERSION = "0.6.0";
773
+
774
+ // src/server.ts
775
+ function createCandleMcpServer(env = process.env) {
776
+ const server = new McpServer({ name: "candle-mcp", version: SERVER_VERSION });
777
+ registerTools(server, env);
778
+ return server;
779
+ }
780
+ async function runStdioServer(env = process.env, transport = new StdioServerTransport) {
781
+ const server = createCandleMcpServer(env);
782
+ await server.connect(transport);
783
+ await new Promise((resolve) => {
784
+ const sdkOnClose = transport.onclose;
785
+ transport.onclose = () => {
786
+ sdkOnClose?.();
787
+ resolve();
788
+ };
789
+ });
790
+ }
611
791
 
612
792
  // src/index.ts
613
- var server = new McpServer({ name: "candle-mcp", version: SERVER_VERSION });
614
- registerTools(server);
615
- var transport = new StdioServerTransport;
616
- await server.connect(transport);
793
+ await runStdioServer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@candledottv/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "MCP server for the Candle agent rail: launch tokens, read markets, report activity",
5
5
  "type": "module",
6
6
  "license": "MIT",