@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.
Files changed (3) hide show
  1. package/README.md +15 -3
  2. package/dist/index.js +737 -65
  3. 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.4";
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: z.string().describe("Caller-chosen idempotency key, unique per account"),
755
- name: z.string().describe("Token name"),
756
- symbol: z.string().describe("Token symbol"),
757
- imageUrl: z.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"),
758
- bannerUrl: z.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"),
759
- chain: z.string().optional().describe('"solana" or "hood"; defaults to solana'),
760
- quoteAsset: z.string().optional().describe("Quote asset symbol; defaults per chain"),
761
- mode: z.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"),
762
- stakerAllocationBps: z.number().optional().describe("Staker allocation in basis points"),
763
- dexVersion: z.string().nullable().optional().describe('"v3" or "v4"; required for hood launches'),
764
- buyAmount: z.union([z.string(), z.number()]).optional().describe("Initial buy, in quote base units (string or number)"),
765
- description: z.string().optional(),
766
- socials: z.record(z.string()).optional().describe("Social links keyed by platform"),
767
- visibility: z.string().optional().describe('"production", "test", "local", or "hidden"'),
768
- dryRun: z.boolean().optional().describe("Validate without executing the launch")
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: z.string().describe('"solana" or "hood"'),
772
- mint: z.string().describe("Token mint (solana) or contract address (hood)")
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: z.string().describe('"solana" or "hood"'),
776
- mint: z.string().describe("Token mint (solana) or contract address (hood)")
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: z.enum(["new", "graduated", "onfire", "bluechip"]),
780
- chain: z.string().optional().describe("Optional chain filter"),
781
- where: z.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."),
782
- sort: z.string().optional().describe('Sort as "field" or "field:asc" / "field:desc". A bare field means desc.'),
783
- fields: z.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."),
784
- limit: z.string().optional().describe("Max rows to return, 1-200.")
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: z.string().describe('"solana" or "hood"'),
788
- signature: z.string().describe("Transaction signature/hash to verify and record")
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: z.string().describe("Candle username or wallet address")
1423
+ idOrWallet: z2.string().describe("Candle username or wallet address")
792
1424
  };
793
1425
  var getOperationShape = {
794
- clientId: z.string().describe("The clientTradeId or clientLaunchId the write used, or that the tool echoed back to you"),
795
- kind: z.enum(["trade", "launch"]).describe("Which rail the id belongs to. Required: ids are caller-chosen strings with no shape to dispatch on")
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: z.string().describe("Token mint (Solana, base58) or contract address (Hood, 0x-prefixed)")
1430
+ mint: z2.string().describe("Token mint (Solana, base58) or contract address (Hood, 0x-prefixed)")
799
1431
  };
800
1432
  var profileWalletsShape = {
801
- keyPrefix: z.string().describe("The profile's API key prefix, as listed by candle keys list or in the dashboard")
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: z.string().describe("The profile's API key prefix")
1436
+ keyPrefix: z2.string().describe("The profile's API key prefix")
805
1437
  };
806
1438
  var profileTradesShape = {
807
- keyPrefix: z.string().describe("The profile's API key prefix"),
808
- limit: z.number().optional().describe("How many of the most recent trades to return. Default 200, max 1000.")
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: z.string().describe("The profile's API key prefix"),
812
- walletIds: z.array(z.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.")
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: z.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).describe("Base asset to spend"),
816
- to: z.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).describe("Base asset to receive; must differ from `from`"),
817
- amount: z.string().optional().describe('Decimal amount of `from` to spend, e.g. "0.5". Preferred. Pass exactly one of amount or amountRaw.'),
818
- amountRaw: z.string().optional().describe("Raw base units of `from`, as a positive integer string. Kept for callers that already " + "compute raw units; new callers should use `amount`."),
819
- maxSlippageBps: z.number().optional().describe("Slippage bound in bps, 0-10000. Server defaults to 100 (1%)"),
820
- clientSwapId: z.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.")
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: z.string().describe("Token mint (solana) or contract address (hood)"),
824
- side: z.enum(["buy", "sell"]),
825
- amount: z.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."),
826
- percent: z.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."),
827
- quoteAsset: z.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."),
828
- maxSlippageBps: z.number().optional().describe("Max slippage in basis points; API default applies when omitted"),
829
- clientTradeId: z.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."),
830
- paper: z.preprocess((value) => value === "true" || value === 1 || value === "1" ? true : value === "false" || value === 0 || value === "0" ? false : value, z.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."))
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: z.string().optional().describe("Idempotency key. Auto-generated when omitted and echoed in the result."),
836
- devBuy: z.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.")
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: z.enum(["solana", "hood"]).describe("Which chain the transfer executes on"),
840
- asset: z.enum(["SOL", "USDC", "CNDL", "ETH", "USDG"]).optional().describe("A base asset key. Pass exactly one of asset or mint."),
841
- mint: z.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."),
842
- amountRaw: z.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".'),
843
- to: z.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."),
844
- clientTransferId: z.string().optional().describe("Caller-chosen idempotency key for safe retries")
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: z.enum(["solana", "hood"]).describe("Which chain to sweep"),
848
- to: z.string().describe("Destination address, same rules as candle_transfer's `to`"),
849
- mints: z.array(z.string()).optional().describe("Extra token mints/contracts to sweep besides the chain's base assets (own-wallet destinations only).")
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.4",
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": {