@cloakely/mcp 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bob Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,115 @@
1
- # Temporary Holding Version
1
+ # @cloakely/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Cloak's MCP server.** It lets an AI agent launch tokens with sealed bids and trade them privately on Kinetiq's Elysium, from Claude, Cursor or any MCP client.
4
+
5
+ An agent that trades on chain leaks its strategy with every transaction, and anyone can copy or front-run it. With Cloak, its bids stay sealed until bidding closes, and its trades come from a private balance: each one is proven on the agent's machine and sent by a relayer, so the agent's wallet never appears on them.
6
+
7
+ **Testnet only for now** (Elysium testnet, chain `99801`). Play money only.
8
+
9
+ There are two ways in:
10
+
11
+ | | Hosted link | Local server (this package) |
12
+ |---|---|---|
13
+ | Install | Nothing: paste `https://cloak.boblabs.tech/mcp` | `npx -y @cloakely/mcp` |
14
+ | Can do | Read and plan: launches, quotes, vault totals, launch plans | Everything: launch, bid, claim, shield, private swaps, batch orders, private sends, withdrawals |
15
+ | Keys | None. It never signs and refuses calls that look like they carry a key | Your agent's wallet, on your machine only |
16
+
17
+ ## Connect by link
18
+
19
+ - **Claude** (claude.ai or Desktop): Settings, Connectors, Add custom connector, then paste `https://cloak.boblabs.tech/mcp`.
20
+ - **ChatGPT**: turn on developer mode under Apps and connectors, then add a connector with the link and no authentication.
21
+ - **Claude Code**: `claude mcp add --transport http cloak https://cloak.boblabs.tech/mcp`
22
+ - **Cursor, VS Code and others**: `{ "mcpServers": { "cloak": { "url": "https://cloak.boblabs.tech/mcp" } } }`
23
+
24
+ Its tools: `cloak_info`, `list_launches`, `get_launch`, `quote_bid`, `quote_swap`, `vault_stats`, `plan_launch` and `connect_full_agent`. It's stateless (each request gets a fresh server and nothing is kept) and rate limited. You can serve it yourself from any runtime that speaks web `Request`/`Response`: `import { handleMcpRequest } from "@cloakely/mcp/http"`.
25
+
26
+ ## Set up the local server
27
+
28
+ Create a fresh wallet for the agent, fund it from the [Elysium faucet](https://elysium.kinetiq.xyz/testnet-faucet), and put its private key in a file only you can read:
29
+
30
+ ```sh
31
+ umask 077 && echo 0xYOUR_AGENT_KEY > ~/.cloak-agent-key
32
+ ```
33
+
34
+ **Claude Code:**
35
+
36
+ ```sh
37
+ claude mcp add cloak --env CLOAK_KEY_FILE=$HOME/.cloak-agent-key -- npx -y @cloakely/mcp
38
+ ```
39
+
40
+ **Claude Desktop, Cursor and other clients** (`claude_desktop_config.json`, `.cursor/mcp.json`, and so on):
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "cloak": {
46
+ "command": "npx",
47
+ "args": ["-y", "@cloakely/mcp"],
48
+ "env": { "CLOAK_KEY_FILE": "/Users/you/.cloak-agent-key" }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ Without a key, the server is **read only**: the agent can browse launches and quote bids and swaps, but can't sign anything.
55
+
56
+ Then ask, for example:
57
+ - "What Cloak launches are open right now?"
58
+ - "Launch a token called Ember, 1M for sale, starting at 0.000001 HYPE, bidding open for an hour."
59
+ - "Bid on EMBR up to 0.00002 HYPE for 50,000 tokens. Dry run first."
60
+ - "Shield 0.5 HYPE, then buy CLOAK privately with 0.1 of it."
61
+ - "Send 0.05 private HYPE to cloak:…, then withdraw the rest to 0x…"
62
+
63
+ ## Tools
64
+
65
+ | Group | Tool | What it does |
66
+ |---|---|---|
67
+ | Read | `cloak_info` | Network, contracts, the wallet and its public balances, spending limits with what's left today |
68
+ | | `list_launches` | Launches, newest first, with phase, timing, prices and whether private trading is on |
69
+ | | `get_launch` | One launch in full: timing, prices, caps, results, revealed demand once bids open |
70
+ | | `quote_bid` | The price tick, cost and rounded deposit for a bid |
71
+ | | `quote_swap` | Expected output of a private buy or sell, with the fees |
72
+ | | `vault_stats` | How much sits in each private vault (totals only) |
73
+ | Launch | `create_launch` | Start a sealed-bid launch |
74
+ | | `place_sealed_bid` | Commit a sealed bid. It opens by itself after bidding closes |
75
+ | | `my_bids` | The wallet's bids: opened, claimed, tokens won, refund |
76
+ | | `claim` | Settle if nobody has, then claim tokens and refunds |
77
+ | | `enable_private_trading` | Open a private vault for a settled launch's token |
78
+ | Private | `unlock` | Derive the private account from one local signature and return its `cloak:` address |
79
+ | | `private_balance` | HYPE, USDC and launch tokens held privately |
80
+ | | `shield` | HYPE or USDC from the wallet into the private balance (the one public step) |
81
+ | | `private_swap` | Buy or sell a launch token from the private balance |
82
+ | | `batch_order` | Trade in standard sizes inside a batch round, which hides the trade's size |
83
+ | | `private_send` | Pay a `cloak:` address. Sender, recipient and amount stay hidden |
84
+ | | `withdraw` | Private balance to a normal address (ideally a fresh one) |
85
+
86
+ Amounts are in whole units (`"0.5"` HYPE). `private_send`, `withdraw` and a `private_swap` sell accept `"max"`.
87
+
88
+ ## Safety
89
+
90
+ - **Spending limits.** Every action that moves value is checked first: bids, shields, swaps (either side, valued in HYPE at the pool price), batch orders, sends and withdrawals. There's a cap per transaction and per UTC day for HYPE value, a separate one for USDC, and a number of launches per day. The day's totals are kept in `~/.cloak-mcp`, so restarting the server doesn't reset them.
91
+ - **Dry run.** Every write tool takes `dry_run: true` and returns exactly what it would do, without signing or sending anything. `CLOAK_DRY_RUN=1` makes every write a dry run.
92
+ - **Testnet by default.** Mainnet needs `CLOAK_NETWORK=mainnet` and `CLOAK_ALLOW_MAINNET=1`, and isn't live yet.
93
+ - **The key stays on your machine.** It signs locally and is never sent anywhere. The private account is derived from a local signature, and proofs are generated locally (the circuit files are downloaded once and checked against the hashes pinned in `@cloakely/sdk`). The relayer only sees proofs, which it can submit but can't change.
94
+ - **One write at a time**, so two calls never spend the same private notes.
95
+ - Give the agent a **dedicated wallet** with only what you're willing to let it spend.
96
+
97
+ ## Configuration
98
+
99
+ | Variable | Default | |
100
+ |---|---|---|
101
+ | `CLOAK_KEY_FILE` | | File holding the agent wallet's private key (preferred) |
102
+ | `CLOAK_PRIVATE_KEY` | | The key itself, if a file isn't practical |
103
+ | `CLOAK_NETWORK` | `testnet` | `testnet`, or `custom` with `CLOAK_DEPLOYMENT_FILE` |
104
+ | `CLOAK_MAX_HYPE_PER_TX` | `1` | HYPE value per transaction |
105
+ | `CLOAK_MAX_HYPE_PER_DAY` | `10` | HYPE value per UTC day |
106
+ | `CLOAK_MAX_USDC_PER_TX` | `100` | USDC per transaction |
107
+ | `CLOAK_MAX_USDC_PER_DAY` | `1000` | USDC per UTC day |
108
+ | `CLOAK_MAX_LAUNCHES_PER_DAY` | `3` | Launches per UTC day |
109
+ | `CLOAK_DRY_RUN` | | `1` turns every write into a dry run |
110
+ | `CLOAK_STATE_DIR` | `~/.cloak-mcp` | Daily spend totals and bid receipts |
111
+ | `CLOAK_RPC_URL` | Elysium testnet RPC | |
112
+ | `CLOAK_RELAYER_URL` | Cloak's hosted relayer | |
113
+ | `CLOAK_CIRCUITS_DIR` | download | Folder with `transaction2x2.wasm` and `transaction2x2_final.zkey` |
114
+
115
+ Built on [`@cloakely/sdk`](https://www.npmjs.com/package/@cloakely/sdk). Source: [NECOKIZZ/Cloak](https://github.com/NECOKIZZ/Cloak/tree/main/packages/mcp). MIT.
@@ -0,0 +1,25 @@
1
+ import { type Chain, type Hex } from "viem";
2
+ import { type CloakDeployment } from "@cloakely/sdk";
3
+ /** Where the server points, and what it may do. Everything comes from environment variables (see README). */
4
+ export interface Settings {
5
+ network: string;
6
+ chain: Chain;
7
+ rpcUrl: string;
8
+ deployment: CloakDeployment;
9
+ /** First block to scan for a launch's bids (the factory's deploy block). */
10
+ launchesFromBlock: bigint;
11
+ /** Local circuit files instead of downloading them (hash-checked) from `deployment.circuitsUrl`. */
12
+ circuitsDir?: string;
13
+ privateKey?: Hex;
14
+ dryRun: boolean;
15
+ stateDir: string;
16
+ limits: Limits;
17
+ }
18
+ export interface Limits {
19
+ hypePerTx: bigint;
20
+ hypePerDay: bigint;
21
+ usdcPerTx: bigint;
22
+ usdcPerDay: bigint;
23
+ launchesPerDay: number;
24
+ }
25
+ export declare function loadSettings(env?: NodeJS.ProcessEnv): Settings;
package/dist/config.js ADDED
@@ -0,0 +1,73 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { defineChain, parseEther, parseUnits } from "viem";
5
+ import { deploymentFor, elysiumTestnet } from "@cloakely/sdk";
6
+ const NETWORKS = {
7
+ testnet: { chain: elysiumTestnet, launchesFromBlock: 15855986n },
8
+ };
9
+ export function loadSettings(env = process.env) {
10
+ const network = env.CLOAK_NETWORK ?? (env.CLOAK_DEPLOYMENT_FILE ? "custom" : "testnet");
11
+ let chain;
12
+ let deployment;
13
+ let launchesFromBlock;
14
+ let rpcUrl;
15
+ if (network === "mainnet") {
16
+ if (env.CLOAK_ALLOW_MAINNET !== "1")
17
+ throw new Error("CLOAK_NETWORK=mainnet needs CLOAK_ALLOW_MAINNET=1 as well. Mainnet moves real money.");
18
+ throw new Error("Cloak mainnet isn't live yet. Use CLOAK_NETWORK=testnet.");
19
+ }
20
+ else if (network === "custom") {
21
+ if (!env.CLOAK_DEPLOYMENT_FILE)
22
+ throw new Error("CLOAK_NETWORK=custom needs CLOAK_DEPLOYMENT_FILE");
23
+ const f = JSON.parse(readFileSync(env.CLOAK_DEPLOYMENT_FILE, "utf8"));
24
+ rpcUrl = f.rpcUrl;
25
+ chain = defineChain({ id: f.chainId, name: `Cloak custom (${f.chainId})`, nativeCurrency: { name: "HYPE", symbol: "HYPE", decimals: 18 }, rpcUrls: { default: { http: [rpcUrl] } }, testnet: true });
26
+ deployment = { ...f, routerBlock: BigInt(f.routerBlock), assets: f.assets?.map((a) => ({ ...a, fromBlock: BigInt(a.fromBlock) })) };
27
+ launchesFromBlock = BigInt(f.launchesFromBlock ?? 0);
28
+ }
29
+ else {
30
+ const n = NETWORKS[network];
31
+ if (!n)
32
+ throw new Error(`unknown CLOAK_NETWORK "${network}" (use testnet)`);
33
+ chain = n.chain;
34
+ deployment = deploymentFor(chain.id);
35
+ launchesFromBlock = n.launchesFromBlock;
36
+ rpcUrl = chain.rpcUrls.default.http[0];
37
+ }
38
+ if (env.CLOAK_RPC_URL)
39
+ rpcUrl = env.CLOAK_RPC_URL;
40
+ if (env.CLOAK_RELAYER_URL)
41
+ deployment = { ...deployment, relayerUrl: env.CLOAK_RELAYER_URL };
42
+ let key = env.CLOAK_PRIVATE_KEY?.trim();
43
+ if (!key && env.CLOAK_KEY_FILE)
44
+ key = readFileSync(env.CLOAK_KEY_FILE, "utf8").trim();
45
+ if (key && !key.startsWith("0x"))
46
+ key = `0x${key}`;
47
+ if (key && !/^0x[0-9a-fA-F]{64}$/.test(key))
48
+ throw new Error("the private key must be 32 bytes of hex");
49
+ const num = (name, fallback) => {
50
+ const v = env[name] ?? fallback;
51
+ if (!/^\d+(\.\d+)?$/.test(v))
52
+ throw new Error(`${name} must be a number, got "${v}"`);
53
+ return v;
54
+ };
55
+ return {
56
+ network,
57
+ chain,
58
+ rpcUrl,
59
+ deployment,
60
+ launchesFromBlock,
61
+ circuitsDir: env.CLOAK_CIRCUITS_DIR,
62
+ privateKey: key,
63
+ dryRun: env.CLOAK_DRY_RUN === "1",
64
+ stateDir: env.CLOAK_STATE_DIR ?? join(homedir(), ".cloak-mcp"),
65
+ limits: {
66
+ hypePerTx: parseEther(num("CLOAK_MAX_HYPE_PER_TX", "1")),
67
+ hypePerDay: parseEther(num("CLOAK_MAX_HYPE_PER_DAY", "10")),
68
+ usdcPerTx: parseUnits(num("CLOAK_MAX_USDC_PER_TX", "100"), 6),
69
+ usdcPerDay: parseUnits(num("CLOAK_MAX_USDC_PER_DAY", "1000"), 6),
70
+ launchesPerDay: Number(num("CLOAK_MAX_LAUNCHES_PER_DAY", "3")),
71
+ },
72
+ };
73
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { type Settings } from "./config.js";
2
+ /** `settings` defaults to Elysium testnet, without a key (tests pass their own). */
3
+ export declare function handleMcpRequest(req: Request, opts?: {
4
+ settings?: Settings;
5
+ }): Promise<Response>;
package/dist/http.js ADDED
@@ -0,0 +1,92 @@
1
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
2
+ import { loadSettings } from "./config.js";
3
+ import { createCloakServer } from "./server.js";
4
+ /**
5
+ * The hosted MCP endpoint as a web-standard handler (Request in, Response out), for any serverless runtime.
6
+ * Streamable HTTP, stateless: every POST gets a fresh read-and-plan server, the answer is plain JSON, and nothing is
7
+ * kept. With no sessions, GET (an event stream) and DELETE (ending a session) answer 405, as the spec allows; a
8
+ * browser opening the URL is sent to the setup docs instead.
9
+ */
10
+ const REQUESTS_PER_MINUTE = 60;
11
+ const MAX_BODY_BYTES = 64_000;
12
+ const DOCS = "https://cloak.boblabs.tech/docs#agents";
13
+ const CORS = {
14
+ "Access-Control-Allow-Origin": "*",
15
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
16
+ "Access-Control-Allow-Headers": "Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID",
17
+ "Access-Control-Expose-Headers": "Mcp-Session-Id, Mcp-Protocol-Version, Retry-After",
18
+ "Access-Control-Max-Age": "86400",
19
+ };
20
+ function withHeaders(res, extra = {}) {
21
+ const headers = new Headers(res.headers);
22
+ for (const [k, v] of Object.entries({ ...CORS, "Cache-Control": "no-store", ...extra }))
23
+ headers.set(k, v);
24
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
25
+ }
26
+ const rpcError = (status, code, message, extra) => withHeaders(Response.json({ jsonrpc: "2.0", error: { code, message }, id: null }, { status }), extra);
27
+ // Best effort, per instance: a serverless platform may run several, so this caps bursts rather than guaranteeing a rate.
28
+ const hits = new Map();
29
+ function limited(ip) {
30
+ const now = Date.now();
31
+ const h = hits.get(ip);
32
+ if (!h || now - h.start > 60_000) {
33
+ if (hits.size > 10_000)
34
+ hits.clear();
35
+ hits.set(ip, { start: now, n: 1 });
36
+ return 0;
37
+ }
38
+ h.n++;
39
+ return h.n > REQUESTS_PER_MINUTE ? Math.ceil((h.start + 60_000 - now) / 1000) : 0;
40
+ }
41
+ let settings;
42
+ /** `settings` defaults to Elysium testnet, without a key (tests pass their own). */
43
+ export async function handleMcpRequest(req, opts = {}) {
44
+ if (req.method === "OPTIONS")
45
+ return withHeaders(new Response(null, { status: 204 }));
46
+ if (req.method === "GET") {
47
+ const accept = req.headers.get("accept") ?? "";
48
+ if (accept.includes("text/html") && !accept.includes("text/event-stream"))
49
+ return withHeaders(Response.redirect(DOCS, 307));
50
+ return rpcError(405, -32000, "Method not allowed. This server is stateless: POST JSON-RPC messages to this URL.", { Allow: "POST, OPTIONS" });
51
+ }
52
+ if (req.method !== "POST")
53
+ return rpcError(405, -32000, "Method not allowed. This server keeps no sessions.", { Allow: "POST, OPTIONS" });
54
+ const ip = (req.headers.get("x-forwarded-for") ?? "").split(",")[0].trim() || req.headers.get("x-real-ip") || "unknown";
55
+ const wait = limited(ip);
56
+ if (wait)
57
+ return rpcError(429, -32000, `Too many requests. Try again in ${wait} seconds.`, { "Retry-After": String(wait) });
58
+ if (Number(req.headers.get("content-length") ?? "0") > MAX_BODY_BYTES)
59
+ return rpcError(413, -32600, "Request too large.");
60
+ let body;
61
+ try {
62
+ const text = await req.text();
63
+ if (text.length > MAX_BODY_BYTES)
64
+ return rpcError(413, -32600, "Request too large.");
65
+ body = JSON.parse(text);
66
+ }
67
+ catch {
68
+ return rpcError(400, -32700, "Parse error: send a JSON-RPC message as JSON.");
69
+ }
70
+ // the body is already parsed, so be lenient about the headers: the answer is always JSON
71
+ const headers = new Headers(req.headers);
72
+ headers.set("accept", "application/json, text/event-stream");
73
+ headers.set("content-type", "application/json");
74
+ headers.delete("content-length");
75
+ headers.delete("transfer-encoding");
76
+ const forward = new Request(req.url, { method: "POST", headers });
77
+ // never a key here, whatever the environment says
78
+ const s = opts.settings ?? (settings ??= loadSettings({ CLOAK_NETWORK: "testnet", CLOAK_RPC_URL: process.env.CLOAK_RPC_URL, CLOAK_RELAYER_URL: process.env.CLOAK_RELAYER_URL }));
79
+ const server = createCloakServer(s, { hosted: true });
80
+ const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
81
+ try {
82
+ await server.connect(transport);
83
+ return withHeaders(await transport.handleRequest(forward, { parsedBody: body }));
84
+ }
85
+ catch (e) {
86
+ console.error("cloak-mcp hosted:", e?.name ?? "error");
87
+ return rpcError(500, -32603, "Internal error.");
88
+ }
89
+ finally {
90
+ await server.close().catch(() => { });
91
+ }
92
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // Cloak's MCP server over stdio: `npx @cloakely/mcp`. Configuration is environment variables only (see README).
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { createCloakServer, loadSettings, VERSION } from "./server.js";
5
+ try {
6
+ const settings = loadSettings();
7
+ const server = createCloakServer(settings);
8
+ await server.connect(new StdioServerTransport());
9
+ // stdout carries the protocol, so anything for humans goes to stderr
10
+ console.error(`cloak-mcp ${VERSION} on ${settings.network} (chain ${settings.chain.id}), ${settings.privateKey ? "wallet configured" : "read only"}${settings.dryRun ? ", dry run" : ""}`);
11
+ }
12
+ catch (e) {
13
+ console.error(`cloak-mcp: ${e.message}`);
14
+ process.exit(1);
15
+ }
@@ -0,0 +1,48 @@
1
+ import type { Limits } from "./config.js";
2
+ export type Budget = "hype" | "usdc" | "launches";
3
+ /**
4
+ * Spending limits for one wallet: a cap per transaction and per UTC day for HYPE value and for USDC, and a number of
5
+ * launches per day. The day's totals live in a file under the state directory, so restarting the server doesn't reset
6
+ * them. Only actions that succeed are counted.
7
+ */
8
+ export declare class Spending {
9
+ readonly limits: Limits;
10
+ private now;
11
+ private file;
12
+ session: {
13
+ hype: bigint;
14
+ usdc: bigint;
15
+ launches: bigint;
16
+ };
17
+ constructor(limits: Limits, stateDir: string, account: string, now?: () => Date);
18
+ private today;
19
+ private caps;
20
+ /** Throws a plain sentence if `amount` would break a limit. */
21
+ check(b: Budget, amount: bigint): void;
22
+ record(b: Budget, amount: bigint): void;
23
+ summary(): {
24
+ day: string;
25
+ hype: {
26
+ perTransaction: string;
27
+ perDay: string;
28
+ usedToday: string;
29
+ leftToday: string;
30
+ usedThisSession: string;
31
+ };
32
+ usdc: {
33
+ perTransaction: string;
34
+ perDay: string;
35
+ usedToday: string;
36
+ leftToday: string;
37
+ usedThisSession: string;
38
+ };
39
+ launches: {
40
+ perTransaction: string;
41
+ perDay: string;
42
+ usedToday: string;
43
+ leftToday: string;
44
+ usedThisSession: string;
45
+ };
46
+ };
47
+ }
48
+ export declare function show(b: Budget, x: bigint): string;
package/dist/limits.js ADDED
@@ -0,0 +1,72 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { formatEther, formatUnits } from "viem";
4
+ /**
5
+ * Spending limits for one wallet: a cap per transaction and per UTC day for HYPE value and for USDC, and a number of
6
+ * launches per day. The day's totals live in a file under the state directory, so restarting the server doesn't reset
7
+ * them. Only actions that succeed are counted.
8
+ */
9
+ export class Spending {
10
+ limits;
11
+ now;
12
+ file;
13
+ session = { hype: 0n, usdc: 0n, launches: 0n };
14
+ constructor(limits, stateDir, account, now = () => new Date()) {
15
+ this.limits = limits;
16
+ this.now = now;
17
+ mkdirSync(stateDir, { recursive: true });
18
+ this.file = join(stateDir, `spend-${account.toLowerCase()}.json`);
19
+ }
20
+ today() {
21
+ const day = this.now().toISOString().slice(0, 10);
22
+ try {
23
+ const j = JSON.parse(readFileSync(this.file, "utf8"));
24
+ if (j.day === day)
25
+ return { day, hype: BigInt(j.hype), usdc: BigInt(j.usdc), launches: BigInt(j.launches ?? 0) };
26
+ }
27
+ catch {
28
+ // no file yet, or unreadable: start the day at zero
29
+ }
30
+ return { day, hype: 0n, usdc: 0n, launches: 0n };
31
+ }
32
+ caps(b) {
33
+ if (b === "hype")
34
+ return { perTx: this.limits.hypePerTx, perDay: this.limits.hypePerDay };
35
+ if (b === "usdc")
36
+ return { perTx: this.limits.usdcPerTx, perDay: this.limits.usdcPerDay };
37
+ return { perTx: 1n, perDay: BigInt(this.limits.launchesPerDay) };
38
+ }
39
+ /** Throws a plain sentence if `amount` would break a limit. */
40
+ check(b, amount) {
41
+ const { perTx, perDay } = this.caps(b);
42
+ const used = this.today()[b];
43
+ const f = (x) => show(b, x);
44
+ if (amount > perTx)
45
+ throw new Error(`Spending limit: ${f(amount)} is over the ${f(perTx)} allowed per transaction. Raise CLOAK_MAX_${b === "hype" ? "HYPE" : "USDC"}_PER_TX to allow it.`);
46
+ if (used + amount > perDay)
47
+ throw new Error(`Spending limit: ${f(amount)} would bring today's total to ${f(used + amount)}, over the ${f(perDay)} daily limit (${f(perDay > used ? perDay - used : 0n)} left today).`);
48
+ }
49
+ record(b, amount) {
50
+ const d = this.today();
51
+ d[b] += amount;
52
+ this.session[b] += amount;
53
+ const tmp = `${this.file}.tmp`;
54
+ writeFileSync(tmp, JSON.stringify({ day: d.day, hype: d.hype.toString(), usdc: d.usdc.toString(), launches: d.launches.toString() }));
55
+ renameSync(tmp, this.file);
56
+ }
57
+ summary() {
58
+ const d = this.today();
59
+ const row = (b) => {
60
+ const { perTx, perDay } = this.caps(b);
61
+ return { perTransaction: show(b, perTx), perDay: show(b, perDay), usedToday: show(b, d[b]), leftToday: show(b, perDay > d[b] ? perDay - d[b] : 0n), usedThisSession: show(b, this.session[b]) };
62
+ };
63
+ return { day: d.day, hype: row("hype"), usdc: row("usdc"), launches: row("launches") };
64
+ }
65
+ }
66
+ export function show(b, x) {
67
+ if (b === "hype")
68
+ return `${formatEther(x)} HYPE`;
69
+ if (b === "usdc")
70
+ return `${formatUnits(x, 6)} USDC`;
71
+ return `${x} launch${x === 1n ? "" : "es"}`;
72
+ }
@@ -0,0 +1,20 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type Prover } from "@cloakely/sdk";
3
+ import type { Settings } from "./config.js";
4
+ export { loadSettings, type Settings, type Limits } from "./config.js";
5
+ export { Spending } from "./limits.js";
6
+ export declare const VERSION = "0.1.0";
7
+ export declare const HOSTED_URL = "https://cloak.boblabs.tech/mcp";
8
+ /** Turn an SDK or chain error into one plain sentence an agent can act on. */
9
+ export declare function explain(e: unknown): string;
10
+ /** A tool argument that looks like a private key, a note secret or a recovery phrase. */
11
+ export declare function looksSecret(args: unknown): boolean;
12
+ /**
13
+ * The Cloak MCP server. `hosted` is the public read-and-plan server (https://cloak.boblabs.tech/mcp): it never has a
14
+ * key, never signs, and refuses any call that looks like it carries a secret. Without `hosted`, it's the local server
15
+ * that signs for one agent wallet under spending limits.
16
+ */
17
+ export declare function createCloakServer(settings: Settings, opts?: {
18
+ prover?: () => Promise<Prover>;
19
+ hosted?: boolean;
20
+ }): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,682 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { z } from "zod";
5
+ import { createPublicClient, createWalletClient, erc20Abi, formatEther, formatUnits, http, isAddress, parseEther, parseUnits, zeroAddress, } from "viem";
6
+ import { privateKeyToAccount } from "viem/accounts";
7
+ import { Cloak, CloakPrivate, cloakPairAbi, createProver, loadProver, privateRouterAbi, relayerSubmitter, shieldedPoolAbi, } from "@cloakely/sdk";
8
+ import { Spending } from "./limits.js";
9
+ export { loadSettings } from "./config.js";
10
+ export { Spending } from "./limits.js";
11
+ export const VERSION = "0.1.0";
12
+ export const HOSTED_URL = "https://cloak.boblabs.tech/mcp";
13
+ const INSTRUCTIONS = `Cloak: sealed-bid token launches and private trading on Kinetiq's Elysium (HYPE is the gas and base currency).
14
+
15
+ Launches: create_launch starts a sealed-bid auction. place_sealed_bid commits a bid that nobody can read until bidding closes; bids then open by themselves (the keeper reveals them from the drand beacon), so there is nothing to come back for. Everyone who wins pays one clearing price. After settlement, claim pays out tokens and refunds (the keeper usually pushes claims already), and enable_private_trading opens a private vault for the token if nobody has yet.
16
+
17
+ Private balances: shield moves HYPE or USDC from the wallet into a private balance (the one public step). private_swap, batch_order, private_send and withdraw then run from that balance: each is proven on this machine and sent by a relayer, so the wallet never appears on chain. batch_order also hides trade size. withdraw lands funds at a normal address, ideally a fresh one. Amounts are in whole units ("0.5" HYPE), and send, sell and withdraw accept "max".
18
+
19
+ Safety: every action that moves value is checked against per-transaction and daily spending limits (see cloak_info), and any write tool takes dry_run: true to see exactly what would happen without signing anything. Use dry_run first when unsure.`;
20
+ const HOSTED_INSTRUCTIONS = `Cloak's hosted MCP server: read and plan for Cloak, sealed-bid token launches and private trading on Kinetiq's Elysium testnet (HYPE is the gas and base currency).
21
+
22
+ It never signs and never holds keys. Use it to find launches (list_launches, get_launch), price a sealed bid (quote_bid) or a private swap (quote_swap), see vault totals (vault_stats) and plan a launch (plan_launch). Bids stay sealed until bidding closes, so demand only shows after that. To actually launch, bid or trade, the agent needs the local server: connect_full_agent explains it. Never send a private key, note secret or recovery phrase here; calls that look like they carry one are refused.`;
23
+ const j = (x) => JSON.stringify(x, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2);
24
+ const reply = (x) => ({ content: [{ type: "text", text: j(x) }] });
25
+ const fail = (msg) => ({ isError: true, content: [{ type: "text", text: msg }] });
26
+ const iso = (t) => new Date(t * 1000).toISOString();
27
+ const hype = (x) => `${formatEther(x)} HYPE`;
28
+ /** Turn an SDK or chain error into one plain sentence an agent can act on. */
29
+ export function explain(e) {
30
+ const m = String(e?.shortMessage ?? e?.message ?? e);
31
+ if (/insufficient private balance/.test(m))
32
+ return "Not enough private balance for this, counting the relayer's fee. Check private_balance, try a smaller amount, or use amount \"max\".";
33
+ if (/insufficient token balance in wallet/.test(m))
34
+ return "The wallet doesn't hold that many tokens. Check cloak_info for the wallet's public balances.";
35
+ if (/insufficient funds|exceeds the balance|gas required exceeds allowance/i.test(m))
36
+ return "The wallet doesn't have enough HYPE for this and its gas. Get testnet HYPE from https://elysium.kinetiq.xyz/testnet-faucet.";
37
+ if (/OverDepositLimit/.test(m))
38
+ return "That's over the vault's deposit cap during the beta. Try a smaller amount.";
39
+ if (/BidOverCap|DepositOverCap|over cap/i.test(m))
40
+ return "That bid is over the auction's cap (100 HYPE per bid, 10,000 in total during the beta).";
41
+ if (/commit phase is over/.test(m))
42
+ return "Bidding has closed for this launch.";
43
+ if (/below the auction's minimum price/.test(m))
44
+ return m;
45
+ return m.replace(/\s*\n\s*/g, " ").slice(0, 400);
46
+ }
47
+ /** Parse a whole-unit amount ("1.5") into base units, or null for "max". */
48
+ function amountOf(s, decimals) {
49
+ if (s.trim().toLowerCase() === "max")
50
+ return null;
51
+ if (!/^\d+(\.\d+)?$/.test(s.trim()))
52
+ throw new Error(`"${s}" isn't an amount: use a number like 0.5, or "max"`);
53
+ const a = parseUnits(s.trim(), decimals);
54
+ if (a <= 0n)
55
+ throw new Error("the amount must be above zero");
56
+ return a;
57
+ }
58
+ /** A tool argument that looks like a private key, a note secret or a recovery phrase. */
59
+ export function looksSecret(args) {
60
+ const text = JSON.stringify(args ?? {});
61
+ return /(0x)?[0-9a-fA-F]{64}(?![0-9a-fA-F])/.test(text) || /\b([a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/.test(text);
62
+ }
63
+ /**
64
+ * The Cloak MCP server. `hosted` is the public read-and-plan server (https://cloak.boblabs.tech/mcp): it never has a
65
+ * key, never signs, and refuses any call that looks like it carries a secret. Without `hosted`, it's the local server
66
+ * that signs for one agent wallet under spending limits.
67
+ */
68
+ export function createCloakServer(settings, opts = {}) {
69
+ if (opts.hosted)
70
+ settings = { ...settings, privateKey: undefined };
71
+ const d = settings.deployment;
72
+ // reads in the same tick go out as one multicall where the chain has Multicall3 (a launch list is ~20 reads per
73
+ // launch, more than a public RPC takes at once), and failed requests are retried
74
+ const pub = createPublicClient({
75
+ chain: settings.chain,
76
+ transport: http(settings.rpcUrl, { retryCount: 4, retryDelay: 400, timeout: 20_000 }),
77
+ batch: settings.chain.contracts?.multicall3 ? { multicall: true } : undefined,
78
+ });
79
+ const account = settings.privateKey ? privateKeyToAccount(settings.privateKey) : undefined;
80
+ const wallet = account ? createWalletClient({ chain: settings.chain, transport: http(settings.rpcUrl), account }) : undefined;
81
+ const cloak = new Cloak(pub, wallet, d.factory);
82
+ const spending = account ? new Spending(settings.limits, settings.stateDir, account.address) : undefined;
83
+ const submit = relayerSubmitter(d.relayerUrl);
84
+ const explorer = settings.chain.blockExplorers?.default.url;
85
+ const link = (h) => (explorer ? `${explorer}/tx/${h}` : h);
86
+ let proverP;
87
+ const prover = opts.prover ??
88
+ (() => (proverP ??= settings.circuitsDir
89
+ ? Promise.resolve(createProver(readFileSync(join(settings.circuitsDir, "transaction2x2.wasm")), readFileSync(join(settings.circuitsDir, "transaction2x2_final.zkey"))))
90
+ : loadProver(d.circuitsUrl)));
91
+ let privP;
92
+ async function priv() {
93
+ if (!wallet)
94
+ throw new Error("No wallet configured: set CLOAK_KEY_FILE (or CLOAK_PRIVATE_KEY) to use private balances.");
95
+ privP ??= (async () => {
96
+ const keys = await CloakPrivate.keysFromWallet(wallet, { chainId: settings.chain.id, router: d.router, verify: true });
97
+ const p = new CloakPrivate(pub, d.router, keys, prover, submit, d.routerBlock, d.batcher, d.migrator);
98
+ for (const a of d.assets ?? [])
99
+ p.addAsset(a);
100
+ return p;
101
+ })().catch((e) => {
102
+ privP = undefined;
103
+ throw e;
104
+ });
105
+ const p = await privP;
106
+ await p.loadMarkets(); // new private markets appear as launches settle
107
+ return p;
108
+ }
109
+ // one write at a time: two private calls must not pick the same notes, and nonces stay in order
110
+ let queue = Promise.resolve();
111
+ const exclusive = (fn) => {
112
+ const run = queue.then(fn, fn);
113
+ queue = run.catch(() => { });
114
+ return run;
115
+ };
116
+ const needWallet = () => {
117
+ if (!wallet || !account || !spending)
118
+ throw new Error("No wallet configured: set CLOAK_KEY_FILE (or CLOAK_PRIVATE_KEY). Read tools work without one.");
119
+ return { wallet, account, spending };
120
+ };
121
+ const symbols = new Map();
122
+ async function symbolOf(token) {
123
+ const k = token.toLowerCase();
124
+ if (!symbols.has(k))
125
+ symbols.set(k, await pub.readContract({ address: token, abi: erc20Abi, functionName: "symbol" }).catch(() => "?"));
126
+ return symbols.get(k);
127
+ }
128
+ /** Every asset with a private vault: HYPE, USDC-like assets, and every launch token with a private market. */
129
+ async function vaults() {
130
+ const c = { address: d.router, abi: privateRouterAbi };
131
+ const n = await pub.readContract({ ...c, functionName: "tokensCount" });
132
+ const out = [{ kind: "hype", symbol: "HYPE", token: d.whype, pool: d.hypePool, decimals: 18 }];
133
+ for (const a of d.assets ?? [])
134
+ out.push({ kind: "asset", symbol: a.symbol, token: a.token, pool: a.pool, decimals: a.decimals });
135
+ const toks = await Promise.all(Array.from({ length: Number(n) - 1 }, (_, i) => pub.readContract({ ...c, functionName: "tokens", args: [BigInt(i + 1)] })));
136
+ for (const token of toks) {
137
+ const [pool, pair, symbol] = await Promise.all([
138
+ pub.readContract({ ...c, functionName: "poolOf", args: [token] }),
139
+ pub.readContract({ ...c, functionName: "pairOf", args: [token] }),
140
+ symbolOf(token),
141
+ ]);
142
+ out.push({ kind: "token", symbol, token, pool, pair, decimals: 18 });
143
+ }
144
+ return out;
145
+ }
146
+ /** "HYPE", "USDC", a launch token's symbol, or any of their addresses. */
147
+ async function resolve(ref) {
148
+ const all = await vaults();
149
+ const r = ref.trim();
150
+ if (isAddress(r)) {
151
+ const hit = all.find((a) => a.token.toLowerCase() === r.toLowerCase() || a.pool.toLowerCase() === r.toLowerCase());
152
+ if (hit)
153
+ return hit;
154
+ throw new Error(`${r} has no private vault yet. If it's a settled Cloak launch, enable_private_trading opens one.`);
155
+ }
156
+ const hits = all.filter((a) => a.symbol.toLowerCase() === r.replace(/^\$/, "").toLowerCase());
157
+ if (hits.length === 1)
158
+ return hits[0];
159
+ if (hits.length > 1)
160
+ throw new Error(`Several tokens are called ${r}: ${hits.map((h) => h.token).join(", ")}. Pass the token address instead.`);
161
+ throw new Error(`No private vault for "${r}". Private vaults: ${[...new Set(all.map((a) => a.symbol))].join(", ")}.`);
162
+ }
163
+ /** Expected output of a private swap of `amountIn` (after the vault's withdrawal fee), and the relayer's fee. */
164
+ async function quoteSwap(t, side, amountIn) {
165
+ const poolIn = side === "buy" ? d.hypePool : t.pool;
166
+ const net = await pub.readContract({ address: poolIn, abi: shieldedPoolAbi, functionName: "afterFee", args: [amountIn] });
167
+ const [rH, rT] = await Promise.all([
168
+ pub.readContract({ address: t.pair, abi: cloakPairAbi, functionName: "reserveHype" }),
169
+ pub.readContract({ address: t.pair, abi: cloakPairAbi, functionName: "reserveToken" }),
170
+ ]);
171
+ const out = await pub.readContract({ address: t.pair, abi: cloakPairAbi, functionName: "getAmountOut", args: side === "buy" ? [net, rH, rT] : [net, rT, rH] });
172
+ const fee = await submit.quote("privateSwap", poolIn).then((q) => q.fee, () => 0n);
173
+ return { out, net, fee };
174
+ }
175
+ /** What an amount of an asset counts against: HYPE value (launch tokens at the pool's price), or USDC. */
176
+ async function cost(a, amount) {
177
+ if (a.kind === "hype")
178
+ return { budget: "hype", value: amount };
179
+ if (a.kind === "asset")
180
+ return { budget: "usdc", value: (amount * 10n ** 6n) / 10n ** BigInt(a.decimals) };
181
+ const [rH, rT] = await Promise.all([
182
+ pub.readContract({ address: a.pair, abi: cloakPairAbi, functionName: "reserveHype" }),
183
+ pub.readContract({ address: a.pair, abi: cloakPairAbi, functionName: "reserveToken" }),
184
+ ]);
185
+ return { budget: "hype", value: rT === 0n ? 0n : (amount * rH) / rT }; // at the pool's spot price, before impact
186
+ }
187
+ const fmtAmt = (a, x) => `${formatUnits(x, a.decimals)} ${a.symbol}`;
188
+ async function receipt(h) {
189
+ const r = await pub.waitForTransactionReceipt({ hash: h });
190
+ if (r.status !== "success")
191
+ throw new Error(`transaction reverted: ${link(h)}`);
192
+ return r;
193
+ }
194
+ function launchView(s) {
195
+ const now = Math.floor(Date.now() / 1000);
196
+ return {
197
+ auction: s.address,
198
+ token: s.token,
199
+ name: s.tokenName,
200
+ symbol: s.tokenSymbol,
201
+ phase: s.failed ? "failed (every bid refunded)" : s.phase,
202
+ biddingEnds: iso(s.commitEnd),
203
+ revealsEnd: iso(s.revealEnd),
204
+ ...(s.phase === "commit" ? { biddingClosesInMinutes: Math.max(0, Math.round((s.commitEnd - now) / 6) / 10) } : {}),
205
+ tokensForSale: formatEther(s.saleSupply),
206
+ minPrice: `${formatEther(s.minPrice)} HYPE per token`,
207
+ tickSize: `${formatEther(s.tickSize)} HYPE`,
208
+ priceTicks: s.numTicks,
209
+ bids: s.commits,
210
+ bidsOpened: s.reveals,
211
+ ...(s.settled
212
+ ? { clearingPrice: `${formatEther(s.clearingPrice)} HYPE per token`, sold: formatEther(s.sold), raised: hype(s.raised), pool: s.pair }
213
+ : {}),
214
+ liquidity: `${s.lpBps / 100}% of the raise seeds the pool at the clearing price; LP ${s.lpLockUntil === 0 ? "burned" : `locked until ${iso(s.lpLockUntil)}`}`,
215
+ noRevealPenalty: `${s.noRevealPenaltyBps / 100}%`,
216
+ launchFee: `${s.launchFeeBps / 100}% of a successful raise`,
217
+ caps: { perBid: s.bidCap ? hype(s.bidCap) : "none", total: s.depositCap ? hype(s.depositCap) : "none" },
218
+ };
219
+ }
220
+ async function privateTradingOn(token) {
221
+ return (await pub.readContract({ address: d.router, abi: privateRouterAbi, functionName: "poolOf", args: [token] })) !== zeroAddress;
222
+ }
223
+ const bidsFile = (a) => join(settings.stateDir, `bids-${a.toLowerCase()}.json`);
224
+ function saveBid(a, secret) {
225
+ mkdirSync(settings.stateDir, { recursive: true });
226
+ let all = [];
227
+ try {
228
+ all = JSON.parse(readFileSync(bidsFile(a), "utf8"));
229
+ }
230
+ catch {
231
+ // first bid
232
+ }
233
+ all.push(secret);
234
+ writeFileSync(bidsFile(a), j(all));
235
+ }
236
+ const server = new McpServer({ name: opts.hosted ? "cloak-hosted" : "cloak", version: VERSION }, { instructions: opts.hosted ? HOSTED_INSTRUCTIONS : INSTRUCTIONS });
237
+ const dry = z.boolean().optional().describe("Return what would happen without signing or sending anything.");
238
+ const addr = z.string().describe("0x address");
239
+ const amt = (what) => z.string().describe(`${what}, in whole units (e.g. "0.5")`);
240
+ const isDry = (x) => settings.dryRun || x === true;
241
+ const READ = { readOnlyHint: true, openWorldHint: true };
242
+ const WRITE = { readOnlyHint: false, destructiveHint: false, openWorldHint: true };
243
+ /** Wrap a handler: plain-sentence errors instead of stack traces. */
244
+ const safe = (fn) => async (a) => {
245
+ if (opts.hosted && looksSecret(a))
246
+ return fail("This hosted server never takes private keys, note secrets or recovery phrases, and refused the call. Nothing was stored. If that was a real key, treat it as exposed and move its funds to a new wallet.");
247
+ try {
248
+ return reply(await fn(a));
249
+ }
250
+ catch (e) {
251
+ return fail(explain(e));
252
+ }
253
+ };
254
+ // ───────────── read ─────────────
255
+ server.registerTool("cloak_info", { title: "Cloak setup and limits", description: "Network, contract addresses, the configured wallet and its public balances, dry-run mode, and spending limits with what's left today.", inputSchema: {}, annotations: READ }, safe(async () => {
256
+ const usdc = d.assets?.[0];
257
+ const wallet = account
258
+ ? {
259
+ address: account.address,
260
+ publicBalances: {
261
+ HYPE: formatEther(await pub.getBalance({ address: account.address })),
262
+ ...(usdc ? { [usdc.symbol]: formatUnits(await pub.readContract({ address: usdc.token, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }), usdc.decimals) } : {}),
263
+ },
264
+ }
265
+ : null;
266
+ return {
267
+ server: opts.hosted ? `Cloak hosted MCP ${VERSION} (${HOSTED_URL})` : `@cloakely/mcp ${VERSION}`,
268
+ network: settings.network,
269
+ chainId: settings.chain.id,
270
+ mode: opts.hosted ? "hosted: read and plan only, never signs (connect_full_agent explains how to launch, bid and trade)" : account ? "read and write" : "read only (no wallet configured)",
271
+ ...(opts.hosted
272
+ ? {}
273
+ : { dryRun: settings.dryRun ? "on for every write (CLOAK_DRY_RUN=1)" : "off (pass dry_run: true on any write to preview it)", wallet, limits: spending?.summary() ?? null }),
274
+ contracts: { factory: d.factory, router: d.router, batcher: d.batcher, hypeVault: d.hypePool, ...(usdc ? { [`${usdc.symbol.toLowerCase()}Vault`]: usdc.pool } : {}) },
275
+ relayer: d.relayerUrl,
276
+ ...(settings.network === "testnet" ? { faucet: "https://elysium.kinetiq.xyz/testnet-faucet", note: "Testnet: play money only." } : {}),
277
+ };
278
+ }));
279
+ server.registerTool("list_launches", {
280
+ title: "List launches",
281
+ description: "Cloak launches, newest first, with phase, timing, prices and whether private trading is on.",
282
+ inputSchema: { limit: z.number().int().min(1).max(50).optional().describe("How many (default 10)"), phase: z.enum(["commit", "reveal", "settle", "settled"]).optional().describe("Only launches in this phase") },
283
+ annotations: READ,
284
+ }, safe(async ({ limit, phase }) => {
285
+ const all = (await cloak.listLaunches()).reverse();
286
+ const out = [];
287
+ for (let i = 0; i < all.length && out.length < (limit ?? 10); i += 10) {
288
+ const states = await Promise.all(all.slice(i, i + 10).map((l) => cloak.getAuction(l.auction)));
289
+ for (const s of states) {
290
+ if (phase && s.phase !== phase)
291
+ continue;
292
+ if (out.length >= (limit ?? 10))
293
+ break;
294
+ const v = launchView(s);
295
+ out.push({ symbol: v.symbol, name: v.name, auction: v.auction, token: v.token, phase: v.phase, biddingEnds: v.biddingEnds, bids: v.bids, minPrice: v.minPrice, ...(s.settled ? { clearingPrice: v.clearingPrice, raised: v.raised, privateTrading: await privateTradingOn(s.token) } : {}) });
296
+ }
297
+ }
298
+ return { total: all.length, launches: out };
299
+ }));
300
+ server.registerTool("get_launch", { title: "Launch details", description: "Everything about one launch: phase, timing, prices, caps, results once settled, revealed demand once bids open, and whether private trading is on.", inputSchema: { auction: addr }, annotations: READ }, safe(async ({ auction }) => {
301
+ const s = await cloak.getAuction(auction);
302
+ const v = launchView(s);
303
+ const demand = s.phase === "commit" ? [] : (await cloak.demandCurve(s.address)).filter((x) => x.demand > 0n).map((x) => ({ price: formatEther(x.price), tokensWanted: formatEther(x.demand) }));
304
+ return {
305
+ ...v,
306
+ ...(s.phase === "commit" ? { demand: "hidden until bidding closes: every bid is sealed" } : { revealedDemand: demand }),
307
+ ...(s.settled && !s.failed ? { poolPrice: `${formatEther(await cloak.pairPrice(s.pair))} HYPE per token`, privateTrading: await privateTradingOn(s.token) } : {}),
308
+ };
309
+ }));
310
+ server.registerTool("quote_bid", {
311
+ title: "Quote a sealed bid",
312
+ description: "What a bid would cost: the price tick it lands on, its cost, and the rounded deposit that hides its size (the unused part is refunded).",
313
+ inputSchema: { auction: addr, max_price: amt("Highest price you'd pay, in HYPE per token"), quantity: amt("Tokens wanted") },
314
+ annotations: READ,
315
+ }, safe(async ({ auction, max_price, quantity }) => {
316
+ const s = await cloak.getAuction(auction);
317
+ const q = cloak.quoteBid(s, parseEther(max_price), parseEther(quantity));
318
+ return { phase: s.phase, tick: q.tick, price: `${formatEther(q.price)} HYPE per token`, cost: hype(q.cost), deposit: hype(q.deposit), note: "The deposit is rounded up to 1, 2 or 5 × 10ⁿ so it doesn't reveal the bid's size. Whatever isn't used at the clearing price is refunded." };
319
+ }));
320
+ server.registerTool("quote_swap", {
321
+ title: "Quote a private swap",
322
+ description: "Expected output of a private buy (HYPE in) or sell (tokens in), after the vault's withdrawal fee, plus the relayer's fee.",
323
+ inputSchema: { token: z.string().describe("Launch token symbol or address"), side: z.enum(["buy", "sell"]), amount: amt("HYPE to spend (buy) or tokens to sell (sell)") },
324
+ annotations: READ,
325
+ }, safe(async ({ token, side, amount }) => {
326
+ const t = await resolve(token);
327
+ if (t.kind !== "token")
328
+ throw new Error(`${t.symbol} isn't a launch token: private swaps trade launch tokens against HYPE.`);
329
+ const a = parseEther(amount);
330
+ const q = await quoteSwap(t, side, a);
331
+ const outSym = side === "buy" ? t.symbol : "HYPE";
332
+ return { side, in: side === "buy" ? hype(a) : `${amount} ${t.symbol}`, expectedOut: `${formatEther(q.out)} ${outSym}`, relayerFee: `${formatEther(q.fee)} ${side === "buy" ? "HYPE" : t.symbol}`, withdrawalFee: `${formatEther(a - q.net)} ${side === "buy" ? "HYPE" : t.symbol}` };
333
+ }));
334
+ server.registerTool("vault_stats", { title: "Private vault totals", description: "How much sits in each private vault (HYPE, USDC and every launch token). Totals are public; who owns what isn't.", inputSchema: {}, annotations: READ }, safe(async () => {
335
+ const out = [];
336
+ for (const a of await vaults()) {
337
+ const held = await pub.readContract({ address: a.pool, abi: shieldedPoolAbi, functionName: "deposited" });
338
+ if (held === 0n && a.kind === "token")
339
+ continue;
340
+ out.push({ asset: a.symbol, token: a.token, vault: a.pool, held: fmtAmt(a, held) });
341
+ }
342
+ return { vaults: out, note: "Each vault is one shared pool of encrypted notes. The chain shows the total, not the owners or their balances." };
343
+ }));
344
+ if (opts.hosted) {
345
+ server.registerTool("plan_launch", {
346
+ title: "Plan a launch",
347
+ description: "Check a launch's settings and see its schedule, price range and what it raises at the lowest and highest price, before creating it on the site or with the local server.",
348
+ inputSchema: {
349
+ sale_supply: amt("Tokens sold in the auction"),
350
+ min_price: amt("Lowest price, in HYPE per token"),
351
+ tick_size: amt("Price step between ticks, in HYPE").optional(),
352
+ price_ticks: z.number().int().min(1).max(256).optional().describe("Number of price ticks (default 100)"),
353
+ bidding_minutes: z.number().positive().max(60 * 24 * 14).optional().describe("How long bidding stays open (default 60)"),
354
+ reveal_minutes: z.number().min(1).max(60 * 24).optional().describe("Time for bids to open after bidding closes (default 10)"),
355
+ lp_percent: z.number().min(0).max(100).optional().describe("Share of the raise that seeds the pool (default 50)"),
356
+ },
357
+ annotations: READ,
358
+ }, safe(async (p) => {
359
+ const supply = parseEther(p.sale_supply);
360
+ const min = parseEther(p.min_price);
361
+ const tick = parseEther(p.tick_size ?? p.min_price);
362
+ const ticks = p.price_ticks ?? 100;
363
+ const top = min + BigInt(ticks - 1) * tick;
364
+ const now = Number((await pub.getBlock()).timestamp);
365
+ const commitEnd = now + Math.round((p.bidding_minutes ?? 60) * 60);
366
+ const revealEnd = commitEnd + Math.round((p.reveal_minutes ?? 10) * 60);
367
+ const raise = (price) => (price * supply) / 10n ** 18n;
368
+ const lp = p.lp_percent ?? 50;
369
+ return {
370
+ ifCreatedNow: { biddingEnds: iso(commitEnd), revealsEnd: iso(revealEnd) },
371
+ priceRange: `${formatEther(min)} to ${formatEther(top)} HYPE per token, ${ticks} ticks`,
372
+ raiseIfFullySold: { atLowestPrice: hype(raise(min)), atHighestPrice: hype(raise(top)) },
373
+ toPool: `${lp}% of the raise, paired with tokens at the clearing price; the LP is burned unless you lock it`,
374
+ launchFee: "1.5% of a successful raise; nothing if the launch doesn't fill",
375
+ caps: "During the beta a bid can be at most 100 HYPE and an auction can take at most 10,000 HYPE.",
376
+ create: { onTheSite: "https://cloak.boblabs.tech/app/create", withAnAgent: "the local server's create_launch tool (see connect_full_agent)" },
377
+ };
378
+ }));
379
+ server.registerTool("connect_full_agent", { title: "Connect a full agent", description: "How to let an agent launch, bid and trade privately: the local Cloak MCP server, which signs on your own machine under spending limits.", inputSchema: {}, annotations: READ }, safe(async () => ({
380
+ why: "This hosted server never holds keys, so it can read and plan but not sign. Launching, bidding and private trading run in the local server, on the machine that holds the agent's wallet. Its proofs are made there too, so nobody else, Cloak included, can see or move the funds.",
381
+ steps: [
382
+ "Make a fresh wallet just for the agent and fund it with testnet HYPE from https://elysium.kinetiq.xyz/testnet-faucet",
383
+ "Save its private key to a file only you can read, for example ~/.cloak-agent-key",
384
+ "Claude Code: claude mcp add cloak --env CLOAK_KEY_FILE=$HOME/.cloak-agent-key -- npx -y @cloakely/mcp",
385
+ 'Other clients: { "mcpServers": { "cloak": { "command": "npx", "args": ["-y", "@cloakely/mcp"], "env": { "CLOAK_KEY_FILE": "/path/to/key" } } } }',
386
+ ],
387
+ safety: "Spending limits per transaction and per day (1 HYPE and 10 HYPE by default, set with CLOAK_MAX_HYPE_PER_TX and CLOAK_MAX_HYPE_PER_DAY), dry_run on every action, testnet by default. Never paste a key into a chat or into this hosted server.",
388
+ docs: "https://github.com/NECOKIZZ/Cloak/tree/main/packages/mcp",
389
+ })));
390
+ return server;
391
+ }
392
+ // ───────────── launches ─────────────
393
+ server.registerTool("create_launch", {
394
+ title: "Create a sealed-bid launch",
395
+ description: "Start a token launch: a sealed-bid auction that ends in one clearing price, with liquidity seeded at that price. Counts against the daily launch limit.",
396
+ inputSchema: {
397
+ name: z.string().min(1).max(40),
398
+ symbol: z.string().min(1).max(12),
399
+ sale_supply: amt("Tokens sold in the auction"),
400
+ min_price: amt("Lowest price, in HYPE per token"),
401
+ tick_size: amt("Price step between ticks, in HYPE").optional(),
402
+ price_ticks: z.number().int().min(1).max(256).optional().describe("Number of price ticks (default 100)"),
403
+ bidding_minutes: z.number().positive().max(60 * 24 * 14).optional().describe("How long bidding stays open (default 60)"),
404
+ reveal_minutes: z.number().min(1).max(60 * 24).optional().describe("Time for bids to open after bidding closes (default 10)"),
405
+ lp_percent: z.number().min(0).max(100).optional().describe("Share of the raise that seeds the pool (default 50)"),
406
+ lp_tokens: amt("Tokens paired with that HYPE (default: same as sale_supply)").optional(),
407
+ lp_lock_days: z.number().min(0).optional().describe("Lock the LP for this many days instead of burning it (default: burn)"),
408
+ creator_share: amt("Tokens for the creator, at most 20% of supply, vesting linearly").optional(),
409
+ vest_days: z.number().min(30).optional().describe("Vesting length for the creator share (default 90)"),
410
+ metadata_uri: z.string().optional(),
411
+ dry_run: dry,
412
+ },
413
+ annotations: WRITE,
414
+ }, safe(async (p) => {
415
+ const { account, spending } = needWallet();
416
+ const now = Number((await pub.getBlock()).timestamp);
417
+ const commitEnd = now + Math.round((p.bidding_minutes ?? 60) * 60);
418
+ const revealEnd = commitEnd + Math.round((p.reveal_minutes ?? 10) * 60);
419
+ const params = {
420
+ name: p.name,
421
+ symbol: p.symbol,
422
+ saleSupply: parseEther(p.sale_supply),
423
+ lpReserve: p.lp_tokens ? parseEther(p.lp_tokens) : undefined,
424
+ creatorAlloc: p.creator_share ? parseEther(p.creator_share) : undefined,
425
+ creatorVestSecs: Math.round((p.vest_days ?? 90) * 86400),
426
+ minPrice: parseEther(p.min_price),
427
+ tickSize: parseEther(p.tick_size ?? p.min_price),
428
+ numTicks: p.price_ticks ?? 100,
429
+ commitEnd,
430
+ revealEnd,
431
+ lpBps: Math.round((p.lp_percent ?? 50) * 100),
432
+ lpLockUntil: p.lp_lock_days ? now + Math.round(p.lp_lock_days * 86400) : "burn",
433
+ metadataURI: p.metadata_uri,
434
+ };
435
+ spending.check("launches", 1n);
436
+ const plan = { creator: account.address, ...p, biddingEnds: iso(commitEnd), revealsEnd: iso(revealEnd), topPrice: `${formatEther(params.minPrice + BigInt(params.numTicks - 1) * params.tickSize)} HYPE per token` };
437
+ if (isDry(p.dry_run))
438
+ return { dryRun: true, wouldCreate: plan };
439
+ return exclusive(async () => {
440
+ const l = await cloak.createLaunch(params);
441
+ spending.record("launches", 1n);
442
+ return { created: true, auction: l.auction, token: l.token, tx: link(l.txHash), biddingEnds: plan.biddingEnds, revealsEnd: plan.revealsEnd, next: "Share the auction address. Bids open by themselves after bidding closes, and the keeper settles the launch." };
443
+ });
444
+ }));
445
+ server.registerTool("place_sealed_bid", {
446
+ title: "Place a sealed bid",
447
+ description: "Commit a sealed bid: only a hash and a timelocked ciphertext go on chain, so nobody (other bidders, bots, the sequencer) sees price or size until bidding closes. It opens by itself afterwards. Spends the rounded deposit from the wallet; the unused part is refunded at settlement.",
448
+ inputSchema: { auction: addr, max_price: amt("Highest price you'd pay, in HYPE per token"), quantity: amt("Tokens wanted"), dry_run: dry },
449
+ annotations: WRITE,
450
+ }, safe(async ({ auction, max_price, quantity, dry_run }) => {
451
+ const { account, spending } = needWallet();
452
+ const s = await cloak.getAuction(auction);
453
+ if (s.phase !== "commit")
454
+ throw new Error(`Bidding has closed for ${s.tokenSymbol} (phase: ${s.phase}).`);
455
+ const q = cloak.quoteBid(s, parseEther(max_price), parseEther(quantity));
456
+ spending.check("hype", q.deposit);
457
+ const plan = { launch: s.tokenSymbol, price: `${formatEther(q.price)} HYPE per token`, quantity, cost: hype(q.cost), deposit: hype(q.deposit), biddingEnds: iso(s.commitEnd) };
458
+ if (isDry(dry_run))
459
+ return { dryRun: true, wouldBid: plan };
460
+ return exclusive(async () => {
461
+ const b = await cloak.bid(s.address, { maxPrice: parseEther(max_price), quantity: parseEther(quantity) });
462
+ spending.record("hype", b.deposit);
463
+ saveBid(account.address, b);
464
+ return { placed: true, bidId: b.id, ...plan, tx: link(b.txHash), next: "Nothing else to do: the bid opens by itself after bidding closes. Check my_bids after settlement." };
465
+ });
466
+ }));
467
+ server.registerTool("my_bids", { title: "My bids", description: "The wallet's bids on a launch: deposit, whether each has opened, and once settled the tokens won, refund and any penalty.", inputSchema: { auction: addr }, annotations: READ }, safe(async ({ auction }) => {
468
+ const { account } = needWallet();
469
+ const s = await cloak.getAuction(auction);
470
+ const bids = await cloak.bidsOf(s.address, account.address, settings.launchesFromBlock);
471
+ return {
472
+ launch: s.tokenSymbol,
473
+ phase: s.phase,
474
+ bids: bids.map((b) => ({ id: b.id, deposit: hype(b.deposit), opened: b.revealed, claimed: b.claimed, ...(s.settled ? { tokensWon: formatEther(b.tokens), refund: hype(b.refund), penalty: hype(b.penalty) } : {}) })),
475
+ };
476
+ }));
477
+ server.registerTool("claim", { title: "Claim tokens and refunds", description: "After bids open, settle the launch if nobody has yet, then claim the wallet's tokens and refunds. Cloak's keeper usually pushes claims already.", inputSchema: { auction: addr, dry_run: dry }, annotations: WRITE }, safe(async ({ auction, dry_run }) => {
478
+ const { account } = needWallet();
479
+ let s = await cloak.getAuction(auction);
480
+ if (s.phase === "commit" || s.phase === "reveal")
481
+ throw new Error(`${s.tokenSymbol} can't settle yet: bids are ${s.phase === "commit" ? "still being placed" : "still opening"} until ${iso(s.phase === "commit" ? s.commitEnd : s.revealEnd)}.`);
482
+ const bids = await cloak.bidsOf(s.address, account.address, settings.launchesFromBlock);
483
+ const open = bids.filter((b) => !b.claimed);
484
+ if (isDry(dry_run))
485
+ return { dryRun: true, wouldSettle: !s.settled, wouldClaim: open.map((b) => b.id) };
486
+ return exclusive(async () => {
487
+ const txs = [];
488
+ if (!s.settled) {
489
+ txs.push(link(await cloak.settle(s.address)));
490
+ s = await cloak.getAuction(s.address);
491
+ }
492
+ if (open.length)
493
+ txs.push(link(await cloak.claim(s.address, open.map((b) => b.id))));
494
+ const after = await cloak.bidsOf(s.address, account.address, settings.launchesFromBlock);
495
+ return {
496
+ launch: s.tokenSymbol,
497
+ settled: s.settled,
498
+ ...(s.failed ? { failed: "No demand was revealed, so every bid was refunded in full." } : { clearingPrice: `${formatEther(s.clearingPrice)} HYPE per token` }),
499
+ claimed: open.length ? open.map((b) => b.id) : "nothing left to claim (already paid out)",
500
+ tokensWon: formatEther(after.reduce((a, b) => a + b.tokens, 0n)),
501
+ refunds: hype(after.reduce((a, b) => a + b.refund, 0n)),
502
+ txs,
503
+ };
504
+ });
505
+ }));
506
+ server.registerTool("enable_private_trading", { title: "Enable private trading", description: "Open a private vault for a settled launch's token so it can be traded from private balances. Anyone can do this once per token; it costs only gas.", inputSchema: { auction: addr, dry_run: dry }, annotations: WRITE }, safe(async ({ auction, dry_run }) => {
507
+ const { wallet } = needWallet();
508
+ const s = await cloak.getAuction(auction);
509
+ if (!s.settled || s.failed)
510
+ throw new Error(`${s.tokenSymbol} needs to settle successfully first (phase: ${s.phase}).`);
511
+ if (await privateTradingOn(s.token))
512
+ return { alreadyOn: true, token: s.token };
513
+ const idx = (await cloak.listLaunches()).findIndex((l) => l.auction.toLowerCase() === s.address.toLowerCase());
514
+ if (idx < 0)
515
+ throw new Error("This launch isn't from the current Cloak factory, so it can't get a private vault.");
516
+ if (isDry(dry_run))
517
+ return { dryRun: true, wouldEnable: s.tokenSymbol, launchIndex: idx };
518
+ return exclusive(async () => {
519
+ const h = await wallet.writeContract({ address: d.router, abi: privateRouterAbi, functionName: "createPool", args: [BigInt(idx)] });
520
+ await receipt(h);
521
+ return { enabled: true, token: s.token, vault: await pub.readContract({ address: d.router, abi: privateRouterAbi, functionName: "poolOf", args: [s.token] }), tx: link(h) };
522
+ });
523
+ }));
524
+ // ───────────── private balances ─────────────
525
+ server.registerTool("unlock", { title: "Unlock the private account", description: "Derive the private account from one local signature (no transaction, nothing sent anywhere) and return its cloak: address, which others use to pay it privately.", inputSchema: {}, annotations: READ }, safe(async () => {
526
+ const p = await priv();
527
+ return { cloakAddress: await p.address(), note: "Share the cloak: address to receive private payments. It doesn't reveal the wallet." };
528
+ }));
529
+ server.registerTool("private_balance", { title: "Private balances", description: "Balances only this account can see: HYPE, USDC and every launch token held privately.", inputSchema: { include_zero: z.boolean().optional() }, annotations: READ }, safe(async ({ include_zero }) => {
530
+ const p = await priv();
531
+ const out = {};
532
+ const tokens = {};
533
+ for (const a of await vaults()) {
534
+ const b = await p.balance(a.pool);
535
+ if (b === 0n && !include_zero && a.kind === "token")
536
+ continue;
537
+ if (a.kind === "token")
538
+ tokens[`${a.symbol} ${a.token.slice(0, 8)}`] = { token: a.token, balance: formatUnits(b, a.decimals) };
539
+ else
540
+ out[a.symbol] = formatUnits(b, a.decimals);
541
+ }
542
+ return { cloakAddress: await p.address(), balances: out, launchTokens: tokens };
543
+ }));
544
+ server.registerTool("shield", {
545
+ title: "Shield into a private balance",
546
+ description: "Deposit HYPE or USDC from the wallet into the private balance. This is the one public step: it lands on a one-time key, so it can't be linked to what comes next.",
547
+ inputSchema: { asset: z.string().describe('"HYPE" or "USDC"'), amount: amt("Amount"), dry_run: dry },
548
+ annotations: WRITE,
549
+ }, safe(async ({ asset, amount, dry_run }) => {
550
+ const { wallet, spending } = needWallet();
551
+ const a = await resolve(asset);
552
+ if (a.kind === "token")
553
+ throw new Error("Launch tokens enter a private balance by buying privately (private_swap), not by shielding.");
554
+ const x = amountOf(amount, a.decimals);
555
+ if (x === null)
556
+ throw new Error("Give an exact amount to shield.");
557
+ const c = await cost(a, x);
558
+ spending.check(c.budget, c.value);
559
+ if (isDry(dry_run))
560
+ return { dryRun: true, wouldShield: fmtAmt(a, x), ...(a.kind === "asset" ? { note: "The first time takes two transactions: an approval, then the deposit." } : {}) };
561
+ return exclusive(async () => {
562
+ const p = await priv();
563
+ const h = a.kind === "hype" ? await p.shieldHype(wallet, x) : await p.shieldToken(wallet, a.token, x);
564
+ spending.record(c.budget, c.value);
565
+ return { shielded: fmtAmt(a, x), privateBalance: fmtAmt(a, await p.balance(a.pool)), tx: link(h) };
566
+ });
567
+ }));
568
+ server.registerTool("private_swap", {
569
+ title: "Private swap",
570
+ description: "Buy a launch token with private HYPE, or sell it for private HYPE. Proven on this machine and sent by a relayer, so the wallet never appears on the trade. The output lands back in the private balance.",
571
+ inputSchema: { token: z.string().describe("Launch token symbol or address"), side: z.enum(["buy", "sell"]), amount: amt('HYPE to spend (buy) or tokens to sell (sell); "max" for the whole balance'), slippage_bps: z.number().int().min(1).max(5000).optional().describe("Max slippage in basis points (default 100 = 1%)"), dry_run: dry },
572
+ annotations: WRITE,
573
+ }, safe(async ({ token, side, amount, slippage_bps, dry_run }) => {
574
+ const { spending } = needWallet();
575
+ const t = await resolve(token);
576
+ if (t.kind !== "token")
577
+ throw new Error(`${t.symbol} isn't a launch token: private swaps trade launch tokens against HYPE.`);
578
+ const p = await priv();
579
+ const poolIn = side === "buy" ? d.hypePool : t.pool;
580
+ const x = amountOf(amount, 18) ?? (await p.maxSpendable(poolIn, "privateSwap"));
581
+ if (x <= 0n)
582
+ throw new Error("Not enough private balance for this, counting the relayer's fee.");
583
+ const c = await cost(side === "buy" ? (await vaults())[0] : t, x);
584
+ spending.check(c.budget, c.value);
585
+ const q = await quoteSwap(t, side, x);
586
+ const inSym = side === "buy" ? "HYPE" : t.symbol;
587
+ const outSym = side === "buy" ? t.symbol : "HYPE";
588
+ if (isDry(dry_run))
589
+ return { dryRun: true, wouldSwap: `${formatEther(x)} ${inSym} for about ${formatEther(q.out)} ${outSym}`, relayerFee: `${formatEther(q.fee)} ${inSym}`, slippage: `${(slippage_bps ?? 100) / 100}%` };
590
+ return exclusive(async () => {
591
+ const r = await (side === "buy" ? p.buy(t.token, x, { slippageBps: slippage_bps }) : p.sell(t.token, x, { slippageBps: slippage_bps }));
592
+ await receipt(r.txHash);
593
+ spending.record(c.budget, c.value);
594
+ const outPool = side === "buy" ? t.pool : d.hypePool;
595
+ return { swapped: `${formatEther(x)} ${inSym}`, expected: `${formatEther(r.expectedOut)} ${outSym}`, minimum: `${formatEther(r.minOut)} ${outSym}`, relayerFee: `${formatEther(r.relayerFee)} ${inSym}`, privateBalance: { [inSym]: formatEther(await p.balance(poolIn)), [outSym]: formatEther(await p.balance(outPool)) }, tx: link(r.txHash), sentBy: "the relayer, not this wallet" };
596
+ });
597
+ }));
598
+ server.registerTool("batch_order", {
599
+ title: "Batch order (hides size)",
600
+ description: "Trade in the next batch round: the amount is split into standard sizes everyone uses, buys and sells cross at the round's average price, and only the net hits the pool. Observers can't tell how much any one trader moved. Fills when the round settles (about 30 s on testnet). A round only settles if its net trade moves the pool less than 5%, so keep orders small next to the pool; a round that can't settle is refunded after a delay.",
601
+ inputSchema: { token: z.string().describe("Launch token symbol or address"), side: z.enum(["buy", "sell"]), amount: amt("HYPE to spend (buy) or tokens to sell (sell)"), dry_run: dry },
602
+ annotations: WRITE,
603
+ }, safe(async ({ token, side, amount, dry_run }) => {
604
+ const { spending } = needWallet();
605
+ const t = await resolve(token);
606
+ if (t.kind !== "token")
607
+ throw new Error(`${t.symbol} isn't a launch token: batch orders trade launch tokens against HYPE.`);
608
+ const x = amountOf(amount, 18);
609
+ if (x === null)
610
+ throw new Error('Give an exact amount for a batch order ("max" works for private_swap).');
611
+ const c = await cost(side === "buy" ? (await vaults())[0] : t, x);
612
+ spending.check(c.budget, c.value);
613
+ const p = await priv();
614
+ const round = await p.roundInfo();
615
+ if (isDry(dry_run))
616
+ return { dryRun: true, wouldPlace: `${formatEther(x)} ${side === "buy" ? "HYPE" : t.symbol} as standard-size ${side} orders`, roundEndsAt: iso(round.endsAt) };
617
+ return exclusive(async () => {
618
+ const r = await p.batch(t.token, side, x);
619
+ const placed = r.orders.reduce((a, o) => a + o.size, 0n);
620
+ const pc = await cost(side === "buy" ? (await vaults())[0] : t, placed);
621
+ spending.record(pc.budget, pc.value);
622
+ return {
623
+ placed: r.orders.map((o) => ({ size: formatEther(o.size), round: o.round, tx: link(o.txHash) })),
624
+ leftInBalance: formatEther(r.rest),
625
+ next: "The output arrives in the private balance when the round settles. Check private_balance in a minute.",
626
+ };
627
+ });
628
+ }));
629
+ server.registerTool("private_send", {
630
+ title: "Send privately",
631
+ description: "Pay someone's cloak: address from the private balance. The chain sees that a private transaction happened, but not who sent it, who got it or how much.",
632
+ inputSchema: { asset: z.string().describe('"HYPE", "USDC" or a launch token'), to: z.string().describe("Recipient cloak: address"), amount: amt('Amount, or "max"'), dry_run: dry },
633
+ annotations: WRITE,
634
+ }, safe(async ({ asset, to, amount, dry_run }) => {
635
+ const { spending } = needWallet();
636
+ if (!to.startsWith("cloak:"))
637
+ throw new Error("The recipient must be a cloak: address (the receiver gets it from unlock or the Portfolio page).");
638
+ const a = await resolve(asset);
639
+ const p = await priv();
640
+ const x = amountOf(amount, a.decimals) ?? (await p.maxSpendable(a.pool, "transact"));
641
+ if (x <= 0n)
642
+ throw new Error("Not enough private balance for this, counting the relayer's fee.");
643
+ const c = await cost(a, x);
644
+ spending.check(c.budget, c.value);
645
+ if (isDry(dry_run))
646
+ return { dryRun: true, wouldSend: fmtAmt(a, x), to };
647
+ return exclusive(async () => {
648
+ const h = await p.send(a.kind === "hype" ? p.hypeMarket.token : a.token, to, x);
649
+ await receipt(h);
650
+ spending.record(c.budget, c.value);
651
+ return { sent: fmtAmt(a, x), to, privateBalance: fmtAmt(a, await p.balance(a.pool)), tx: link(h), visibleOnChain: "a private transaction happened; no sender, recipient or amount" };
652
+ });
653
+ }));
654
+ server.registerTool("withdraw", {
655
+ title: "Withdraw to an address",
656
+ description: "Move funds from the private balance to a normal 0x address (public on purpose). A brand-new address keeps the most privacy. Cloak keeps 0.25%, and the relayer takes a small fee for gas.",
657
+ inputSchema: { asset: z.string().describe('"HYPE", "USDC" or a launch token'), to: addr, amount: amt('Amount, or "max"'), dry_run: dry },
658
+ annotations: WRITE,
659
+ }, safe(async ({ asset, to, amount, dry_run }) => {
660
+ const { spending } = needWallet();
661
+ if (!isAddress(to))
662
+ throw new Error("`to` must be a 0x address.");
663
+ const a = await resolve(asset);
664
+ const p = await priv();
665
+ const kind = a.kind === "hype" ? "unshieldHype" : "transact";
666
+ const x = amountOf(amount, a.decimals) ?? (await p.maxSpendable(a.pool, kind));
667
+ if (x <= 0n)
668
+ throw new Error("Not enough private balance for this, counting the relayer's fee.");
669
+ const c = await cost(a, x);
670
+ spending.check(c.budget, c.value);
671
+ const arrives = await pub.readContract({ address: a.pool, abi: shieldedPoolAbi, functionName: "afterFee", args: [x] });
672
+ if (isDry(dry_run))
673
+ return { dryRun: true, wouldWithdraw: fmtAmt(a, x), arrives: fmtAmt(a, arrives), to };
674
+ return exclusive(async () => {
675
+ const h = a.kind === "hype" ? await p.unshieldHype(to, x) : await p.unshieldToken(a.token, to, x);
676
+ await receipt(h);
677
+ spending.record(c.budget, c.value);
678
+ return { withdrew: fmtAmt(a, x), arrived: fmtAmt(a, arrives), to, privateBalance: fmtAmt(a, await p.balance(a.pool)), tx: link(h) };
679
+ });
680
+ }));
681
+ return server;
682
+ }
package/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "@cloakely/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Cloak's MCP server: let an AI agent launch tokens with sealed bids and trade them privately on Elysium, with spending limits, testnet by default and a local signer.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "ai-agent",
9
+ "cloak",
10
+ "elysium",
11
+ "kinetiq",
12
+ "hype",
13
+ "sealed-bid",
14
+ "privacy",
15
+ "zero-knowledge"
16
+ ],
17
+ "homepage": "https://cloak.boblabs.tech/sdk",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/NECOKIZZ/Cloak.git",
21
+ "directory": "packages/mcp"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/NECOKIZZ/cloak/issues"
25
+ },
26
+ "author": "Bob Labs",
27
+ "license": "MIT",
28
+ "type": "module",
29
+ "bin": {
30
+ "cloak-mcp": "dist/index.js"
31
+ },
32
+ "main": "dist/server.js",
33
+ "types": "dist/server.d.ts",
34
+ "files": [
35
+ "dist",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "1.32.1",
47
+ "viem": "2.21.54",
48
+ "zod": "4.6.7",
49
+ "@cloakely/sdk": "^0.2.6"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "22.10.2",
53
+ "typescript": "5.6.3"
54
+ },
55
+ "exports": {
56
+ ".": {
57
+ "types": "./dist/server.d.ts",
58
+ "default": "./dist/server.js"
59
+ },
60
+ "./http": {
61
+ "types": "./dist/http.d.ts",
62
+ "default": "./dist/http.js"
63
+ },
64
+ "./package.json": "./package.json"
65
+ },
66
+ "scripts": {
67
+ "build": "tsc -p tsconfig.json && chmod +x dist/index.js",
68
+ "test": "node --test test/*.test.mjs"
69
+ }
6
70
  }