@candledottv/mcp 0.10.3 → 0.10.5
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 +17 -4
- package/dist/index.js +752 -67
- package/package.json +2 -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
|
+
twenty-seven 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
|
|
34
|
+
Unset means all twenty-seven. 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
|
|
|
@@ -75,6 +75,11 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
|
|
|
75
75
|
you try to write. `candle_trade` additionally needs the key's `swap:write` scope server-side,
|
|
76
76
|
which is opt-in only and never granted by omission, see `docs/mcp-launch-and-seed.md` in the
|
|
77
77
|
`candle-monorepo` repo.
|
|
78
|
+
- `CANDLE_KEY_SIGNER_PEM_FILE` -- the perps write tools only: the path to the key signer's PEM
|
|
79
|
+
(`candle tee signer new --out <pem>` writes one). The relay's authorization is signed with it;
|
|
80
|
+
without it those tools refuse before anything is built.
|
|
81
|
+
- `CANDLE_HYPERLIQUID_BUILDER` / `CANDLE_HYPERLIQUID_NETWORK` -- the perps tools: pin Candle's
|
|
82
|
+
Hyperliquid builder address for the pre-sign check, and set `testnet` to trade testnet.
|
|
78
83
|
- `CANDLE_API_KEY` -- alias for `CANDLE_AGENT_API_KEY`, the same variable name the Candle CLI uses
|
|
79
84
|
for this credential. Set either one; if both are set, `CANDLE_AGENT_API_KEY` takes precedence.
|
|
80
85
|
|
|
@@ -97,10 +102,18 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
|
|
|
97
102
|
| `candle_get_wallets` | The account's embedded wallets, one per chain, with delegation state | `GET /api/v1/agent/wallets/embedded` | `CANDLE_AGENT_API_KEY` |
|
|
98
103
|
| `candle_get_profile_wallets` | Which wallets an agent profile may spend from, and whether it is scoped | `GET /api/v1/agent/keys/:prefix/wallets` | `CANDLE_AGENT_API_KEY` |
|
|
99
104
|
| `candle_set_profile_wallets` | Replace the wallets an agent profile may spend from | `PUT /api/v1/agent/keys/:prefix/wallets` | `CANDLE_AGENT_API_KEY` |
|
|
100
|
-
| `candle_get_profile_pnl` | An agent profile's realized P&L, fees, and open positions at cost basis, marked at current prices (unpriced positions counted, not zeroed),
|
|
105
|
+
| `candle_get_profile_pnl` | An agent profile's realized P&L, fees, and open positions at cost basis, marked at current prices (unpriced positions counted, not zeroed), split by the wallet that paid (`pnl.byWallet`) and by chain (`pnl.byChain`), with `chain` on every position and wallet row | `GET /api/v1/agent/keys/:prefix/pnl` | `CANDLE_AGENT_API_KEY` |
|
|
101
106
|
| `candle_get_profile_trades` | An agent profile's orders, fills, fees and transaction hashes | `GET /api/v1/agent/keys/:prefix/trades` | `CANDLE_AGENT_API_KEY` |
|
|
107
|
+
| `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`) |
|
|
102
108
|
| `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` |
|
|
103
109
|
| `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` |
|
|
110
|
+
| `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` |
|
|
111
|
+
| `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` |
|
|
112
|
+
| `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` |
|
|
113
|
+
| `candle_perps_cancel` | Cancel a perps order Candle built, by cloid | `POST /api/v1/agent/perps/cancel`, the same | `CANDLE_AGENT_API_KEY` (`perps:write`), `CANDLE_KEY_SIGNER_PEM_FILE` |
|
|
114
|
+
| `candle_perps_leverage` | Set a market's leverage and margin mode | `POST /api/v1/agent/perps/leverage`, the same | `CANDLE_AGENT_API_KEY` (`perps:write`), `CANDLE_KEY_SIGNER_PEM_FILE` |
|
|
115
|
+
| `candle_perps_orders` | Open Hyperliquid orders and every action Candle built | `GET /api/v1/agent/perps/orders` | `CANDLE_AGENT_API_KEY` |
|
|
116
|
+
| `candle_perps_positions` | Perps positions and account value, read live | `GET /api/v1/agent/perps/positions` | `CANDLE_AGENT_API_KEY` |
|
|
104
117
|
|
|
105
118
|
### Transfers and sweeps
|
|
106
119
|
|
|
@@ -117,7 +130,7 @@ explicitly. Assets with nothing spendable report `empty`; a failed asset never s
|
|
|
117
130
|
## Errors
|
|
118
131
|
|
|
119
132
|
This package never reinterprets an error body, and that body is not one uniform shape across all
|
|
120
|
-
|
|
133
|
+
twenty-seven tools:
|
|
121
134
|
|
|
122
135
|
- `candle_launch_token`, `candle_get_market`, and `candle_get_feed` hit endpoints that use the
|
|
123
136
|
structured envelope `{ success: false, error: { code, message, ... } }`. Branch on `error.code`.
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
|
|
7
7
|
// src/tools.ts
|
|
8
|
-
import { z } from "zod";
|
|
8
|
+
import { z as z2 } from "zod";
|
|
9
9
|
|
|
10
10
|
// src/client.ts
|
|
11
11
|
var DEFAULT_API_URL = "https://api.alpha.candle.tv";
|
|
@@ -106,7 +106,7 @@ function percentOfBalance(balanceRaw, percent) {
|
|
|
106
106
|
import { randomUUID } from "node:crypto";
|
|
107
107
|
|
|
108
108
|
// src/version.ts
|
|
109
|
-
var SERVER_VERSION = "0.10.
|
|
109
|
+
var SERVER_VERSION = "0.10.5";
|
|
110
110
|
|
|
111
111
|
// src/update-notice.ts
|
|
112
112
|
var PLAIN_VERSION = /^\d+\.\d+\.\d+$/;
|
|
@@ -551,6 +551,631 @@ async function executionStatus(cfg, doFetch) {
|
|
|
551
551
|
};
|
|
552
552
|
}
|
|
553
553
|
|
|
554
|
+
// src/perps.ts
|
|
555
|
+
import { createPrivateKey, sign } from "node:crypto";
|
|
556
|
+
import { readFile } from "node:fs/promises";
|
|
557
|
+
import { z } from "zod";
|
|
558
|
+
|
|
559
|
+
// src/hyperliquid.ts
|
|
560
|
+
import { keccak_256 } from "@noble/hashes/sha3";
|
|
561
|
+
var HYPERLIQUID_ALLOWED_ACTION_TYPES = [
|
|
562
|
+
"order",
|
|
563
|
+
"cancel",
|
|
564
|
+
"modify",
|
|
565
|
+
"updateLeverage",
|
|
566
|
+
"updateIsolatedMargin"
|
|
567
|
+
];
|
|
568
|
+
var HYPERLIQUID_EXCHANGE_URLS = {
|
|
569
|
+
mainnet: "https://api.hyperliquid.xyz/exchange",
|
|
570
|
+
testnet: "https://api.hyperliquid-testnet.xyz/exchange"
|
|
571
|
+
};
|
|
572
|
+
var CANDLE_HYPERLIQUID_BUILDER_ADDRESS = null;
|
|
573
|
+
var HYPERLIQUID_MAX_BUILDER_FEE_TENTHS_BPS = 100;
|
|
574
|
+
var HYPERLIQUID_MAX_BUILDER_FEE_RATE = "0.1%";
|
|
575
|
+
var HYPERLIQUID_SIGNATURE_CHAIN_ID = "0xa4b1";
|
|
576
|
+
var ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
|
577
|
+
var UINT64_MAX = (1n << 64n) - 1n;
|
|
578
|
+
var INT64_MIN = -(1n << 63n);
|
|
579
|
+
function pushUint(out, value, width) {
|
|
580
|
+
for (let i = width - 1;i >= 0; i--)
|
|
581
|
+
out.push(Number(value >> BigInt(i * 8) & 0xffn));
|
|
582
|
+
}
|
|
583
|
+
function packInteger(out, value) {
|
|
584
|
+
if (value >= 0n) {
|
|
585
|
+
if (value > UINT64_MAX)
|
|
586
|
+
throw new Error("msgpack: integer above uint64");
|
|
587
|
+
if (value < 0x80n)
|
|
588
|
+
out.push(Number(value));
|
|
589
|
+
else if (value <= 0xffn) {
|
|
590
|
+
out.push(204);
|
|
591
|
+
pushUint(out, value, 1);
|
|
592
|
+
} else if (value <= 0xffffn) {
|
|
593
|
+
out.push(205);
|
|
594
|
+
pushUint(out, value, 2);
|
|
595
|
+
} else if (value <= 0xffffffffn) {
|
|
596
|
+
out.push(206);
|
|
597
|
+
pushUint(out, value, 4);
|
|
598
|
+
} else {
|
|
599
|
+
out.push(207);
|
|
600
|
+
pushUint(out, value, 8);
|
|
601
|
+
}
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (value < INT64_MIN)
|
|
605
|
+
throw new Error("msgpack: integer below int64");
|
|
606
|
+
if (value >= -32n)
|
|
607
|
+
out.push(Number(value & 0xffn));
|
|
608
|
+
else if (value >= -128n) {
|
|
609
|
+
out.push(208);
|
|
610
|
+
pushUint(out, value & 0xffn, 1);
|
|
611
|
+
} else if (value >= -32768n) {
|
|
612
|
+
out.push(209);
|
|
613
|
+
pushUint(out, value & 0xffffn, 2);
|
|
614
|
+
} else if (value >= -2147483648n) {
|
|
615
|
+
out.push(210);
|
|
616
|
+
pushUint(out, value & 0xffffffffn, 4);
|
|
617
|
+
} else {
|
|
618
|
+
out.push(211);
|
|
619
|
+
pushUint(out, value & UINT64_MAX, 8);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function packHeader(out, length, fixBase, fixMax, codes) {
|
|
623
|
+
if (length <= fixMax) {
|
|
624
|
+
out.push(fixBase | length);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const [c8, c16, c32] = codes;
|
|
628
|
+
if (c8 !== undefined && c8 !== 0 && length <= 255) {
|
|
629
|
+
out.push(c8);
|
|
630
|
+
pushUint(out, BigInt(length), 1);
|
|
631
|
+
} else if (length <= 65535) {
|
|
632
|
+
out.push(c16);
|
|
633
|
+
pushUint(out, BigInt(length), 2);
|
|
634
|
+
} else {
|
|
635
|
+
out.push(c32);
|
|
636
|
+
pushUint(out, BigInt(length), 4);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function packValue(out, value) {
|
|
640
|
+
if (value === null)
|
|
641
|
+
out.push(192);
|
|
642
|
+
else if (value === false)
|
|
643
|
+
out.push(194);
|
|
644
|
+
else if (value === true)
|
|
645
|
+
out.push(195);
|
|
646
|
+
else if (typeof value === "bigint")
|
|
647
|
+
packInteger(out, value);
|
|
648
|
+
else if (typeof value === "number") {
|
|
649
|
+
if (!Number.isSafeInteger(value))
|
|
650
|
+
throw new Error(`msgpack: ${value} is not a safe integer`);
|
|
651
|
+
packInteger(out, BigInt(value));
|
|
652
|
+
} else if (typeof value === "string") {
|
|
653
|
+
const bytes = new TextEncoder().encode(value);
|
|
654
|
+
packHeader(out, bytes.length, 160, 31, [217, 218, 219]);
|
|
655
|
+
for (const b of bytes)
|
|
656
|
+
out.push(b);
|
|
657
|
+
} else if (Array.isArray(value)) {
|
|
658
|
+
packHeader(out, value.length, 144, 15, [0, 220, 221]);
|
|
659
|
+
for (const item of value)
|
|
660
|
+
packValue(out, item);
|
|
661
|
+
} else if (typeof value === "object") {
|
|
662
|
+
const entries = Object.entries(value).filter(([, v]) => v !== undefined);
|
|
663
|
+
for (const [key] of entries) {
|
|
664
|
+
if (/^(0|[1-9][0-9]*)$/.test(key))
|
|
665
|
+
throw new Error(`msgpack: integer-like key "${key}"`);
|
|
666
|
+
}
|
|
667
|
+
packHeader(out, entries.length, 128, 15, [0, 222, 223]);
|
|
668
|
+
for (const [key, item] of entries) {
|
|
669
|
+
packValue(out, key);
|
|
670
|
+
packValue(out, item);
|
|
671
|
+
}
|
|
672
|
+
} else {
|
|
673
|
+
throw new Error(`msgpack: cannot encode a ${typeof value}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
function msgpackEncode(value) {
|
|
677
|
+
const out = [];
|
|
678
|
+
packValue(out, value);
|
|
679
|
+
return Uint8Array.from(out);
|
|
680
|
+
}
|
|
681
|
+
function toHex(bytes) {
|
|
682
|
+
let out = "";
|
|
683
|
+
for (const b of bytes)
|
|
684
|
+
out += b.toString(16).padStart(2, "0");
|
|
685
|
+
return out;
|
|
686
|
+
}
|
|
687
|
+
function hyperliquidActionHash(action, nonce) {
|
|
688
|
+
const n = BigInt(nonce);
|
|
689
|
+
if (n < 0n || n > UINT64_MAX)
|
|
690
|
+
throw new Error("hyperliquidActionHash: nonce out of range");
|
|
691
|
+
const out = Array.from(msgpackEncode(action));
|
|
692
|
+
pushUint(out, n, 8);
|
|
693
|
+
out.push(0);
|
|
694
|
+
return `0x${toHex(keccak_256(Uint8Array.from(out)))}`;
|
|
695
|
+
}
|
|
696
|
+
function hyperliquidL1TypedData(connectionId, network) {
|
|
697
|
+
return {
|
|
698
|
+
domain: { name: "Exchange", version: "1", chainId: 1337, verifyingContract: ZERO_ADDRESS },
|
|
699
|
+
types: {
|
|
700
|
+
Agent: [
|
|
701
|
+
{ name: "source", type: "string" },
|
|
702
|
+
{ name: "connectionId", type: "bytes32" }
|
|
703
|
+
]
|
|
704
|
+
},
|
|
705
|
+
primary_type: "Agent",
|
|
706
|
+
message: { source: network === "mainnet" ? "a" : "b", connectionId }
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function hyperliquidApproveBuilderFeeTypedData(builder, nonce) {
|
|
710
|
+
return {
|
|
711
|
+
domain: { name: "HyperliquidSignTransaction", version: "1", chainId: 42161, verifyingContract: ZERO_ADDRESS },
|
|
712
|
+
types: {
|
|
713
|
+
"HyperliquidTransaction:ApproveBuilderFee": [
|
|
714
|
+
{ name: "hyperliquidChain", type: "string" },
|
|
715
|
+
{ name: "maxFeeRate", type: "string" },
|
|
716
|
+
{ name: "builder", type: "address" },
|
|
717
|
+
{ name: "nonce", type: "uint64" }
|
|
718
|
+
]
|
|
719
|
+
},
|
|
720
|
+
primary_type: "HyperliquidTransaction:ApproveBuilderFee",
|
|
721
|
+
message: {
|
|
722
|
+
hyperliquidChain: "Mainnet",
|
|
723
|
+
maxFeeRate: HYPERLIQUID_MAX_BUILDER_FEE_RATE,
|
|
724
|
+
builder: builder.toLowerCase(),
|
|
725
|
+
nonce
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
function hyperliquidCanonicalJson(value) {
|
|
730
|
+
return canonical(value);
|
|
731
|
+
}
|
|
732
|
+
function canonical(value) {
|
|
733
|
+
if (value === null || typeof value !== "object")
|
|
734
|
+
return JSON.stringify(value);
|
|
735
|
+
if (Array.isArray(value))
|
|
736
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
737
|
+
const record = value;
|
|
738
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`;
|
|
739
|
+
}
|
|
740
|
+
function sameValue(a, b) {
|
|
741
|
+
return canonical(a) === canonical(b);
|
|
742
|
+
}
|
|
743
|
+
var EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
744
|
+
function checkBuilderField(action, builder) {
|
|
745
|
+
if (!("builder" in action) || action.builder === undefined)
|
|
746
|
+
return null;
|
|
747
|
+
const field = action.builder;
|
|
748
|
+
if (typeof field !== "object" || field === null)
|
|
749
|
+
return "builder field is not an object";
|
|
750
|
+
if (typeof field.b !== "string" || field.b.toLowerCase() !== builder.toLowerCase()) {
|
|
751
|
+
return "the order names a builder that is not Candle's";
|
|
752
|
+
}
|
|
753
|
+
if (typeof field.f !== "number" || !Number.isInteger(field.f) || field.f <= 0 || field.f > HYPERLIQUID_MAX_BUILDER_FEE_TENTHS_BPS) {
|
|
754
|
+
return "the builder fee is outside Candle's 0.1% maximum";
|
|
755
|
+
}
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
function decimal(value) {
|
|
759
|
+
if (typeof value !== "string" || !/^-?(0|[1-9][0-9]*)(\.[0-9]{1,18})?$/.test(value))
|
|
760
|
+
return null;
|
|
761
|
+
const negative = value.startsWith("-");
|
|
762
|
+
const [whole = "0", frac = ""] = (negative ? value.slice(1) : value).split(".");
|
|
763
|
+
const n = BigInt(whole) * 10n ** 18n + BigInt(frac.padEnd(18, "0"));
|
|
764
|
+
return negative ? -n : n;
|
|
765
|
+
}
|
|
766
|
+
function checkActionIntent(action, intent) {
|
|
767
|
+
const mainAsset = (a) => typeof a === "number" && Number.isInteger(a) && a >= 0 && a < 1e4;
|
|
768
|
+
const orderList = action.type === "order" ? action.orders : action.type === "modify" ? [action.order] : [];
|
|
769
|
+
if (!Array.isArray(orderList))
|
|
770
|
+
return "invalid orders";
|
|
771
|
+
for (const raw of orderList) {
|
|
772
|
+
if (!raw || typeof raw !== "object")
|
|
773
|
+
return "invalid order";
|
|
774
|
+
const o = raw;
|
|
775
|
+
if (!mainAsset(o.a))
|
|
776
|
+
return "order asset is outside the main perp universe";
|
|
777
|
+
if (typeof o.b !== "boolean" || typeof o.r !== "boolean" || (decimal(o.s) ?? 0n) <= 0n || (decimal(o.p) ?? 0n) <= 0n)
|
|
778
|
+
return "invalid order side, size, price or reduce-only flag";
|
|
779
|
+
}
|
|
780
|
+
if (action.type === "cancel") {
|
|
781
|
+
if (!Array.isArray(action.cancels) || action.cancels.length !== 1 || !mainAsset(action.cancels[0]?.a))
|
|
782
|
+
return "invalid cancel asset";
|
|
783
|
+
}
|
|
784
|
+
if ((action.type === "updateLeverage" || action.type === "updateIsolatedMargin") && !mainAsset(action.asset))
|
|
785
|
+
return "invalid action asset";
|
|
786
|
+
if (!intent)
|
|
787
|
+
return null;
|
|
788
|
+
const { method, params: p } = intent;
|
|
789
|
+
const types = {
|
|
790
|
+
setup: "approveBuilderFee",
|
|
791
|
+
open: "order",
|
|
792
|
+
close: "order",
|
|
793
|
+
cancel: "cancel",
|
|
794
|
+
modify: "modify",
|
|
795
|
+
leverage: "updateLeverage",
|
|
796
|
+
margin: "updateIsolatedMargin"
|
|
797
|
+
};
|
|
798
|
+
if (action.type !== types[method])
|
|
799
|
+
return "action type does not match the requested method";
|
|
800
|
+
if (method === "leverage") {
|
|
801
|
+
if (action.leverage !== p.leverage || action.isCross !== (p.mode !== "isolated"))
|
|
802
|
+
return "leverage or mode does not match the request";
|
|
803
|
+
}
|
|
804
|
+
if (method === "margin") {
|
|
805
|
+
const amount = decimal(p.amount);
|
|
806
|
+
if (amount === null || amount % 10n ** 12n !== 0n || !Number.isSafeInteger(action.ntli) || BigInt(action.ntli) !== amount / 10n ** 12n || action.isBuy !== true)
|
|
807
|
+
return "isolated margin amount does not match the request";
|
|
808
|
+
}
|
|
809
|
+
if (method === "open" || method === "close" || method === "modify") {
|
|
810
|
+
const list = orderList;
|
|
811
|
+
const first = list[0];
|
|
812
|
+
if (!first)
|
|
813
|
+
return "missing requested order";
|
|
814
|
+
const typ = first.t;
|
|
815
|
+
if (!typ?.limit)
|
|
816
|
+
return "requested order is not a limit/IOC order";
|
|
817
|
+
if (method === "open") {
|
|
818
|
+
const buy = p.side === "long" || p.side === "buy";
|
|
819
|
+
if (first.b !== buy || first.r !== false || decimal(first.s) !== decimal(p.size))
|
|
820
|
+
return "order side, size or reduce-only flag does not match the request";
|
|
821
|
+
const tif = (p.type ?? (p.price === undefined ? "market" : "limit")) === "market" ? "Ioc" : p.tif ?? "Gtc";
|
|
822
|
+
if (typ.limit.tif !== tif)
|
|
823
|
+
return "order time in force does not match the request";
|
|
824
|
+
const triggers = [
|
|
825
|
+
["takeProfit", "tp"],
|
|
826
|
+
["stopLoss", "sl"]
|
|
827
|
+
].filter(([field]) => p[field] != null);
|
|
828
|
+
if (list.length !== 1 + triggers.length || action.grouping !== (triggers.length ? "normalTpsl" : "na"))
|
|
829
|
+
return "unexpected extra orders or grouping";
|
|
830
|
+
for (let i = 0;i < triggers.length; i++) {
|
|
831
|
+
const [field, kind] = triggers[i];
|
|
832
|
+
const leg = list[i + 1];
|
|
833
|
+
if (!leg)
|
|
834
|
+
return "missing trigger order";
|
|
835
|
+
const trigger = leg.t?.trigger;
|
|
836
|
+
if (leg.a !== first.a || leg.b !== !buy || leg.r !== true || decimal(leg.s) !== decimal(p.size) || trigger?.tpsl !== kind || trigger.isMarket !== true || decimal(trigger.triggerPx) !== decimal(p[field]))
|
|
837
|
+
return "trigger side, size, reduce-only flag or price does not match the request";
|
|
838
|
+
}
|
|
839
|
+
} else {
|
|
840
|
+
if (list.length !== 1)
|
|
841
|
+
return "unexpected extra orders";
|
|
842
|
+
if (method === "close" && (first.r !== true || typ.limit.tif !== "Ioc" || p.size != null && (decimal(first.s) ?? 0n) > (decimal(p.size) ?? 0n)))
|
|
843
|
+
return "close size or reduce-only flag does not match the request";
|
|
844
|
+
if (method === "modify" && p.size != null && decimal(first.s) !== decimal(p.size))
|
|
845
|
+
return "replacement size does not match the request";
|
|
846
|
+
}
|
|
847
|
+
if (p.price != null && decimal(first.p) !== decimal(p.price))
|
|
848
|
+
return "order price does not match the request";
|
|
849
|
+
}
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
async function hyperliquidCloseOrder(fetcher, network, address, params) {
|
|
853
|
+
const read = async (body) => {
|
|
854
|
+
const res = await fetcher(HYPERLIQUID_EXCHANGE_URLS[network].replace("/exchange", "/info"), {
|
|
855
|
+
method: "POST",
|
|
856
|
+
headers: { "content-type": "application/json" },
|
|
857
|
+
body: JSON.stringify(body)
|
|
858
|
+
});
|
|
859
|
+
if (!res.ok)
|
|
860
|
+
throw new Error("Cannot read the close position from Hyperliquid");
|
|
861
|
+
return JSON.parse(await res.text());
|
|
862
|
+
};
|
|
863
|
+
const [meta, state] = await Promise.all([read({ type: "meta" }), read({ type: "clearinghouseState", user: address })]);
|
|
864
|
+
const coin = String(params.coin).toUpperCase();
|
|
865
|
+
const asset = meta.universe.findIndex((m) => m.name.toUpperCase() === coin);
|
|
866
|
+
const szi = state.assetPositions.find((p) => p.position.coin.toUpperCase() === coin)?.position.szi;
|
|
867
|
+
const signed = decimal(szi);
|
|
868
|
+
if (asset < 0 || asset >= 1e4 || signed === null || signed === 0n)
|
|
869
|
+
throw new Error("No main-universe position to close");
|
|
870
|
+
const held = signed < 0n ? -signed : signed;
|
|
871
|
+
const asked = params.size == null ? held : decimal(params.size);
|
|
872
|
+
if (asked === null || asked <= 0n)
|
|
873
|
+
throw new Error("Invalid close size");
|
|
874
|
+
const size = asked < held ? String(params.size) : String(szi).replace(/^-/, "");
|
|
875
|
+
return { asset, isBuy: signed < 0n, size };
|
|
876
|
+
}
|
|
877
|
+
function verifyPerpsBuild(build, opts) {
|
|
878
|
+
try {
|
|
879
|
+
const network = opts.network ?? "mainnet";
|
|
880
|
+
if (!EVM_ADDRESS_RE.test(opts.builder))
|
|
881
|
+
return { ok: false, reason: "no valid Candle builder address to check against" };
|
|
882
|
+
if (build.network !== undefined && build.network !== network) {
|
|
883
|
+
return { ok: false, reason: `the build is for ${build.network}, this client trades ${network}` };
|
|
884
|
+
}
|
|
885
|
+
if (opts.address && build.address && build.address.toLowerCase() !== opts.address.toLowerCase()) {
|
|
886
|
+
return { ok: false, reason: "the build is for a different wallet" };
|
|
887
|
+
}
|
|
888
|
+
if (!Number.isSafeInteger(build.nonce) || build.nonce <= 0)
|
|
889
|
+
return { ok: false, reason: "invalid nonce" };
|
|
890
|
+
const typed = build.typedData;
|
|
891
|
+
if (!typed || typeof typed !== "object")
|
|
892
|
+
return { ok: false, reason: "no typed data" };
|
|
893
|
+
const action = build.action;
|
|
894
|
+
if (!action || typeof action !== "object")
|
|
895
|
+
return { ok: false, reason: "no action" };
|
|
896
|
+
const intentRefusal = checkActionIntent(action, opts.intent);
|
|
897
|
+
if (intentRefusal)
|
|
898
|
+
return { ok: false, reason: intentRefusal };
|
|
899
|
+
if (opts.closeOrder) {
|
|
900
|
+
const order = action.orders[0];
|
|
901
|
+
if (!order || order.a !== opts.closeOrder.asset || order.b !== opts.closeOrder.isBuy || decimal(order.s) !== decimal(opts.closeOrder.size) || order.r !== true)
|
|
902
|
+
return { ok: false, reason: "close side, asset or size does not match the requested position" };
|
|
903
|
+
}
|
|
904
|
+
if (typed.primary_type === "Agent") {
|
|
905
|
+
const type = action.type;
|
|
906
|
+
if (typeof type !== "string" || !HYPERLIQUID_ALLOWED_ACTION_TYPES.includes(type)) {
|
|
907
|
+
return { ok: false, reason: `action type ${String(type)} is not one Candle builds` };
|
|
908
|
+
}
|
|
909
|
+
if (type === "order") {
|
|
910
|
+
const refusal = checkBuilderField(action, opts.builder);
|
|
911
|
+
if (refusal)
|
|
912
|
+
return { ok: false, reason: refusal };
|
|
913
|
+
} else if ("builder" in action) {
|
|
914
|
+
return { ok: false, reason: `a ${type} action carries no builder` };
|
|
915
|
+
}
|
|
916
|
+
if ("vaultAddress" in action)
|
|
917
|
+
return { ok: false, reason: "the action names a vault" };
|
|
918
|
+
const expected = hyperliquidL1TypedData(hyperliquidActionHash(action, build.nonce), network);
|
|
919
|
+
if (!sameValue(typed, expected)) {
|
|
920
|
+
return { ok: false, reason: "the typed data does not commit to this action and nonce" };
|
|
921
|
+
}
|
|
922
|
+
return { ok: true, kind: "l1" };
|
|
923
|
+
}
|
|
924
|
+
if (typed.primary_type === "HyperliquidTransaction:ApproveBuilderFee") {
|
|
925
|
+
const expected = hyperliquidApproveBuilderFeeTypedData(opts.builder, build.nonce);
|
|
926
|
+
if (!sameValue(typed, expected)) {
|
|
927
|
+
return { ok: false, reason: "the builder approval is not Candle's builder at 0.1% on Mainnet" };
|
|
928
|
+
}
|
|
929
|
+
const expectedAction = {
|
|
930
|
+
type: "approveBuilderFee",
|
|
931
|
+
hyperliquidChain: "Mainnet",
|
|
932
|
+
signatureChainId: HYPERLIQUID_SIGNATURE_CHAIN_ID,
|
|
933
|
+
maxFeeRate: HYPERLIQUID_MAX_BUILDER_FEE_RATE,
|
|
934
|
+
builder: opts.builder.toLowerCase(),
|
|
935
|
+
nonce: build.nonce
|
|
936
|
+
};
|
|
937
|
+
if (!sameValue(action, expectedAction)) {
|
|
938
|
+
return { ok: false, reason: "the approval action does not match its typed data" };
|
|
939
|
+
}
|
|
940
|
+
return { ok: true, kind: "approveBuilderFee" };
|
|
941
|
+
}
|
|
942
|
+
return { ok: false, reason: `primary type ${String(typed.primary_type)} is not one the relay signs` };
|
|
943
|
+
} catch (err) {
|
|
944
|
+
return { ok: false, reason: err instanceof Error ? err.message : "unreadable build" };
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
function hyperliquidSplitSignature(signature) {
|
|
948
|
+
if (!/^0x[0-9a-fA-F]{130}$/.test(signature))
|
|
949
|
+
throw new Error("Not a 65-byte signature");
|
|
950
|
+
let v = Number.parseInt(signature.slice(130, 132), 16);
|
|
951
|
+
if (v < 27)
|
|
952
|
+
v += 27;
|
|
953
|
+
return { r: `0x${signature.slice(2, 66)}`, s: `0x${signature.slice(66, 130)}`, v };
|
|
954
|
+
}
|
|
955
|
+
function hyperliquidExchangeBody(action, nonce, signature) {
|
|
956
|
+
return { action, nonce, signature: hyperliquidSplitSignature(signature), vaultAddress: null, expiresAfter: null };
|
|
957
|
+
}
|
|
958
|
+
function hyperliquidRelayBody(typedData) {
|
|
959
|
+
return { method: "eth_signTypedData_v4", params: { typed_data: typedData } };
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// src/perps.ts
|
|
963
|
+
var decimal2 = z.string().regex(/^(0|[1-9][0-9]*)(\.[0-9]+)?$/, "a plain decimal string");
|
|
964
|
+
var wallet = z.string().optional().describe("The EVM TEE wallet by id, address or label. Omit when the key has exactly one.");
|
|
965
|
+
var submit = z.boolean().optional().describe("false: build and check only, sign and submit nothing. Default true.");
|
|
966
|
+
var perpsShapes = {
|
|
967
|
+
candle_perps_setup: { wallet, submit },
|
|
968
|
+
candle_perps_open: {
|
|
969
|
+
coin: z.string().describe("A market on Hyperliquid's main perp exchange, e.g. BTC"),
|
|
970
|
+
side: z.enum(["long", "short"]),
|
|
971
|
+
size: decimal2.describe('Size in the coin, decimal (e.g. "0.01")'),
|
|
972
|
+
price: decimal2.optional().describe("Limit price. Omit for a market order (IOC within slippageBps of the mid)."),
|
|
973
|
+
tif: z.enum(["Gtc", "Alo", "Ioc"]).optional().describe("With price: time in force (default Gtc)"),
|
|
974
|
+
slippageBps: z.number().int().min(1).optional(),
|
|
975
|
+
takeProfit: decimal2.optional().describe("Reduce-only take-profit trigger price"),
|
|
976
|
+
stopLoss: decimal2.optional().describe("Reduce-only stop-loss trigger price"),
|
|
977
|
+
wallet,
|
|
978
|
+
submit
|
|
979
|
+
},
|
|
980
|
+
candle_perps_close: {
|
|
981
|
+
coin: z.string(),
|
|
982
|
+
size: decimal2.optional().describe("Part of the position to close; the whole position when omitted"),
|
|
983
|
+
slippageBps: z.number().int().min(1).optional(),
|
|
984
|
+
wallet,
|
|
985
|
+
submit
|
|
986
|
+
},
|
|
987
|
+
candle_perps_cancel: {
|
|
988
|
+
cloid: z.string().regex(/^0x[0-9a-fA-F]{32}$/).describe("The cloid of an order Candle built (an open, or one of its take-profit / stop-loss legs)"),
|
|
989
|
+
wallet,
|
|
990
|
+
submit
|
|
991
|
+
},
|
|
992
|
+
candle_perps_orders: { wallet, limit: z.number().int().min(1).max(200).optional() },
|
|
993
|
+
candle_perps_positions: { wallet },
|
|
994
|
+
candle_perps_leverage: {
|
|
995
|
+
coin: z.string(),
|
|
996
|
+
leverage: z.number().int().min(1),
|
|
997
|
+
mode: z.enum(["cross", "isolated"]).optional(),
|
|
998
|
+
wallet,
|
|
999
|
+
submit
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
var PATHS = {
|
|
1003
|
+
candle_perps_setup: "/api/v1/agent/perps/setup",
|
|
1004
|
+
candle_perps_open: "/api/v1/agent/perps/open",
|
|
1005
|
+
candle_perps_close: "/api/v1/agent/perps/close",
|
|
1006
|
+
candle_perps_cancel: "/api/v1/agent/perps/cancel",
|
|
1007
|
+
candle_perps_orders: "/api/v1/agent/perps/orders",
|
|
1008
|
+
candle_perps_positions: "/api/v1/agent/perps/positions",
|
|
1009
|
+
candle_perps_leverage: "/api/v1/agent/perps/leverage"
|
|
1010
|
+
};
|
|
1011
|
+
function fail(code, message, extra = {}) {
|
|
1012
|
+
return { text: JSON.stringify({ success: false, error: { code, message }, ...extra }), isError: true };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
class Refusal extends Error {
|
|
1016
|
+
tool;
|
|
1017
|
+
constructor(tool) {
|
|
1018
|
+
super("refused");
|
|
1019
|
+
this.tool = tool;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
async function call(cfg, fetch2, method, path, body) {
|
|
1023
|
+
const res = await fetch2(`${cfg.apiUrl.replace(/\/$/, "")}${path}`, {
|
|
1024
|
+
method,
|
|
1025
|
+
headers: { "Content-Type": "application/json", ...cfg.apiKey ? { "x-api-key": cfg.apiKey } : {} },
|
|
1026
|
+
...body !== undefined ? { body: JSON.stringify(body) } : {}
|
|
1027
|
+
});
|
|
1028
|
+
const text = await res.text();
|
|
1029
|
+
let parsed;
|
|
1030
|
+
try {
|
|
1031
|
+
parsed = JSON.parse(text);
|
|
1032
|
+
} catch {
|
|
1033
|
+
parsed = text;
|
|
1034
|
+
}
|
|
1035
|
+
if (!res.ok) {
|
|
1036
|
+
if (res.status === 404 && (typeof parsed !== "object" || parsed === null))
|
|
1037
|
+
throw new Refusal(fail("PERPS_NOT_ENABLED", "This Candle deployment does not serve perps routes."));
|
|
1038
|
+
throw new Refusal({ text: typeof parsed === "string" ? parsed : JSON.stringify(parsed), isError: true });
|
|
1039
|
+
}
|
|
1040
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
1041
|
+
throw new Refusal(fail("INVALID_RESPONSE", "Not a JSON object"));
|
|
1042
|
+
return parsed;
|
|
1043
|
+
}
|
|
1044
|
+
async function walletFor(cfg, fetch2, name, scope) {
|
|
1045
|
+
const listed = await call(cfg, fetch2, "GET", "/api/v1/agent/wallets/trading");
|
|
1046
|
+
if (scope && !(Array.isArray(listed.scopes) && listed.scopes.includes(scope)))
|
|
1047
|
+
throw new Refusal(fail("SCOPE_MISSING", `The key needs ${scope}.`));
|
|
1048
|
+
const rows = Array.isArray(listed.page) ? listed.page : [];
|
|
1049
|
+
const evm = rows.filter((row) => row.chain === "evm");
|
|
1050
|
+
const matches = name === undefined ? evm : evm.filter((row) => row.id === name || row.label === name || row.address.toLowerCase() === name.toLowerCase());
|
|
1051
|
+
if (matches.length !== 1)
|
|
1052
|
+
throw new Refusal(fail("TEE_WALLET_REQUIRED", evm.length === 0 ? "This key has no EVM TEE wallet bound to it." : `Name exactly one EVM TEE wallet: ${evm.map((row) => row.label ?? row.id).join(", ")}`));
|
|
1053
|
+
return { row: matches[0], appId: typeof listed.privyAppId === "string" ? listed.privyAppId : "" };
|
|
1054
|
+
}
|
|
1055
|
+
async function builderPin(cfg, fetch2, env) {
|
|
1056
|
+
const pinned = env.CANDLE_HYPERLIQUID_BUILDER?.trim() || CANDLE_HYPERLIQUID_BUILDER_ADDRESS;
|
|
1057
|
+
if (pinned)
|
|
1058
|
+
return pinned.toLowerCase();
|
|
1059
|
+
const config = await call(cfg, fetch2, "GET", "/api/v1/agent/perps/config");
|
|
1060
|
+
if (typeof config.builder !== "string")
|
|
1061
|
+
throw new Refusal(fail("PERPS_NOT_CONFIGURED", "The server reports no Hyperliquid builder address."));
|
|
1062
|
+
return config.builder.toLowerCase();
|
|
1063
|
+
}
|
|
1064
|
+
async function executePerps(tool, args, cfg, env, fetch2) {
|
|
1065
|
+
if (!cfg.apiKey)
|
|
1066
|
+
return fail("MCP_VALIDATION", "CANDLE_AGENT_API_KEY is required for this tool.");
|
|
1067
|
+
const network = env.CANDLE_HYPERLIQUID_NETWORK === "testnet" ? "testnet" : "mainnet";
|
|
1068
|
+
const name = typeof args.wallet === "string" ? args.wallet : undefined;
|
|
1069
|
+
try {
|
|
1070
|
+
if (tool === "candle_perps_orders" || tool === "candle_perps_positions") {
|
|
1071
|
+
const { row: row2 } = await walletFor(cfg, fetch2, name);
|
|
1072
|
+
const query = new URLSearchParams({ walletId: row2.id });
|
|
1073
|
+
if (typeof args.limit === "number")
|
|
1074
|
+
query.set("limit", String(args.limit));
|
|
1075
|
+
return { text: JSON.stringify(await call(cfg, fetch2, "GET", `${PATHS[tool]}?${query}`)) };
|
|
1076
|
+
}
|
|
1077
|
+
const willSubmit = args.submit !== false;
|
|
1078
|
+
const pemFile = env.CANDLE_KEY_SIGNER_PEM_FILE?.trim();
|
|
1079
|
+
let signerPem = null;
|
|
1080
|
+
if (willSubmit) {
|
|
1081
|
+
if (!pemFile)
|
|
1082
|
+
return fail("SIGNER_UNAVAILABLE", "Set CANDLE_KEY_SIGNER_PEM_FILE to the key signer PEM (candle tee signer new --out <pem>) to sign perps actions.");
|
|
1083
|
+
try {
|
|
1084
|
+
signerPem = await readFile(pemFile, "utf8");
|
|
1085
|
+
createPrivateKey(signerPem);
|
|
1086
|
+
} catch {
|
|
1087
|
+
return fail("SIGNER_UNAVAILABLE", "CANDLE_KEY_SIGNER_PEM_FILE does not hold a readable private key PEM.");
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
const { row, appId } = await walletFor(cfg, fetch2, name, "perps:write");
|
|
1091
|
+
const builder = await builderPin(cfg, fetch2, env);
|
|
1092
|
+
const { wallet: _w, submit: _s, ...fields } = args;
|
|
1093
|
+
const build = await call(cfg, fetch2, "POST", PATHS[tool], {
|
|
1094
|
+
walletId: row.id,
|
|
1095
|
+
...fields,
|
|
1096
|
+
...tool === "candle_perps_open" ? { type: fields.price === undefined ? "market" : "limit" } : {}
|
|
1097
|
+
});
|
|
1098
|
+
if (build.ready === true)
|
|
1099
|
+
return { text: JSON.stringify(build) };
|
|
1100
|
+
const method = PATHS[tool].split("/").at(-1);
|
|
1101
|
+
const check = verifyPerpsBuild(build, {
|
|
1102
|
+
builder,
|
|
1103
|
+
network,
|
|
1104
|
+
address: row.address,
|
|
1105
|
+
intent: { method, params: fields }
|
|
1106
|
+
});
|
|
1107
|
+
if (!check.ok)
|
|
1108
|
+
return fail("PERPS_BUILD_REFUSED", `Refused to sign this build: ${check.reason}. Nothing was signed.`);
|
|
1109
|
+
if (method === "close") {
|
|
1110
|
+
const closeOrder = await hyperliquidCloseOrder(fetch2, network, row.address, fields);
|
|
1111
|
+
const closeCheck = verifyPerpsBuild(build, {
|
|
1112
|
+
builder,
|
|
1113
|
+
network,
|
|
1114
|
+
address: row.address,
|
|
1115
|
+
intent: { method, params: fields },
|
|
1116
|
+
closeOrder
|
|
1117
|
+
});
|
|
1118
|
+
if (!closeCheck.ok)
|
|
1119
|
+
return fail("PERPS_BUILD_REFUSED", `Refused to sign this build: ${closeCheck.reason}. Nothing was signed.`);
|
|
1120
|
+
}
|
|
1121
|
+
if (!willSubmit || signerPem === null)
|
|
1122
|
+
return { text: JSON.stringify({ ...build, signed: false, submitted: false }) };
|
|
1123
|
+
if (!appId || !row.privyWalletId)
|
|
1124
|
+
return fail("SIGNER_UNAVAILABLE", "The server did not return its relay identifiers.");
|
|
1125
|
+
const body = hyperliquidRelayBody(build.typedData);
|
|
1126
|
+
const payload = hyperliquidCanonicalJson({
|
|
1127
|
+
body,
|
|
1128
|
+
headers: { "privy-app-id": appId },
|
|
1129
|
+
method: "POST",
|
|
1130
|
+
url: `https://api.privy.io/v1/wallets/${row.privyWalletId}/rpc`,
|
|
1131
|
+
version: 1
|
|
1132
|
+
});
|
|
1133
|
+
const authorizationSignature = sign("sha256", Buffer.from(payload), signerPem).toString("base64");
|
|
1134
|
+
const relay = await call(cfg, fetch2, "POST", `/api/v1/agent/wallets/${encodeURIComponent(row.id)}/sign`, {
|
|
1135
|
+
authorizationSignature,
|
|
1136
|
+
body
|
|
1137
|
+
});
|
|
1138
|
+
const signature = relay.signature;
|
|
1139
|
+
if (typeof signature !== "string" || !/^0x[0-9a-fA-F]{130}$/.test(signature))
|
|
1140
|
+
return fail("INVALID_RESPONSE", "The relay did not return a signature.");
|
|
1141
|
+
const exchangeBody = hyperliquidExchangeBody(build.action, build.nonce, signature);
|
|
1142
|
+
let exchange;
|
|
1143
|
+
try {
|
|
1144
|
+
const res = await fetch2(HYPERLIQUID_EXCHANGE_URLS[network], {
|
|
1145
|
+
method: "POST",
|
|
1146
|
+
headers: { "Content-Type": "application/json" },
|
|
1147
|
+
body: JSON.stringify(exchangeBody)
|
|
1148
|
+
});
|
|
1149
|
+
const text = await res.text();
|
|
1150
|
+
if (!res.ok)
|
|
1151
|
+
throw new Error(`HTTP ${res.status}`);
|
|
1152
|
+
exchange = JSON.parse(text);
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
return fail("PERPS_SUBMIT_FAILED", `Signed, but the submit to Hyperliquid failed (${error instanceof Error ? error.message : "unknown"}). POST exchangeBody to Hyperliquid's /exchange to resubmit.`, { perpOrderId: build.perpOrderId, exchangeBody });
|
|
1155
|
+
}
|
|
1156
|
+
const accepted = typeof exchange === "object" && exchange !== null && exchange.status === "ok" && !(exchange.response?.data?.statuses ?? []).some((status) => typeof status === "object" && status !== null && ("error" in status));
|
|
1157
|
+
return {
|
|
1158
|
+
text: JSON.stringify({
|
|
1159
|
+
success: accepted,
|
|
1160
|
+
perpOrderId: build.perpOrderId,
|
|
1161
|
+
kind: build.kind,
|
|
1162
|
+
network: build.network,
|
|
1163
|
+
address: row.address,
|
|
1164
|
+
...build.cloid ? { cloid: build.cloid } : {},
|
|
1165
|
+
...build.childCloids ? { childCloids: build.childCloids } : {},
|
|
1166
|
+
nonce: build.nonce,
|
|
1167
|
+
signature,
|
|
1168
|
+
exchange
|
|
1169
|
+
}),
|
|
1170
|
+
...accepted ? {} : { isError: true }
|
|
1171
|
+
};
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
if (error instanceof Refusal)
|
|
1174
|
+
return error.tool;
|
|
1175
|
+
return fail("MCP_TRANSPORT", error instanceof Error ? error.message : "The perps call failed.");
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
554
1179
|
// src/tools.ts
|
|
555
1180
|
var TOOL_NAMES = [
|
|
556
1181
|
"candle_launch_token",
|
|
@@ -569,9 +1194,17 @@ var TOOL_NAMES = [
|
|
|
569
1194
|
"candle_set_profile_wallets",
|
|
570
1195
|
"candle_get_profile_pnl",
|
|
571
1196
|
"candle_get_profile_trades",
|
|
1197
|
+
"candle_get_portfolio",
|
|
572
1198
|
"candle_resolve_token",
|
|
573
1199
|
"candle_execution_status",
|
|
574
|
-
"candle_get_operation"
|
|
1200
|
+
"candle_get_operation",
|
|
1201
|
+
"candle_perps_setup",
|
|
1202
|
+
"candle_perps_open",
|
|
1203
|
+
"candle_perps_close",
|
|
1204
|
+
"candle_perps_cancel",
|
|
1205
|
+
"candle_perps_orders",
|
|
1206
|
+
"candle_perps_positions",
|
|
1207
|
+
"candle_perps_leverage"
|
|
575
1208
|
];
|
|
576
1209
|
function resolveToolAllowlist(env) {
|
|
577
1210
|
const raw = env.CANDLE_MCP_TOOLS?.trim();
|
|
@@ -714,6 +1347,13 @@ function buildRequest(name, args, cfg) {
|
|
|
714
1347
|
init: { method: "GET", headers: jsonHeaders(apiKey) }
|
|
715
1348
|
};
|
|
716
1349
|
}
|
|
1350
|
+
case "candle_get_portfolio": {
|
|
1351
|
+
const apiKey = requireApiKey2(cfg);
|
|
1352
|
+
return {
|
|
1353
|
+
url: `${base2}/api/v1/agent/portfolio`,
|
|
1354
|
+
init: { method: "GET", headers: jsonHeaders(apiKey) }
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
717
1357
|
case "candle_get_profile_trades": {
|
|
718
1358
|
const apiKey = requireApiKey2(cfg);
|
|
719
1359
|
const { keyPrefix, limit } = args;
|
|
@@ -743,102 +1383,102 @@ async function callAndRelay(name, args, cfg) {
|
|
|
743
1383
|
};
|
|
744
1384
|
}
|
|
745
1385
|
var launchTokenShape = {
|
|
746
|
-
clientLaunchId:
|
|
747
|
-
name:
|
|
748
|
-
symbol:
|
|
749
|
-
imageUrl:
|
|
750
|
-
bannerUrl:
|
|
751
|
-
chain:
|
|
752
|
-
quoteAsset:
|
|
753
|
-
mode:
|
|
754
|
-
stakerAllocationBps:
|
|
755
|
-
dexVersion:
|
|
756
|
-
buyAmount:
|
|
757
|
-
description:
|
|
758
|
-
socials:
|
|
759
|
-
visibility:
|
|
760
|
-
dryRun:
|
|
1386
|
+
clientLaunchId: z2.string().describe("Caller-chosen idempotency key, unique per account"),
|
|
1387
|
+
name: z2.string().describe("Token name"),
|
|
1388
|
+
symbol: z2.string().describe("Token symbol"),
|
|
1389
|
+
imageUrl: z2.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"),
|
|
1390
|
+
bannerUrl: z2.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"),
|
|
1391
|
+
chain: z2.string().optional().describe('"solana" or "hood"; defaults to solana'),
|
|
1392
|
+
quoteAsset: z2.string().optional().describe("Quote asset symbol; defaults per chain"),
|
|
1393
|
+
mode: z2.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"),
|
|
1394
|
+
stakerAllocationBps: z2.number().optional().describe("Staker allocation in basis points"),
|
|
1395
|
+
dexVersion: z2.string().nullable().optional().describe('"v3" or "v4"; required for hood launches'),
|
|
1396
|
+
buyAmount: z2.union([z2.string(), z2.number()]).optional().describe("Initial buy, in quote base units (string or number)"),
|
|
1397
|
+
description: z2.string().optional(),
|
|
1398
|
+
socials: z2.record(z2.string()).optional().describe("Social links keyed by platform"),
|
|
1399
|
+
visibility: z2.string().optional().describe('"production", "test", "local", or "hidden"'),
|
|
1400
|
+
dryRun: z2.boolean().optional().describe("Validate without executing the launch")
|
|
761
1401
|
};
|
|
762
1402
|
var getMarketShape = {
|
|
763
|
-
chain:
|
|
764
|
-
mint:
|
|
1403
|
+
chain: z2.string().describe('"solana" or "hood"'),
|
|
1404
|
+
mint: z2.string().describe("Token mint (solana) or contract address (hood)")
|
|
765
1405
|
};
|
|
766
1406
|
var tokenForensicsShape = {
|
|
767
|
-
chain:
|
|
768
|
-
mint:
|
|
1407
|
+
chain: z2.string().describe('"solana" or "hood"'),
|
|
1408
|
+
mint: z2.string().describe("Token mint (solana) or contract address (hood)")
|
|
769
1409
|
};
|
|
770
1410
|
var getFeedShape = {
|
|
771
|
-
bucket:
|
|
772
|
-
chain:
|
|
773
|
-
where:
|
|
774
|
-
sort:
|
|
775
|
-
fields:
|
|
776
|
-
limit:
|
|
1411
|
+
bucket: z2.enum(["new", "graduated", "onfire", "bluechip"]),
|
|
1412
|
+
chain: z2.string().optional().describe("Optional chain filter"),
|
|
1413
|
+
where: z2.string().optional().describe('JSON filter, e.g. {"marketCap":{"lt":150000},"liquidityUsd":{"gte":25000},"mintAuthorityDisabled":{"eq":true}}. ' + "Comparators: eq, ne, lt, lte, gt, gte, present. An ABSENT field satisfies none of them except " + "present:false, so a filter for mintAuthorityDisabled eq true returns only tokens that actually say " + "true, never ones where the flag is simply missing. Use present:false to find the tokens with no data."),
|
|
1414
|
+
sort: z2.string().optional().describe('Sort as "field" or "field:asc" / "field:desc". A bare field means desc.'),
|
|
1415
|
+
fields: z2.string().optional().describe("Comma-separated fields to return, e.g. symbol,marketCap,liquidityUsd. chain, address and symbol always " + "ride along. Cuts a 135KB response to a couple of KB."),
|
|
1416
|
+
limit: z2.string().optional().describe("Max rows to return, 1-200.")
|
|
777
1417
|
};
|
|
778
1418
|
var reportActivityShape = {
|
|
779
|
-
chain:
|
|
780
|
-
signature:
|
|
1419
|
+
chain: z2.string().describe('"solana" or "hood"'),
|
|
1420
|
+
signature: z2.string().describe("Transaction signature/hash to verify and record")
|
|
781
1421
|
};
|
|
782
1422
|
var getAgentProfileShape = {
|
|
783
|
-
idOrWallet:
|
|
1423
|
+
idOrWallet: z2.string().describe("Candle username or wallet address")
|
|
784
1424
|
};
|
|
785
1425
|
var getOperationShape = {
|
|
786
|
-
clientId:
|
|
787
|
-
kind:
|
|
1426
|
+
clientId: z2.string().describe("The clientTradeId or clientLaunchId the write used, or that the tool echoed back to you"),
|
|
1427
|
+
kind: z2.enum(["trade", "launch"]).describe("Which rail the id belongs to. Required: ids are caller-chosen strings with no shape to dispatch on")
|
|
788
1428
|
};
|
|
789
1429
|
var resolveTokenShape = {
|
|
790
|
-
mint:
|
|
1430
|
+
mint: z2.string().describe("Token mint (Solana, base58) or contract address (Hood, 0x-prefixed)")
|
|
791
1431
|
};
|
|
792
1432
|
var profileWalletsShape = {
|
|
793
|
-
keyPrefix:
|
|
1433
|
+
keyPrefix: z2.string().describe("The profile's API key prefix, as listed by candle keys list or in the dashboard")
|
|
794
1434
|
};
|
|
795
1435
|
var profilePnlShape = {
|
|
796
|
-
keyPrefix:
|
|
1436
|
+
keyPrefix: z2.string().describe("The profile's API key prefix")
|
|
797
1437
|
};
|
|
798
1438
|
var profileTradesShape = {
|
|
799
|
-
keyPrefix:
|
|
800
|
-
limit:
|
|
1439
|
+
keyPrefix: z2.string().describe("The profile's API key prefix"),
|
|
1440
|
+
limit: z2.number().optional().describe("How many of the most recent trades to return. Default 200, max 1000.")
|
|
801
1441
|
};
|
|
802
1442
|
var setProfileWalletsShape = {
|
|
803
|
-
keyPrefix:
|
|
804
|
-
walletIds:
|
|
1443
|
+
keyPrefix: z2.string().describe("The profile's API key prefix"),
|
|
1444
|
+
walletIds: z2.array(z2.string()).describe("The linked-wallet ids the profile may spend from -- ids, not addresses. This REPLACES the " + "whole set: any wallet omitted loses access. An empty array assigns none.")
|
|
805
1445
|
};
|
|
806
1446
|
var swapShape = {
|
|
807
|
-
from:
|
|
808
|
-
to:
|
|
809
|
-
amount:
|
|
810
|
-
amountRaw:
|
|
811
|
-
maxSlippageBps:
|
|
812
|
-
clientSwapId:
|
|
1447
|
+
from: z2.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).describe("Base asset to spend"),
|
|
1448
|
+
to: z2.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).describe("Base asset to receive; must differ from `from`"),
|
|
1449
|
+
amount: z2.string().optional().describe('Decimal amount of `from` to spend, e.g. "0.5". Preferred. Pass exactly one of amount or amountRaw.'),
|
|
1450
|
+
amountRaw: z2.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`."),
|
|
1451
|
+
maxSlippageBps: z2.number().optional().describe("Slippage bound in bps, 0-10000. Server defaults to 100 (1%)"),
|
|
1452
|
+
clientSwapId: z2.string().optional().describe("Durable idempotency key. Same id + same from/to/amountRaw/effective slippage (omitted means " + "100 bps) replays the stored result, including an indeterminate SWAP_FAILED with the " + "signature in the message. A different body is rejected. Omit it and nothing is coalesced. " + "Pass one so a timeout retry returns that stored result.")
|
|
813
1453
|
};
|
|
814
1454
|
var tradeShape = {
|
|
815
|
-
mint:
|
|
816
|
-
side:
|
|
817
|
-
amount:
|
|
818
|
-
percent:
|
|
819
|
-
quoteAsset:
|
|
820
|
-
maxSlippageBps:
|
|
821
|
-
clientTradeId:
|
|
822
|
-
paper:
|
|
1455
|
+
mint: z2.string().describe("Token mint (solana) or contract address (hood)"),
|
|
1456
|
+
side: z2.enum(["buy", "sell"]),
|
|
1457
|
+
amount: z2.string().optional().describe("Decimal amount. Buys: how much of THIS TOKEN'S OWN quote asset to spend (SOL for a " + 'SOL-launched token, USDC for a USDC-quoted one, and so on: e.g. "0.5"). Sells: how many ' + "TOKENS to sell. Pass exactly one of amount or percent."),
|
|
1458
|
+
percent: z2.number().optional().describe("Sells only: sell this percent (integer 1-100) of the holding. Live trades size against the " + "embedded wallet. Paper trades (`paper: true`) size against this key's paper inventory -- " + "the position a previous paper buy credited -- because paper never moves the live wallet."),
|
|
1459
|
+
quoteAsset: z2.string().optional().describe('What the wallet spends on a buy or receives on a sell: "sol", "usdc" or "cndl" on Solana, ' + '"eth" or "usdg" on Hood. Safe to pass through from candle_quote. On Solana it applies only ' + "to an arbitrary mint Candle never launched (Pro/Max) and is ignored for a Candle token, " + "whose quote comes from the token itself. On Hood it is the settlement asset of a DEX " + "trade; a USDG buy adds an approval transaction an ETH buy does not. It is not the route: " + "the cheapest path to the asset is chosen separately. Defaults to sol / ETH settlement."),
|
|
1460
|
+
maxSlippageBps: z2.number().optional().describe("Max slippage in basis points; API default applies when omitted"),
|
|
1461
|
+
clientTradeId: z2.string().optional().describe("Idempotency key. Auto-generated when omitted and echoed in the result. Retrying with the " + "SAME id is safe (idempotent replay); a new id is a SECOND trade."),
|
|
1462
|
+
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."))
|
|
823
1463
|
};
|
|
824
1464
|
var { buyAmount: _rawBuyAmount, ...seedableLaunchShape } = launchTokenShape;
|
|
825
1465
|
var launchAndSeedShape = {
|
|
826
1466
|
...seedableLaunchShape,
|
|
827
|
-
clientLaunchId:
|
|
828
|
-
devBuy:
|
|
1467
|
+
clientLaunchId: z2.string().optional().describe("Idempotency key. Auto-generated when omitted and echoed in the result."),
|
|
1468
|
+
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. 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.")
|
|
829
1469
|
};
|
|
830
1470
|
var transferShape = {
|
|
831
|
-
chain:
|
|
832
|
-
asset:
|
|
833
|
-
mint:
|
|
834
|
-
amountRaw:
|
|
835
|
-
to:
|
|
836
|
-
clientTransferId:
|
|
1471
|
+
chain: z2.enum(["solana", "hood"]).describe("Which chain the transfer executes on"),
|
|
1472
|
+
asset: z2.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).optional().describe("A base asset key. Pass exactly one of asset or mint."),
|
|
1473
|
+
mint: z2.string().optional().describe("An arbitrary token: SPL mint (Solana) or ERC-20 contract (Hood). Own-wallet destinations only; withdrawals to approved addresses are base-assets-only."),
|
|
1474
|
+
amountRaw: z2.string().describe('RAW base units as a decimal string (lamports, wei, token raw units), or "max" to sweep the spendable balance. NOT a human decimal: 1 SOL is "1000000000".'),
|
|
1475
|
+
to: z2.string().describe("Destination address. Must be one of the account's own wallets, or an address the OWNER pre-approved as a withdrawal address in the Candle console -- anything else is refused before signing."),
|
|
1476
|
+
clientTransferId: z2.string().optional().describe("Caller-chosen idempotency key for safe retries")
|
|
837
1477
|
};
|
|
838
1478
|
var sweepShape = {
|
|
839
|
-
chain:
|
|
840
|
-
to:
|
|
841
|
-
mints:
|
|
1479
|
+
chain: z2.enum(["solana", "hood"]).describe("Which chain to sweep"),
|
|
1480
|
+
to: z2.string().describe("Destination address, same rules as candle_transfer's `to`"),
|
|
1481
|
+
mints: z2.array(z2.string()).optional().describe("Extra token mints/contracts to sweep besides the chain's base assets (own-wallet destinations only).")
|
|
842
1482
|
};
|
|
843
1483
|
function registerTools(server, env = process.env) {
|
|
844
1484
|
const cfg = resolveConfig(env);
|
|
@@ -862,7 +1502,7 @@ function registerTools(server, env = process.env) {
|
|
|
862
1502
|
title: "Token forensics",
|
|
863
1503
|
description: `Gate a buy before making it: who launched it (resolved on-chain; pump.fun's shared updateAuthority is never the developer; when no developer is on chain, deployer.attribution names the launchpad or issuer instead, e.g. launched via stonk.fun or issued by xStocks), their went-to-zero rate and last coins, who bought in the deploy window (the creator's own wallets are marked disclosed; strangers in the same slot are the bundle signal), same-funder insider share, same-funder deployer cluster, and safety.summary with six sourced flags (mintAuthority, freezeAuthority, tokenExtensions, lpLock, sellability, liquidityDrain). Refuse an unprompted buy when flagged; incomplete or unknown is not clearance. launch.deployerLaunches is an inclusive informational count, never a warning. Every measurement carries a coverage note -- 'unavailable' is not 'clean'. No key needed.
|
|
864
1504
|
|
|
865
|
-
MARKET_NOT_FOUND means Candle has no market for that token and this could not run. That is also not 'clean': report that you could not check it, rather than reporting the token as safe. That refusal now carries error.coverage -- covered:false, a reason ('external_launchpad' when the token launched somewhere else, 'unknown_mint' when nobody has indexed it), the launchpad when known, and every check that consequently did not run. Read it instead of guessing. Most of the feed, and any Solana mint Jupiter's index knows, now answers with a partial report instead.`,
|
|
1505
|
+
MARKET_NOT_FOUND means Candle has no market for that token and this could not run. That is also not 'clean': report that you could not check it, rather than reporting the token as safe. That refusal now carries error.coverage -- covered:false, a reason ('external_launchpad' when the token launched somewhere else, 'unknown_mint' when nobody has indexed it), the launchpad when known, and every check that consequently did not run. Read it instead of guessing. Most of the feed, and any Solana mint Jupiter's index knows, now answers with a partial report instead. On Hood, a token Candle did not launch names its launch account (deployer.method cvc_launch_account) for pons.family and pools.trade launches, with no record of earlier coins; other Hood launchpads return no developer.`,
|
|
866
1506
|
inputSchema: tokenForensicsShape
|
|
867
1507
|
}, async (args) => callAndRelay("candle_token_forensics", args, cfg));
|
|
868
1508
|
register("candle_get_feed", {
|
|
@@ -914,7 +1554,7 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
|
|
|
914
1554
|
}, async (args) => callAndRelay("candle_set_profile_wallets", args, cfg));
|
|
915
1555
|
register("candle_get_profile_pnl", {
|
|
916
1556
|
title: "Read an agent profile's P&L",
|
|
917
|
-
description: "Realized profit for this profile's own fills, the Candle fees charged against it, and the " + "positions it still holds with their cost basis, each MARKED at Candle's current price where one " + "exists: `markPriceUsd`, `marketValueUsd` and `unrealizedUsd` per position, and `unrealizedUsd` " + "overall. A position with no price is counted in `unmarkedPositions` and left out of unrealized, " + "never valued at zero; `oldestMarkAt` says how old the marks are. Deposits, withdrawals and " + "transfers are excluded: funding a wallet is not profit. Check `unvalued` and `truncated` before " + "quoting the number; they mean the total is partial. `pnl.byWallet` splits the same fills by the " + "wallet that paid for each trade (main or linked, with the linked wallet's label), one row per " + "wallet with the same figures, for tracking one strategy per wallet. Each row is an independent " + "average-cost pool over its own fills, so the rows need not sum to the total (a token moved " + "between wallets, or two wallets holding one token at different costs); the total stays correct. " + "Reads only.",
|
|
1557
|
+
description: "Realized profit for this profile's own fills, the Candle fees charged against it, and the " + "positions it still holds with their cost basis, each MARKED at Candle's current price where one " + "exists: `markPriceUsd`, `marketValueUsd` and `unrealizedUsd` per position, and `unrealizedUsd` " + "overall. A position with no price is counted in `unmarkedPositions` and left out of unrealized, " + "never valued at zero; `oldestMarkAt` says how old the marks are. Deposits, withdrawals and " + "transfers are excluded: funding a wallet is not profit. Check `unvalued` and `truncated` before " + "quoting the number; they mean the total is partial. `pnl.byWallet` splits the same fills by the " + "wallet that paid for each trade (main or linked, with the linked wallet's label), one row per " + "wallet with the same figures, for tracking one strategy per wallet. Each row is an independent " + "average-cost pool over its own fills, so the rows need not sum to the total (a token moved " + "between wallets, or two wallets holding one token at different costs); the total stays correct. " + "Every open position and every `byWallet` row (and its positions) carries `chain` ('solana' or " + "'hood'), and `pnl.byChain` has the total's figures over each chain's fills alone, both chains " + "always present, with `openPositions` there a count (the positions are in `pnl.openPositions`, by " + "`chain`). Each chain is its own average-cost pool, so the two need not sum exactly to the total. A " + "server that predates per-chain P&L omits `chain` and `byChain`. Reads only.",
|
|
918
1558
|
inputSchema: profilePnlShape
|
|
919
1559
|
}, async (args) => callAndRelay("candle_get_profile_pnl", args, cfg));
|
|
920
1560
|
register("candle_get_profile_trades", {
|
|
@@ -922,6 +1562,11 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
|
|
|
922
1562
|
description: "Orders, actual fills, fees, timestamps and transaction hashes for this profile. Includes FAILED " + "trades, with an errorCode saying why each did not go through, so this answers 'what happened to " + "my order' as well as 'what did I trade'. Reads only; moves nothing.",
|
|
923
1563
|
inputSchema: profileTradesShape
|
|
924
1564
|
}, async (args) => callAndRelay("candle_get_profile_trades", args, cfg));
|
|
1565
|
+
register("candle_get_portfolio", {
|
|
1566
|
+
title: "Read what the account holds, on Solana and Hood",
|
|
1567
|
+
description: "Balances and prices for the wallets Candle already knows on this account, on both chains: the " + "embedded wallet and every TEE wallet. Solana wallets are in `embedded` and `tee` (SOL as raw " + "`lamports`, tokens as raw `amountRaw` with `decimals`). Hood wallets are in `hood.embedded` and " + "`hood.tee` (ETH as raw `wei`, ERC-20s including USDG), never in the top-level arrays. Every " + "wallet and holding carries `chain`. Prices are in `prices`: a Solana mint under its own " + "address, Hood ETH under `hood:native`, and a Hood token under `hood:<contract lowercased>`, so " + "lowercase the address before looking it up. An unpriced entry is `priceUsd: null`, never zero; " + "a Hood one says why in `unpricedReason` (no-market-row, unusable-price, stale-mark, " + "source-unavailable), and `hood.unpriced` / `hood.unpricedByReason` count them. A Hood token " + "mark older than six hours is not used, so many Hood tokens read unpriced. A wallet whose read " + "failed has null balances, never zero, and is listed in `unavailable` (Solana) or " + "`hood.unavailable`; check `complete` before quoting a total. Vault and external wallets are " + "not included: Candle does not know their addresses (the Candle CLI's `candle portfolio` reads " + "them over your own RPC). Needs a key with the account:read scope; without it the answer is " + "SCOPE_MISSING. A server that predates Hood in the portfolio omits `hood` and `chain`. Reads " + "only; moves nothing.",
|
|
1568
|
+
inputSchema: {}
|
|
1569
|
+
}, async () => callAndRelay("candle_get_portfolio", {}, cfg));
|
|
925
1570
|
register("candle_resolve_token", {
|
|
926
1571
|
title: "Resolve a contract address to a token",
|
|
927
1572
|
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.",
|
|
@@ -997,6 +1642,46 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
|
|
|
997
1642
|
const result = await executeLaunchAndSeed(args, cfg, fetch);
|
|
998
1643
|
return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
|
|
999
1644
|
});
|
|
1645
|
+
const perpsTool = (tool) => async (args) => {
|
|
1646
|
+
const result = await executePerps(tool, args, cfg, env, fetch);
|
|
1647
|
+
return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
|
|
1648
|
+
};
|
|
1649
|
+
const perpsWrite = " MOVES REAL FUNDS on Hyperliquid unless submit is false. Candle builds the action within this key's limits " + "(USD window, maximum leverage, maximum position, slippage and price bands, main-exchange markets only); this " + "server recomputes its hash and checks the action type and Candle's builder before signing, then submits it to " + "Hyperliquid itself. Needs perps:write on the key and CANDLE_KEY_SIGNER_PEM_FILE. A refusal names the limit " + "that refused it (error.limit).";
|
|
1650
|
+
register("candle_perps_setup", {
|
|
1651
|
+
title: "Set up Hyperliquid perps",
|
|
1652
|
+
description: "Approve Candle's Hyperliquid builder fee (0.1%) once for the key's EVM TEE wallet, and report the " + "account's mode and balance. Idempotent: nothing is signed when the approval is already on Hyperliquid." + perpsWrite,
|
|
1653
|
+
inputSchema: perpsShapes.candle_perps_setup
|
|
1654
|
+
}, perpsTool("candle_perps_setup"));
|
|
1655
|
+
register("candle_perps_open", {
|
|
1656
|
+
title: "Open a perps position",
|
|
1657
|
+
description: "Open or add to a perpetual position on Hyperliquid's main perp exchange: a market order (IOC within " + "slippageBps of the mid) without `price`, a limit order with it, and optional reduce-only takeProfit and " + "stopLoss triggers. Free, Believer and Pro pay a 0.1% builder fee; Max pays none." + perpsWrite,
|
|
1658
|
+
inputSchema: perpsShapes.candle_perps_open
|
|
1659
|
+
}, perpsTool("candle_perps_open"));
|
|
1660
|
+
register("candle_perps_close", {
|
|
1661
|
+
title: "Close a perps position",
|
|
1662
|
+
description: "Close all or part of a position with a reduce-only IOC order. It reserves nothing, so a key at its USD " + "cap can still close." + perpsWrite,
|
|
1663
|
+
inputSchema: perpsShapes.candle_perps_close
|
|
1664
|
+
}, perpsTool("candle_perps_close"));
|
|
1665
|
+
register("candle_perps_cancel", {
|
|
1666
|
+
title: "Cancel a perps order",
|
|
1667
|
+
description: "Cancel an order Candle built, by its cloid (from candle_perps_open or candle_perps_orders)." + perpsWrite,
|
|
1668
|
+
inputSchema: perpsShapes.candle_perps_cancel
|
|
1669
|
+
}, perpsTool("candle_perps_cancel"));
|
|
1670
|
+
register("candle_perps_orders", {
|
|
1671
|
+
title: "Perps orders",
|
|
1672
|
+
description: "Open orders on Hyperliquid for the key's EVM TEE wallet, and every action Candle built for it with what " + "the venue shows. Reads only; moves nothing.",
|
|
1673
|
+
inputSchema: perpsShapes.candle_perps_orders
|
|
1674
|
+
}, perpsTool("candle_perps_orders"));
|
|
1675
|
+
register("candle_perps_positions", {
|
|
1676
|
+
title: "Perps positions",
|
|
1677
|
+
description: "Positions, account value and withdrawable balance on Hyperliquid for the key's EVM TEE wallet, read " + "live by its address. Reads only; moves nothing.",
|
|
1678
|
+
inputSchema: perpsShapes.candle_perps_positions
|
|
1679
|
+
}, perpsTool("candle_perps_positions"));
|
|
1680
|
+
register("candle_perps_leverage", {
|
|
1681
|
+
title: "Set perps leverage",
|
|
1682
|
+
description: "Set a market's leverage and margin mode (cross or isolated) on the account, within the key's maximum leverage." + perpsWrite,
|
|
1683
|
+
inputSchema: perpsShapes.candle_perps_leverage
|
|
1684
|
+
}, perpsTool("candle_perps_leverage"));
|
|
1000
1685
|
}
|
|
1001
1686
|
|
|
1002
1687
|
// src/server.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@candledottv/mcp",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.5",
|
|
4
4
|
"description": "MCP server for the Candle agent rail: launch tokens, read markets, report activity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@modelcontextprotocol/sdk": "1.22.0",
|
|
50
|
+
"@noble/hashes": "1.8.0",
|
|
50
51
|
"zod": "^3.23.0"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|