hookit-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.
Files changed (3) hide show
  1. package/README.md +57 -2
  2. package/index.js +321 -0
  3. package/package.json +14 -4
package/README.md CHANGED
@@ -1,3 +1,58 @@
1
- # Temporary Holding Version
1
+ # hookit-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
+ Lets an AI agent launch a coin on [hookit.fun](https://hookit.fun) (Ink) by itself, with its own Uniswap v4 hook modules, and collect its creator fees.
4
+
5
+ ## Setup
6
+
7
+ ```json
8
+ {
9
+ "mcpServers": {
10
+ "hookit": {
11
+ "command": "npx",
12
+ "args": ["-y", "hookit-mcp"],
13
+ "env": { "HOOKIT_PRIVATE_KEY": "0x…" }
14
+ }
15
+ }
16
+ }
17
+ ```
18
+
19
+ The key signs on this machine and is never sent anywhere. Give it a wallet funded for launches and nothing else.
20
+
21
+ Optional env: `HOOKIT_API_URL` (default `https://hookit.fun`), `HOOKIT_RPC_URL` (default Ink public RPC).
22
+
23
+ ## Tools
24
+
25
+ | Tool | What it does |
26
+ | --- | --- |
27
+ | `list_presets` | Strategies by goal: clean, community, deflationary, floor, builder, dynamic. |
28
+ | `list_modules` | Every hook module, its settings and the rules a combination must follow. |
29
+ | `list_pairs` | What a coin can be priced in. |
30
+ | `wallet_status` | Whether the wallet holds enough ETH to launch. |
31
+ | `launch_token` | Launches in one call, with an optional `devBuyPct`. `dryRun: true` checks and simulates without signing. |
32
+ | `claim_fees` | Collects the creator fees the wallet earned on coins it launched. |
33
+ | `send_eth` | Sends ETH from the wallet, for example back to a Kraken deposit address on Ink. `dryRun: true` checks only. |
34
+
35
+ ## With Kraken
36
+
37
+ Ink is Kraken's chain: Kraken moves ETH to and from Ink directly. Run the [Kraken CLI](https://github.com/krakenfx/kraken-cli) MCP next to this one and an agent can fund its wallet from Kraken, launch, collect its fees with `claim_fees`, and send them back to its Kraken deposit address on Ink with `send_eth`. Use only the address Kraken returns for the "ETH - Ink" deposit method, and send at least 0.001 ETH: Kraken marks smaller deposits as failed and the funds are lost.
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "kraken": {
43
+ "command": "kraken",
44
+ "args": ["mcp", "-s", "market,account,funding"],
45
+ "env": { "KRAKEN_API_KEY": "…", "KRAKEN_API_SECRET": "…" }
46
+ },
47
+ "hookit": {
48
+ "command": "npx",
49
+ "args": ["-y", "hookit-mcp"],
50
+ "env": { "HOOKIT_PRIVATE_KEY": "0x…" }
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ Kraken withdraws only to saved addresses: save the agent's wallet in Kraken on the Ink network first.
57
+
58
+ Without MCP: `GET /api/agents/catalog` and `POST /api/agents/prepare-launch` on hookit.fun (see `/llms.txt`).
package/index.js ADDED
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+ // hookit-mcp: lets an AI agent launch a coin on hookit.fun (Ink) with its own hook modules.
3
+ // The key in HOOKIT_PRIVATE_KEY signs on this machine and is never sent anywhere.
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { createPublicClient, createWalletClient, decodeEventLog, defineChain, formatEther, http, isAddress, parseAbi, parseEther, zeroAddress } from "viem";
7
+ import { privateKeyToAccount } from "viem/accounts";
8
+ import { z } from "zod";
9
+
10
+ const API = (process.env.HOOKIT_API_URL || "https://hookit.fun").replace(/\/$/, "");
11
+ const RPC = process.env.HOOKIT_RPC_URL || "https://rpc-gel.inkonchain.com";
12
+ const KEY = process.env.HOOKIT_PRIVATE_KEY?.trim();
13
+
14
+ const ink = defineChain({
15
+ id: 57073,
16
+ name: "Ink",
17
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
18
+ rpcUrls: { default: { http: [RPC] } },
19
+ blockExplorers: { default: { name: "Blockscout", url: "https://explorer.inkonchain.com" } },
20
+ });
21
+
22
+ const account = KEY ? privateKeyToAccount(KEY.startsWith("0x") ? KEY : `0x${KEY}`) : null;
23
+ const publicClient = createPublicClient({ chain: ink, transport: http(RPC) });
24
+ const walletClient = account ? createWalletClient({ account, chain: ink, transport: http(RPC) }) : null;
25
+
26
+ const factoryAbi = parseAbi([
27
+ "event TokenLaunched(uint256 indexed launchId, address indexed token, address indexed creator, bytes32 poolId, address hooks, bool customHook, int24 tickLower, int24 tickUpper, uint128 liquidity)",
28
+ "function masterHook() view returns (address)",
29
+ "function launchFee() view returns (uint256)",
30
+ ]);
31
+ const hookAbi = parseAbi(["function feeEscrow() view returns (address)"]);
32
+ const routerAbi = parseAbi([
33
+ "event PayoutLaunch(address indexed token, address indexed agent, address indexed controller, uint8 provider, uint256 accountId, address wallet)",
34
+ ]);
35
+ const escrowAbi = parseAbi([
36
+ "function balanceOf(address account, address currency) view returns (uint256)",
37
+ "function claimAll(address[] currencies)",
38
+ ]);
39
+ const erc20Abi = parseAbi(["function symbol() view returns (string)", "function decimals() view returns (uint8)"]);
40
+
41
+ let catalogCache = null;
42
+ async function catalog() {
43
+ if (catalogCache) return catalogCache;
44
+ const res = await fetch(`${API}/api/agents/catalog`);
45
+ if (!res.ok) throw new Error(`hookit catalog unavailable (${res.status})`);
46
+ catalogCache = await res.json();
47
+ return catalogCache;
48
+ }
49
+
50
+ function reply(value) {
51
+ return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] };
52
+ }
53
+ function fail(message) {
54
+ return { content: [{ type: "text", text: message }], isError: true };
55
+ }
56
+ function needKey() {
57
+ return fail("No wallet. Set HOOKIT_PRIVATE_KEY in this server's env to a wallet funded for launches and nothing else.");
58
+ }
59
+
60
+ const launchShape = {
61
+ name: z.string().describe("Coin name, up to 32 characters."),
62
+ symbol: z.string().describe("Ticker, 1 to 12 letters or digits."),
63
+ description: z.string().optional(),
64
+ image: z.string().optional().describe("Logo as an https:// or ipfs:// URL."),
65
+ twitter: z.string().optional(),
66
+ telegram: z.string().optional(),
67
+ website: z.string().optional(),
68
+ pair: z.string().optional().describe("What the coin is priced in. Default eth. See list_pairs."),
69
+ pairs: z.array(z.string()).optional().describe("Several pairs for a multi-pair launch (USDG and stocks only)."),
70
+ preset: z.string().optional().describe("A strategy from list_presets: clean, community, deflationary, floor, builder, dynamic."),
71
+ hookTaxPct: z.number().optional().describe("Extra swap fee feeding the hook modules, 0 to 9."),
72
+ modules: z.record(z.union([z.boolean(), z.number()])).optional().describe("Module settings from list_modules, e.g. {\"autoBurn\": true, \"autoBurnPct\": 100}. Without a preset they start from a clean slate."),
73
+ devBuyPct: z.number().optional().describe("Creator's own first buy in the launch tx, % of supply (up to 2.5, less with Max Tx). No sniper tax on it."),
74
+ payout: z
75
+ .object({
76
+ kind: z.enum(["wallet", "x", "github", "tiktok"]),
77
+ address: z.string().optional(),
78
+ handle: z.string().optional(),
79
+ accountId: z.string().optional(),
80
+ })
81
+ .optional()
82
+ .describe("Who receives the creator fees. Default: the launching wallet."),
83
+ };
84
+
85
+ async function prepare(args) {
86
+ const res = await fetch(`${API}/api/agents/prepare-launch`, {
87
+ method: "POST",
88
+ headers: { "content-type": "application/json" },
89
+ body: JSON.stringify({ ...args, from: account?.address }),
90
+ });
91
+ const body = await res.json().catch(() => ({ ok: false, error: `hookit answered ${res.status}` }));
92
+ if (!res.ok && !body.error) body.error = `hookit answered ${res.status}`;
93
+ return body;
94
+ }
95
+
96
+ const server = new McpServer({ name: "hookit", version: "0.1.0" });
97
+
98
+ server.registerTool(
99
+ "list_presets",
100
+ {
101
+ description: "Launch strategies by goal (clean, community, deflationary, floor, builder, dynamic) with the exact hook modules each one turns on. Needs no key.",
102
+ inputSchema: {},
103
+ annotations: { readOnlyHint: true },
104
+ },
105
+ async () => {
106
+ const c = await catalog();
107
+ return reply(c.presets.map(({ id, label, goal, settings, singlePairOnly }) => ({ id, label, goal, settings, singlePairOnly })));
108
+ },
109
+ );
110
+
111
+ server.registerTool(
112
+ "list_modules",
113
+ {
114
+ description: "Every hook module an agent can combine, what it does, its settings and the rules a combination must follow. Needs no key.",
115
+ inputSchema: {},
116
+ annotations: { readOnlyHint: true },
117
+ },
118
+ async () => {
119
+ const c = await catalog();
120
+ return reply({ modules: c.modules, rules: c.rules });
121
+ },
122
+ );
123
+
124
+ server.registerTool(
125
+ "list_pairs",
126
+ {
127
+ description: "What a coin can be priced in (ETH, USDG, kHYPE, kBTC, HKT, tokenized stocks), and which can be combined in a multi-pair launch. Needs no key.",
128
+ inputSchema: {},
129
+ annotations: { readOnlyHint: true },
130
+ },
131
+ async () => reply((await catalog()).pairs),
132
+ );
133
+
134
+ server.registerTool(
135
+ "wallet_status",
136
+ {
137
+ description: "Says in words whether the wallet can launch: its address, ETH balance and the launch fee.",
138
+ inputSchema: {},
139
+ annotations: { readOnlyHint: true },
140
+ },
141
+ async () => {
142
+ if (!account) return needKey();
143
+ const c = await catalog();
144
+ const [balance, fee] = await Promise.all([
145
+ publicClient.getBalance({ address: account.address }),
146
+ c.factory ? publicClient.readContract({ address: c.factory, abi: factoryAbi, functionName: "launchFee" }).catch(() => null) : null,
147
+ ]);
148
+ const gasPrice = await publicClient.getGasPrice();
149
+ const needed = (fee ?? 0n) + gasPrice * 4_000_000n;
150
+ return reply({
151
+ address: account.address,
152
+ chain: "Ink (57073)",
153
+ balanceEth: formatEther(balance),
154
+ launchFeeEth: fee === null ? "unknown" : formatEther(fee),
155
+ roughCostEth: formatEther(needed),
156
+ ready: balance >= needed,
157
+ verdict:
158
+ balance >= needed
159
+ ? "Ready to launch."
160
+ : `Not enough ETH on Ink. Send about ${formatEther(needed - balance)} ETH more to ${account.address}.`,
161
+ ...(balance >= needed
162
+ ? {}
163
+ : {
164
+ howToFund:
165
+ "Withdraw ETH from Kraken straight to Ink (choose the Ink network), or bridge from another chain. With the Kraken CLI MCP loaded, an agent can do the withdrawal itself; the user must first whitelist this address in Kraken.",
166
+ }),
167
+ });
168
+ },
169
+ );
170
+
171
+ server.registerTool(
172
+ "launch_token",
173
+ {
174
+ description:
175
+ "Launches a coin on hookit.fun with the chosen preset or hook modules, in one call. With dryRun it only checks the launch and returns the unsigned transaction: use it first and show the user the settings.",
176
+ inputSchema: { ...launchShape, dryRun: z.boolean().optional().describe("Check and simulate only. Signs nothing.") },
177
+ },
178
+ async ({ dryRun, ...args }) => {
179
+ if (!dryRun && !account) return needKey();
180
+ let prepared = await prepare(args);
181
+ if (prepared.error) return fail(prepared.error);
182
+ if (!dryRun && prepared.approve) {
183
+ // ERC-20 dev buy: approve the pair token, then prepare again so the launch is simulated.
184
+ const approveHash = await walletClient.sendTransaction({
185
+ to: prepared.approve.tx.to,
186
+ data: prepared.approve.tx.data,
187
+ });
188
+ await publicClient.waitForTransactionReceipt({ hash: approveHash });
189
+ prepared = await prepare(args);
190
+ if (prepared.error) return fail(prepared.error);
191
+ }
192
+ if (dryRun) {
193
+ return reply({
194
+ dryRun: true,
195
+ verdict: prepared.simulation?.needsApproval
196
+ ? "Valid. A real launch will first approve the dev buy token, then launch."
197
+ : prepared.simulation?.ok === false
198
+ ? `The launch would fail: ${prepared.simulation.error}`
199
+ : "The launch is valid.",
200
+ plan: prepared.plan,
201
+ approveFirst: prepared.approve ?? null,
202
+ tx: prepared.tx,
203
+ warnings: prepared.warnings,
204
+ });
205
+ }
206
+ if (prepared.simulation && !prepared.simulation.ok) {
207
+ return fail(`The launch would fail: ${prepared.simulation.error}\nSettings: ${prepared.plan.settings.join("; ")}`);
208
+ }
209
+
210
+ const hash = await walletClient.sendTransaction({
211
+ to: prepared.tx.to,
212
+ data: prepared.tx.data,
213
+ value: BigInt(prepared.tx.value),
214
+ gas: BigInt(prepared.tx.gas),
215
+ });
216
+ const receipt = await publicClient.waitForTransactionReceipt({ hash });
217
+ if (receipt.status !== "success") return fail(`Launch transaction reverted: ${hash}`);
218
+ let token = null;
219
+ let agent = null;
220
+ for (const log of receipt.logs) {
221
+ for (const abi of [factoryAbi, routerAbi]) {
222
+ try {
223
+ const ev = decodeEventLog({ abi, data: log.data, topics: log.topics });
224
+ if (ev.eventName === "TokenLaunched") token = ev.args.token;
225
+ if (ev.eventName === "PayoutLaunch") agent = ev.args.agent;
226
+ } catch {}
227
+ }
228
+ }
229
+ return reply({
230
+ launched: true,
231
+ token,
232
+ page: token ? `${API}/token/${token}` : null,
233
+ tx: `https://explorer.inkonchain.com/tx/${hash}`,
234
+ settings: prepared.plan.settings,
235
+ payout: prepared.plan.payout,
236
+ payoutAgent: agent,
237
+ note: agent
238
+ ? "Creator fees go to the payout target through this launch's CreatorPayoutAgent. This wallet is its controller."
239
+ : "Creator fees build up in FeeEscrow for this wallet. Call claim_fees to collect them.",
240
+ });
241
+ },
242
+ );
243
+
244
+ server.registerTool(
245
+ "claim_fees",
246
+ {
247
+ description: "Collects the creator fees this wallet has earned on coins it launched itself (paid in ETH and the pair tokens).",
248
+ inputSchema: {
249
+ currencies: z.array(z.string()).optional().describe("Token addresses to collect. Default: ETH plus every pair token."),
250
+ },
251
+ },
252
+ async ({ currencies }) => {
253
+ if (!account) return needKey();
254
+ const c = await catalog();
255
+ if (!c.factory) return fail("Launches are not configured on this hookit.");
256
+ const hook = await publicClient.readContract({ address: c.factory, abi: factoryAbi, functionName: "masterHook" });
257
+ const escrow = await publicClient.readContract({ address: hook, abi: hookAbi, functionName: "feeEscrow" });
258
+ const candidates = currencies?.length ? currencies : [zeroAddress, ...(c.pairAddresses ?? [])];
259
+ const owed = [];
260
+ for (const currency of candidates) {
261
+ const amount = await publicClient.readContract({ address: escrow, abi: escrowAbi, functionName: "balanceOf", args: [account.address, currency] });
262
+ if (amount > 0n) owed.push({ currency, amount });
263
+ }
264
+ if (owed.length === 0) return reply("Nothing to collect yet.");
265
+ const hash = await walletClient.writeContract({ address: escrow, abi: escrowAbi, functionName: "claimAll", args: [owed.map((o) => o.currency)] });
266
+ await publicClient.waitForTransactionReceipt({ hash });
267
+ const lines = [];
268
+ for (const { currency, amount } of owed) {
269
+ if (currency === zeroAddress) {
270
+ lines.push(`${formatEther(amount)} ETH`);
271
+ continue;
272
+ }
273
+ const [symbol, decimals] = await Promise.all([
274
+ publicClient.readContract({ address: currency, abi: erc20Abi, functionName: "symbol" }).catch(() => currency),
275
+ publicClient.readContract({ address: currency, abi: erc20Abi, functionName: "decimals" }).catch(() => 18),
276
+ ]);
277
+ lines.push(`${Number(amount) / 10 ** Number(decimals)} ${symbol}`);
278
+ }
279
+ return reply({ collected: lines, tx: `https://explorer.inkonchain.com/tx/${hash}` });
280
+ },
281
+ );
282
+
283
+ server.registerTool(
284
+ "send_eth",
285
+ {
286
+ description:
287
+ "Sends ETH from this wallet on Ink, for example to cash out to the user's Kraken ETH deposit address on the Ink network (get it with the Kraken MCP deposit addresses tool for the \"ETH - Ink\" method only). Kraken's minimum ETH deposit on Ink is 0.001 ETH; smaller deposits fail and the funds are lost, so never send less. Run it with dryRun first and repeat the address and amount to the user before sending.",
288
+ inputSchema: {
289
+ to: z.string().describe("Destination address on Ink."),
290
+ amountEth: z.string().describe('Amount in ETH, or "max" to send everything except gas.'),
291
+ dryRun: z.boolean().optional().describe("Check only. Signs nothing."),
292
+ },
293
+ },
294
+ async ({ to, amountEth, dryRun }) => {
295
+ if (!account) return needKey();
296
+ if (!isAddress(to)) return fail(`Not an address: ${to}`);
297
+ const [balance, gasPrice] = await Promise.all([publicClient.getBalance({ address: account.address }), publicClient.getGasPrice()]);
298
+ // A plain transfer is 21k gas; keep extra for the L1 data fee.
299
+ const gasReserve = gasPrice * 60_000n + 20_000_000_000_000n;
300
+ let value;
301
+ if (amountEth.trim().toLowerCase() === "max") {
302
+ value = balance > gasReserve ? balance - gasReserve : 0n;
303
+ } else {
304
+ try {
305
+ value = parseEther(amountEth.trim());
306
+ } catch {
307
+ return fail(`Not an amount: ${amountEth}`);
308
+ }
309
+ }
310
+ if (value <= 0n) return fail(`Nothing to send: the wallet holds ${formatEther(balance)} ETH.`);
311
+ if (value + gasReserve > balance) {
312
+ return fail(`Not enough ETH: the wallet holds ${formatEther(balance)} ETH and needs some left for gas.`);
313
+ }
314
+ if (dryRun) return reply({ dryRun: true, from: account.address, to, amountEth: formatEther(value), verdict: "Ready to send." });
315
+ const hash = await walletClient.sendTransaction({ to, value });
316
+ await publicClient.waitForTransactionReceipt({ hash });
317
+ return reply({ sentEth: formatEther(value), to, tx: `https://explorer.inkonchain.com/tx/${hash}` });
318
+ },
319
+ );
320
+
321
+ await server.connect(new StdioServerTransport());
package/package.json CHANGED
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "name": "hookit-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Let an AI agent launch a coin on hookit.fun (Ink) with its own hook modules, and collect its creator fees.",
5
+ "type": "module",
6
+ "bin": { "hookit-mcp": "index.js" },
7
+ "files": ["index.js", "README.md"],
8
+ "engines": { "node": ">=20" },
9
+ "license": "MIT",
10
+ "keywords": ["mcp", "model-context-protocol", "ink", "uniswap-v4", "hooks", "launchpad", "agents"],
11
+ "dependencies": {
12
+ "@modelcontextprotocol/sdk": "^1.31.0",
13
+ "viem": "^2.55.0",
14
+ "zod": "^3.25.0"
15
+ }
16
+ }