@candledottv/mcp 0.3.0 → 0.5.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.
- package/README.md +15 -6
- package/dist/index.js +82 -15
- 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
|
-
|
|
4
|
+
eleven 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.
|
|
@@ -9,7 +9,8 @@ calls.
|
|
|
9
9
|
## Try it without a key
|
|
10
10
|
|
|
11
11
|
Three tools are read-only and need nothing but `CANDLE_API_URL` (which already defaults to
|
|
12
|
-
production): `candle_get_market`, `candle_get_feed`,
|
|
12
|
+
production): `candle_get_market`, `candle_get_feed`, `candle_token_forensics`, and
|
|
13
|
+
`candle_get_agent_profile`. Install the
|
|
13
14
|
server with no `env` block at all and those three work immediately:
|
|
14
15
|
|
|
15
16
|
```json
|
|
@@ -24,8 +25,10 @@ server with no `env` block at all and those three work immediately:
|
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
Add `CANDLE_AGENT_API_KEY` only once you're ready to launch, trade, or report activity, see
|
|
27
|
-
Environment below -- or skip env editing entirely:
|
|
28
|
-
`
|
|
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.
|
|
29
32
|
|
|
30
33
|
`CANDLE_MCP_TOOLS` (optional) is a comma-separated allowlist of tool names; only those register.
|
|
31
34
|
Unset means all eight. An unknown name fails startup with the valid names in the message, rather
|
|
@@ -58,7 +61,12 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
|
|
|
58
61
|
## Environment
|
|
59
62
|
|
|
60
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
|
|
61
|
-
`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.
|
|
62
70
|
- `CANDLE_AGENT_API_KEY` -- an agent API key (`cndl_live_...` / `cndl_test_...`), issued from a
|
|
63
71
|
Candle account's agent settings page. Only required by `candle_launch_token`,
|
|
64
72
|
`candle_report_activity`, `candle_trade`, `candle_launch_and_seed`, and `candle_swap`; the three
|
|
@@ -77,6 +85,7 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
|
|
|
77
85
|
| `candle_launch_token` | Launch a token on Candle | `POST /api/v1/launch/headless` (or `/dry-run` when `dryRun: true`) | `CANDLE_AGENT_API_KEY` |
|
|
78
86
|
| `candle_get_market` | Get market state | `GET /api/v1/markets/:chain/:mint` | none |
|
|
79
87
|
| `candle_get_feed` | Get a token feed | `GET /api/v1/markets/feed?bucket=...` | none |
|
|
88
|
+
| `candle_token_forensics` | Deployer history, deploy-window buyers, holder concentration, risk tier | `GET /api/v1/markets/:chain/:mint/forensics` | none |
|
|
80
89
|
| `candle_report_activity` | Report on-chain activity | `POST /api/v1/activity/report` | `CANDLE_AGENT_API_KEY` |
|
|
81
90
|
| `candle_get_agent_profile` | Get an agent profile | `GET /api/v1/users/:idOrWallet/agent` | none |
|
|
82
91
|
| `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`) |
|
|
@@ -100,7 +109,7 @@ explicitly. Assets with nothing spendable report `empty`; a failed asset never s
|
|
|
100
109
|
## Errors
|
|
101
110
|
|
|
102
111
|
This package never reinterprets an error body, and that body is not one uniform shape across all
|
|
103
|
-
|
|
112
|
+
eleven tools:
|
|
104
113
|
|
|
105
114
|
- `candle_launch_token`, `candle_get_market`, and `candle_get_feed` hit endpoints that use the
|
|
106
115
|
structured envelope `{ success: false, error: { code, message, ... } }`. Branch on `error.code`.
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/
|
|
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,9 +9,36 @@ import { z } from "zod";
|
|
|
9
9
|
|
|
10
10
|
// src/client.ts
|
|
11
11
|
var DEFAULT_API_URL = "https://api.alpha.candle.tv";
|
|
12
|
-
function
|
|
13
|
-
const
|
|
14
|
-
|
|
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
|
|
|
@@ -135,7 +162,7 @@ async function postJson(url, apiKey, payload, doFetch) {
|
|
|
135
162
|
}
|
|
136
163
|
}
|
|
137
164
|
async function readMarket(mint, cfg, doFetch, extra) {
|
|
138
|
-
const res = await doFetch(`${base(cfg)}/api/v1/markets/${chainForMint(mint)}/${mint}`, {
|
|
165
|
+
const res = await doFetch(`${base(cfg)}/api/v1/markets/${chainForMint(mint)}/${encodeURIComponent(mint)}`, {
|
|
139
166
|
method: "GET",
|
|
140
167
|
headers: headers()
|
|
141
168
|
});
|
|
@@ -210,7 +237,7 @@ async function executeTrade(args, cfg, doFetch) {
|
|
|
210
237
|
if (!address) {
|
|
211
238
|
return errText(`percent sells need an embedded ${chain === "hood" ? "EVM" : "Solana"} wallet, and this account has none`, { clientTradeId });
|
|
212
239
|
}
|
|
213
|
-
const balRes = await doFetch(`${base(cfg)}/api/v1/tokens/${args.mint}/balance/${address}`, {
|
|
240
|
+
const balRes = await doFetch(`${base(cfg)}/api/v1/tokens/${encodeURIComponent(args.mint)}/balance/${encodeURIComponent(address)}`, {
|
|
214
241
|
method: "GET",
|
|
215
242
|
headers: headers()
|
|
216
243
|
});
|
|
@@ -274,7 +301,9 @@ async function executeLaunchAndSeed(args, cfg, doFetch) {
|
|
|
274
301
|
let market = null;
|
|
275
302
|
let note;
|
|
276
303
|
try {
|
|
277
|
-
|
|
304
|
+
if (launch.mint === undefined)
|
|
305
|
+
throw new Error("the launch response carried no mint");
|
|
306
|
+
const marketRes = await doFetch(`${base(cfg)}/api/v1/markets/${encodeURIComponent(launch.chain ?? "solana")}/${encodeURIComponent(launch.mint)}`, {
|
|
278
307
|
method: "GET",
|
|
279
308
|
headers: headers()
|
|
280
309
|
});
|
|
@@ -362,6 +391,7 @@ var TOOL_NAMES = [
|
|
|
362
391
|
"candle_launch_token",
|
|
363
392
|
"candle_get_market",
|
|
364
393
|
"candle_get_feed",
|
|
394
|
+
"candle_token_forensics",
|
|
365
395
|
"candle_report_activity",
|
|
366
396
|
"candle_get_agent_profile",
|
|
367
397
|
"candle_trade",
|
|
@@ -407,7 +437,17 @@ function buildRequest(name, args, cfg) {
|
|
|
407
437
|
}
|
|
408
438
|
case "candle_get_market": {
|
|
409
439
|
const { chain, mint } = args;
|
|
410
|
-
return {
|
|
440
|
+
return {
|
|
441
|
+
url: `${base2}/api/v1/markets/${encodeURIComponent(chain)}/${encodeURIComponent(mint)}`,
|
|
442
|
+
init: { method: "GET", headers: jsonHeaders() }
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
case "candle_token_forensics": {
|
|
446
|
+
const { chain, mint } = args;
|
|
447
|
+
return {
|
|
448
|
+
url: `${base2}/api/v1/markets/${encodeURIComponent(chain)}/${encodeURIComponent(mint)}/forensics`,
|
|
449
|
+
init: { method: "GET", headers: jsonHeaders() }
|
|
450
|
+
};
|
|
411
451
|
}
|
|
412
452
|
case "candle_get_feed": {
|
|
413
453
|
const { bucket, chain } = args;
|
|
@@ -426,7 +466,10 @@ function buildRequest(name, args, cfg) {
|
|
|
426
466
|
}
|
|
427
467
|
case "candle_get_agent_profile": {
|
|
428
468
|
const { idOrWallet } = args;
|
|
429
|
-
return {
|
|
469
|
+
return {
|
|
470
|
+
url: `${base2}/api/v1/users/${encodeURIComponent(idOrWallet)}/agent`,
|
|
471
|
+
init: { method: "GET", headers: jsonHeaders() }
|
|
472
|
+
};
|
|
430
473
|
}
|
|
431
474
|
case "candle_swap": {
|
|
432
475
|
const apiKey = requireApiKey2(cfg);
|
|
@@ -474,6 +517,10 @@ var getMarketShape = {
|
|
|
474
517
|
chain: z.string().describe('"solana" or "hood"'),
|
|
475
518
|
mint: z.string().describe("Token mint (solana) or contract address (hood)")
|
|
476
519
|
};
|
|
520
|
+
var tokenForensicsShape = {
|
|
521
|
+
chain: z.string().describe('"solana" or "hood"'),
|
|
522
|
+
mint: z.string().describe("Token mint (solana) or contract address (hood)")
|
|
523
|
+
};
|
|
477
524
|
var getFeedShape = {
|
|
478
525
|
bucket: z.enum(["new", "graduated", "onfire", "bluechip"]),
|
|
479
526
|
chain: z.string().optional().describe("Optional chain filter")
|
|
@@ -521,7 +568,7 @@ var sweepShape = {
|
|
|
521
568
|
mints: z.array(z.string()).optional().describe("Extra token mints/contracts to sweep besides the chain's base assets (own-wallet destinations only).")
|
|
522
569
|
};
|
|
523
570
|
function registerTools(server, env = process.env) {
|
|
524
|
-
const cfg = resolveConfig();
|
|
571
|
+
const cfg = resolveConfig(env);
|
|
525
572
|
const allowed = resolveToolAllowlist(env);
|
|
526
573
|
const register = (name, ...rest) => {
|
|
527
574
|
if (!allowed.has(name))
|
|
@@ -538,6 +585,11 @@ function registerTools(server, env = process.env) {
|
|
|
538
585
|
description: "Read the current market state for a token: lifecycle, pool address, whether buys are open.",
|
|
539
586
|
inputSchema: getMarketShape
|
|
540
587
|
}, async (args) => callAndRelay("candle_get_market", args, cfg));
|
|
588
|
+
register("candle_token_forensics", {
|
|
589
|
+
title: "Token forensics",
|
|
590
|
+
description: "Gate a buy before making it: deployer history, who bought in the deploy window (the creator's own wallets are marked disclosed; strangers in the same slot are the bundle signal), holder concentration, and a risk tier (LOW/MODERATE/HIGH/CRITICAL) with per-factor reasons. Every measurement carries a coverage note -- 'unavailable' is not 'clean'. No key needed.",
|
|
591
|
+
inputSchema: tokenForensicsShape
|
|
592
|
+
}, async (args) => callAndRelay("candle_token_forensics", args, cfg));
|
|
541
593
|
register("candle_get_feed", {
|
|
542
594
|
title: "Get a token feed",
|
|
543
595
|
description: "Read one of the trade page's public feeds: new, graduated, onfire, or bluechip.",
|
|
@@ -590,10 +642,25 @@ function registerTools(server, env = process.env) {
|
|
|
590
642
|
}
|
|
591
643
|
|
|
592
644
|
// src/version.ts
|
|
593
|
-
var SERVER_VERSION = "0.
|
|
645
|
+
var SERVER_VERSION = "0.5.0";
|
|
646
|
+
|
|
647
|
+
// src/server.ts
|
|
648
|
+
function createCandleMcpServer(env = process.env) {
|
|
649
|
+
const server = new McpServer({ name: "candle-mcp", version: SERVER_VERSION });
|
|
650
|
+
registerTools(server, env);
|
|
651
|
+
return server;
|
|
652
|
+
}
|
|
653
|
+
async function runStdioServer(env = process.env, transport = new StdioServerTransport) {
|
|
654
|
+
const server = createCandleMcpServer(env);
|
|
655
|
+
await server.connect(transport);
|
|
656
|
+
await new Promise((resolve) => {
|
|
657
|
+
const sdkOnClose = transport.onclose;
|
|
658
|
+
transport.onclose = () => {
|
|
659
|
+
sdkOnClose?.();
|
|
660
|
+
resolve();
|
|
661
|
+
};
|
|
662
|
+
});
|
|
663
|
+
}
|
|
594
664
|
|
|
595
665
|
// src/index.ts
|
|
596
|
-
|
|
597
|
-
registerTools(server);
|
|
598
|
-
var transport = new StdioServerTransport;
|
|
599
|
-
await server.connect(transport);
|
|
666
|
+
await runStdioServer();
|