@candledottv/mcp 0.10.4 → 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 +15 -3
- package/dist/index.js +737 -65
- 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
|
-
twenty tools over stdio, so an MCP-capable agent can launch tokens (optionally seeded with a dev
|
|
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 twenty. An unknown name fails startup with the valid names in the message, rather
|
|
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
|
|
|
@@ -102,6 +107,13 @@ tools, getting a key, funding the embedded wallet, and idempotent retries, see
|
|
|
102
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`) |
|
|
103
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` |
|
|
104
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` |
|
|
105
117
|
|
|
106
118
|
### Transfers and sweeps
|
|
107
119
|
|
|
@@ -118,7 +130,7 @@ explicitly. Assets with nothing spendable report `empty`; a failed asset never s
|
|
|
118
130
|
## Errors
|
|
119
131
|
|
|
120
132
|
This package never reinterprets an error body, and that body is not one uniform shape across all
|
|
121
|
-
twenty tools:
|
|
133
|
+
twenty-seven tools:
|
|
122
134
|
|
|
123
135
|
- `candle_launch_token`, `candle_get_market`, and `candle_get_feed` hit endpoints that use the
|
|
124
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",
|
|
@@ -572,7 +1197,14 @@ var TOOL_NAMES = [
|
|
|
572
1197
|
"candle_get_portfolio",
|
|
573
1198
|
"candle_resolve_token",
|
|
574
1199
|
"candle_execution_status",
|
|
575
|
-
"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"
|
|
576
1208
|
];
|
|
577
1209
|
function resolveToolAllowlist(env) {
|
|
578
1210
|
const raw = env.CANDLE_MCP_TOOLS?.trim();
|
|
@@ -751,102 +1383,102 @@ async function callAndRelay(name, args, cfg) {
|
|
|
751
1383
|
};
|
|
752
1384
|
}
|
|
753
1385
|
var launchTokenShape = {
|
|
754
|
-
clientLaunchId:
|
|
755
|
-
name:
|
|
756
|
-
symbol:
|
|
757
|
-
imageUrl:
|
|
758
|
-
bannerUrl:
|
|
759
|
-
chain:
|
|
760
|
-
quoteAsset:
|
|
761
|
-
mode:
|
|
762
|
-
stakerAllocationBps:
|
|
763
|
-
dexVersion:
|
|
764
|
-
buyAmount:
|
|
765
|
-
description:
|
|
766
|
-
socials:
|
|
767
|
-
visibility:
|
|
768
|
-
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")
|
|
769
1401
|
};
|
|
770
1402
|
var getMarketShape = {
|
|
771
|
-
chain:
|
|
772
|
-
mint:
|
|
1403
|
+
chain: z2.string().describe('"solana" or "hood"'),
|
|
1404
|
+
mint: z2.string().describe("Token mint (solana) or contract address (hood)")
|
|
773
1405
|
};
|
|
774
1406
|
var tokenForensicsShape = {
|
|
775
|
-
chain:
|
|
776
|
-
mint:
|
|
1407
|
+
chain: z2.string().describe('"solana" or "hood"'),
|
|
1408
|
+
mint: z2.string().describe("Token mint (solana) or contract address (hood)")
|
|
777
1409
|
};
|
|
778
1410
|
var getFeedShape = {
|
|
779
|
-
bucket:
|
|
780
|
-
chain:
|
|
781
|
-
where:
|
|
782
|
-
sort:
|
|
783
|
-
fields:
|
|
784
|
-
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.")
|
|
785
1417
|
};
|
|
786
1418
|
var reportActivityShape = {
|
|
787
|
-
chain:
|
|
788
|
-
signature:
|
|
1419
|
+
chain: z2.string().describe('"solana" or "hood"'),
|
|
1420
|
+
signature: z2.string().describe("Transaction signature/hash to verify and record")
|
|
789
1421
|
};
|
|
790
1422
|
var getAgentProfileShape = {
|
|
791
|
-
idOrWallet:
|
|
1423
|
+
idOrWallet: z2.string().describe("Candle username or wallet address")
|
|
792
1424
|
};
|
|
793
1425
|
var getOperationShape = {
|
|
794
|
-
clientId:
|
|
795
|
-
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")
|
|
796
1428
|
};
|
|
797
1429
|
var resolveTokenShape = {
|
|
798
|
-
mint:
|
|
1430
|
+
mint: z2.string().describe("Token mint (Solana, base58) or contract address (Hood, 0x-prefixed)")
|
|
799
1431
|
};
|
|
800
1432
|
var profileWalletsShape = {
|
|
801
|
-
keyPrefix:
|
|
1433
|
+
keyPrefix: z2.string().describe("The profile's API key prefix, as listed by candle keys list or in the dashboard")
|
|
802
1434
|
};
|
|
803
1435
|
var profilePnlShape = {
|
|
804
|
-
keyPrefix:
|
|
1436
|
+
keyPrefix: z2.string().describe("The profile's API key prefix")
|
|
805
1437
|
};
|
|
806
1438
|
var profileTradesShape = {
|
|
807
|
-
keyPrefix:
|
|
808
|
-
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.")
|
|
809
1441
|
};
|
|
810
1442
|
var setProfileWalletsShape = {
|
|
811
|
-
keyPrefix:
|
|
812
|
-
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.")
|
|
813
1445
|
};
|
|
814
1446
|
var swapShape = {
|
|
815
|
-
from:
|
|
816
|
-
to:
|
|
817
|
-
amount:
|
|
818
|
-
amountRaw:
|
|
819
|
-
maxSlippageBps:
|
|
820
|
-
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.")
|
|
821
1453
|
};
|
|
822
1454
|
var tradeShape = {
|
|
823
|
-
mint:
|
|
824
|
-
side:
|
|
825
|
-
amount:
|
|
826
|
-
percent:
|
|
827
|
-
quoteAsset:
|
|
828
|
-
maxSlippageBps:
|
|
829
|
-
clientTradeId:
|
|
830
|
-
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."))
|
|
831
1463
|
};
|
|
832
1464
|
var { buyAmount: _rawBuyAmount, ...seedableLaunchShape } = launchTokenShape;
|
|
833
1465
|
var launchAndSeedShape = {
|
|
834
1466
|
...seedableLaunchShape,
|
|
835
|
-
clientLaunchId:
|
|
836
|
-
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.")
|
|
837
1469
|
};
|
|
838
1470
|
var transferShape = {
|
|
839
|
-
chain:
|
|
840
|
-
asset:
|
|
841
|
-
mint:
|
|
842
|
-
amountRaw:
|
|
843
|
-
to:
|
|
844
|
-
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")
|
|
845
1477
|
};
|
|
846
1478
|
var sweepShape = {
|
|
847
|
-
chain:
|
|
848
|
-
to:
|
|
849
|
-
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).")
|
|
850
1482
|
};
|
|
851
1483
|
function registerTools(server, env = process.env) {
|
|
852
1484
|
const cfg = resolveConfig(env);
|
|
@@ -1010,6 +1642,46 @@ MARKET_NOT_FOUND means Candle has no market for that token and this could not ru
|
|
|
1010
1642
|
const result = await executeLaunchAndSeed(args, cfg, fetch);
|
|
1011
1643
|
return { content: [{ type: "text", text: result.text }], ...result.isError ? { isError: true } : {} };
|
|
1012
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"));
|
|
1013
1685
|
}
|
|
1014
1686
|
|
|
1015
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": {
|