@blockula/mcp 0.1.0 → 0.3.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 +7 -4
  2. package/dist/index.js +145 -32
  3. package/package.json +11 -2
package/README.md CHANGED
@@ -6,20 +6,23 @@
6
6
  claude mcp add blockula -- npx -y @blockula/mcp
7
7
  ```
8
8
 
9
- **Tools:** `status`, `start_dev_node`, `summon_chain`, `create_wallet`, `use_account`, `wallet_info`, `faucet`, `send`, `check_name`, `register_name`, `lookup`, `latest_blocks`.
9
+ It works with zero setup: if no local node is running, it connects to the public testnet at `https://blockula-testnet.fly.dev`. Call `status` to see which node you're on.
10
+
11
+ **Tools:** `status`, `tx_status`, `install_node`, `list_hunts`, `link_github`, `fund_hunt`, `start_dev_node`, `summon_chain`, `create_wallet`, `use_account`, `wallet_info`, `faucet`, `send`, `check_name`, `register_name`, `lookup`, `latest_blocks`.
10
12
 
11
13
  **Spending a user's money safely:**
12
14
  1. `create_wallet` gives you a `key_id`. Hand it to the user.
13
- 2. The user grants it a budget in the Blockula web wallet (Devices & agents → Give an agent a budget). The chain enforces the limit and expiry.
15
+ 2. The user grants it a budget in the Blockula web wallet at https://blockula-wallet.vercel.app (Devices & agents → Give an agent a budget). The chain enforces the limit and expiry.
14
16
  3. `use_account("theirname.boo")`, then `send`.
15
17
 
16
18
  | Env | Default | |
17
19
  |---|---|---|
18
- | `BLOCKULA_RPC` | `http://localhost:7070` | node URL |
20
+ | `BLOCKULA_RPC` | local node if running, else the public testnet | node URL |
19
21
  | `BLOCKULA_HOME` | `~/.blockula` | where the agent key is stored (mode 600) |
20
22
  | `BLOCKULA_MAX_SEND` | `100` | max FANG per `send` call |
23
+ | `BLOCKULA_MAX_DAILY` | `100` | max FANG sent per UTC day across all calls (persisted in `BLOCKULA_HOME`) |
21
24
  | `BLOCKULA_BIN` | `blockula` | node binary used by `start_dev_node` / `summon_chain` |
22
25
 
23
- `start_dev_node` and `summon_chain` need the `blockula` node binary on your PATH. All other tools work against any reachable node.
26
+ `start_dev_node` and `summon_chain` need the `blockula` node binary (build it from the Blockula repo with `cargo build --release`). Every other tool works against the public testnet without it.
24
27
 
25
28
  ⚠️ Early and unaudited: test chains only.
package/dist/index.js CHANGED
@@ -3,25 +3,72 @@
3
3
  // claude mcp add blockula -- npx -y @blockula/mcp
4
4
  //
5
5
  // Env:
6
- // BLOCKULA_RPC node URL (default http://localhost:7070)
7
- // BLOCKULA_HOME where the agent wallet lives (default ~/.blockula)
8
- // BLOCKULA_MAX_SEND max FANG per send() call (default 100) — a guardrail until on-chain session keys land
9
- // BLOCKULA_BIN path to the `blockula` binary (default: found on PATH)
6
+ // BLOCKULA_RPC node URL (default: a local node on :7070 if one is running, else the public testnet)
7
+ // BLOCKULA_HOME where the agent wallet lives (default ~/.blockula)
8
+ // BLOCKULA_MAX_SEND max FANG per send() call (default 100)
9
+ // BLOCKULA_MAX_DAILY max FANG this MCP sends per UTC day, across calls (default 100)
10
+ // BLOCKULA_BIN path to the `blockula` binary (default: ~/.blockula/bin/blockula from install_node, else PATH)
11
+ // When acting for a user's account via a session key, the chain also enforces that key's limit.
10
12
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
13
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
- import { Client, Wallet, parseFang } from "@blockula/sdk";
14
+ import { Client, Wallet, formatFang, parseFang } from "@blockula/sdk";
13
15
  import { spawn, execFileSync } from "node:child_process";
14
- import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
15
- import { homedir } from "node:os";
16
+ import { createHash } from "node:crypto";
17
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18
+ import { homedir, tmpdir } from "node:os";
16
19
  import { join } from "node:path";
17
20
  import { z } from "zod";
18
- const RPC = process.env.BLOCKULA_RPC ?? "http://localhost:7070";
21
+ const LOCAL_RPC = "http://localhost:7070";
22
+ const TESTNET_RPC = "https://blockula-testnet.fly.dev";
19
23
  const HOME = process.env.BLOCKULA_HOME ?? join(homedir(), ".blockula");
20
24
  const WALLET_FILE = join(HOME, "agent-wallet.json");
21
25
  const MAX_SEND = parseFang(process.env.BLOCKULA_MAX_SEND ?? "100");
22
- const BIN = process.env.BLOCKULA_BIN ?? "blockula";
23
- const client = new Client(RPC);
24
- const server = new McpServer({ name: "blockula", version: "0.1.0" });
26
+ const MAX_DAILY = parseFang(process.env.BLOCKULA_MAX_DAILY ?? "100");
27
+ const SPEND_FILE = join(HOME, "agent-spend.json");
28
+ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
29
+ const REPO = "jackdishman/blockula";
30
+ const INSTALLED_BIN = join(HOME, "bin", "blockula");
31
+ let BIN = process.env.BLOCKULA_BIN ?? (existsSync(INSTALLED_BIN) ? INSTALLED_BIN : "blockula");
32
+ function releaseTarget() {
33
+ const key = `${process.platform}-${process.arch}`;
34
+ return { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu" }[key] ?? null;
35
+ }
36
+ async function download(url) {
37
+ const res = await fetch(url, { redirect: "follow" });
38
+ if (!res.ok)
39
+ throw new Error(`download failed (${res.status}): ${url}`);
40
+ return Buffer.from(await res.arrayBuffer());
41
+ }
42
+ async function reachable(url) {
43
+ try {
44
+ await new Client(url).status();
45
+ return true;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ // No configuration needed: use a local node if one is running, otherwise the public testnet.
52
+ let RPC = process.env.BLOCKULA_RPC ?? ((await reachable(LOCAL_RPC)) ? LOCAL_RPC : TESTNET_RPC);
53
+ let client = new Client(RPC);
54
+ function useRpc(url) {
55
+ RPC = url;
56
+ client = new Client(url);
57
+ }
58
+ /** Running total of FANG sent today (UTC), persisted so the cap holds across calls and restarts. */
59
+ function spentToday() {
60
+ const day = new Date().toISOString().slice(0, 10);
61
+ try {
62
+ const f = JSON.parse(readFileSync(SPEND_FILE, "utf8"));
63
+ if (f.day === day)
64
+ return { day, spent: BigInt(f.spent) };
65
+ }
66
+ catch {
67
+ /* no spend yet */
68
+ }
69
+ return { day, spent: 0n };
70
+ }
71
+ const server = new McpServer({ name: "blockula", version: VERSION });
25
72
  const text = (v) => ({ content: [{ type: "text", text: typeof v === "string" ? v : JSON.stringify(v, null, 2) }] });
26
73
  function readWalletFile() {
27
74
  return existsSync(WALLET_FILE) ? JSON.parse(readFileSync(WALLET_FILE, "utf8")) : null;
@@ -41,15 +88,6 @@ function requireWallet() {
41
88
  throw new Error("No agent wallet yet — call create_wallet first.");
42
89
  return w;
43
90
  }
44
- async function nodeUp() {
45
- try {
46
- await client.status();
47
- return true;
48
- }
49
- catch {
50
- return false;
51
- }
52
- }
53
91
  function tool(name, description, shape, run) {
54
92
  server.registerTool(name, { description, inputSchema: shape }, (async (args) => {
55
93
  try {
@@ -62,22 +100,69 @@ function tool(name, description, shape, run) {
62
100
  }
63
101
  }));
64
102
  }
65
- tool("status", "Blockula network status: chain id, height, supply, fees burned, and the crypto in use.", {}, () => client.status());
66
- tool("start_dev_node", "Start a local Blockula dev chain (with faucet + built-in explorer) if one isn't already running. Requires the `blockula` binary.", { reset: z.boolean().optional().describe("wipe the dev chain first") }, async ({ reset }) => {
67
- if (!reset && (await nodeUp()))
68
- return `Already running at ${RPC} (explorer: ${RPC})`;
69
- const port = new URL(RPC).port || "7070";
103
+ tool("status", "Blockula network status: which node you're connected to, chain id, height, supply, fees burned, and the crypto in use.", {}, async () => ({
104
+ rpc: RPC,
105
+ network: RPC === TESTNET_RPC ? "public testnet" : RPC === LOCAL_RPC ? "local node" : "custom node",
106
+ explorer: RPC,
107
+ mcp_version: VERSION,
108
+ ...(await client.status()),
109
+ }));
110
+ tool("tx_status", "Check a transaction by id: pending, finalized (with block), and its plain-English summary.", { tx_id: z.string().regex(/^[0-9a-f]{64}$/) }, async ({ tx_id }) => {
111
+ const tx = await client.tx(tx_id);
112
+ return { status: tx.status, summary: tx.summary, block: tx.height, fee: tx.fee_display, explorer: `${RPC}/#/tx/${tx_id}` };
113
+ });
114
+ tool("start_dev_node", "Start a local Blockula dev chain (faucet + built-in explorer) and switch to it. Needs the `blockula` binary; without it, keep using the public testnet, which supports everything except running your own chain.", { reset: z.boolean().optional().describe("wipe the dev chain first") }, async ({ reset }) => {
115
+ const target = process.env.BLOCKULA_RPC ?? LOCAL_RPC;
116
+ if (!reset && (await reachable(target))) {
117
+ useRpc(target);
118
+ return `Already running at ${target} (explorer: ${target})`;
119
+ }
120
+ const port = new URL(target).port || "7070";
70
121
  mkdirSync(HOME, { recursive: true });
71
122
  const log = openSync(join(HOME, "dev-node.log"), "a");
123
+ let missing = false;
72
124
  const child = spawn(BIN, ["dev", "--port", port, ...(reset ? ["--reset"] : [])], { detached: true, stdio: ["ignore", log, log] });
73
- child.on("error", () => { });
125
+ child.on("error", (e) => {
126
+ missing = e.code === "ENOENT";
127
+ });
74
128
  child.unref();
75
- for (let i = 0; i < 40; i++) {
129
+ for (let i = 0; i < 40 && !missing; i++) {
76
130
  await new Promise((r) => setTimeout(r, 250));
77
- if (await nodeUp())
78
- return `🧛 Dev chain is up. Explorer: ${RPC} · logs: ${join(HOME, "dev-node.log")}`;
131
+ if (await reachable(target)) {
132
+ useRpc(target);
133
+ return `🧛 Dev chain is up. Explorer: ${target} · logs: ${join(HOME, "dev-node.log")}`;
134
+ }
135
+ }
136
+ if (missing) {
137
+ throw new Error(`The \`${BIN}\` node binary isn't installed, so a local chain can't start. You're connected to ${RPC}${RPC === TESTNET_RPC ? " (the public testnet)" : ""}, which works for wallets, the faucet, names and sends. Call install_node to download the prebuilt node (checksum-verified), then start_dev_node again.`);
138
+ }
139
+ throw new Error(`node didn't start. See ${join(HOME, "dev-node.log")}`);
140
+ });
141
+ tool("install_node", "Download the prebuilt `blockula` node + CLI for this machine from the official GitHub release, verify its SHA-256 checksum, and install it to ~/.blockula/bin. Needed for start_dev_node and summon_chain; nothing else needs it.", { version: z.string().regex(/^(latest|v\d+\.\d+\.\d+)$/).default("latest") }, async ({ version }) => {
142
+ const target = releaseTarget();
143
+ if (!target)
144
+ throw new Error(`No prebuilt node for ${process.platform}/${process.arch}. Build from source: https://github.com/${REPO}`);
145
+ const base = version === "latest" ? `https://github.com/${REPO}/releases/latest/download` : `https://github.com/${REPO}/releases/download/${version}`;
146
+ const asset = `blockula-${target}.tar.gz`;
147
+ const [tarball, sums] = await Promise.all([download(`${base}/${asset}`), download(`${base}/checksums.txt`)]);
148
+ const expected = sums.toString("utf8").split("\n").map((l) => l.trim().split(/\s+/)).find(([, f]) => f === asset)?.[0];
149
+ const actual = createHash("sha256").update(tarball).digest("hex");
150
+ if (!expected || expected !== actual)
151
+ throw new Error(`checksum mismatch for ${asset}; not installing`);
152
+ const tmp = mkdtempSync(join(tmpdir(), "blockula-"));
153
+ try {
154
+ writeFileSync(join(tmp, asset), tarball);
155
+ execFileSync("tar", ["-xzf", join(tmp, asset), "-C", tmp]);
156
+ mkdirSync(join(HOME, "bin"), { recursive: true });
157
+ copyFileSync(join(tmp, `blockula-${target}`, "blockula"), INSTALLED_BIN);
158
+ chmodSync(INSTALLED_BIN, 0o755);
159
+ }
160
+ finally {
161
+ rmSync(tmp, { recursive: true, force: true });
79
162
  }
80
- throw new Error(`node didn't start — is \`${BIN}\` installed? See ${join(HOME, "dev-node.log")}`);
163
+ BIN = INSTALLED_BIN;
164
+ const installed = execFileSync(INSTALLED_BIN, ["--version"], { encoding: "utf8" }).trim();
165
+ return { installed, path: INSTALLED_BIN, sha256: actual, next: "start_dev_node runs a local chain; summon_chain creates your own network" };
81
166
  });
82
167
  tool("summon_chain", "Create a brand-new Blockula chain (fresh genesis + validator key) that you can run as your own network.", { chain_id: z.string().regex(/^[a-z0-9-]{3,40}$/), faucet: z.boolean().default(true) }, async ({ chain_id, faucet }) => {
83
168
  const out = execFileSync(BIN, ["summon", chain_id, ...(faucet ? [] : ["--no-faucet"])], { encoding: "utf8" });
@@ -130,12 +215,19 @@ tool("wallet_info", "This agent's address, balance, .boo names and recent activi
130
215
  tool("faucet", "Get 100 test FANG on a dev/test chain (to this agent's wallet, or any name/address).", { to: z.string().optional() }, async ({ to }) => {
131
216
  const target = to ?? requireWallet().account;
132
217
  const tx = await client.faucet(target);
133
- return tx.summary;
218
+ return { summary: tx.summary, tx_id: tx.id, explorer: `${RPC}/#/tx/${tx.id}` };
134
219
  });
135
220
  tool("send", "Send FANG to a .boo name or boo1… address. Amounts are in FANG, e.g. \"1.5\".", { to: z.string(), amount: z.string(), memo: z.string().max(256).optional() }, async ({ to, amount, memo }) => {
136
- if (parseFang(amount) > MAX_SEND)
221
+ const drops = parseFang(amount);
222
+ if (drops > MAX_SEND)
137
223
  throw new Error(`amount exceeds this agent's per-send limit (BLOCKULA_MAX_SEND=${process.env.BLOCKULA_MAX_SEND ?? "100"} FANG)`);
224
+ const today = spentToday();
225
+ if (today.spent + drops > MAX_DAILY) {
226
+ throw new Error(`this would exceed the agent's daily limit (BLOCKULA_MAX_DAILY=${process.env.BLOCKULA_MAX_DAILY ?? "100"} FANG; ${formatFang(today.spent)} already sent today)`);
227
+ }
138
228
  const tx = await requireWallet().send(to, amount, { memo });
229
+ mkdirSync(HOME, { recursive: true });
230
+ writeFileSync(SPEND_FILE, JSON.stringify({ day: today.day, spent: (today.spent + drops + BigInt(tx.fee)).toString() }), { mode: 0o600 });
139
231
  return { summary: tx.summary, fee: tx.fee_display, tx_id: tx.id, block: tx.height, explorer: `${RPC}/#/tx/${tx.id}` };
140
232
  });
141
233
  tool("check_name", "Check whether a .boo name is available and what it costs.", { name: z.string() }, ({ name }) => client.name(name));
@@ -152,4 +244,25 @@ tool("latest_blocks", "The most recent blocks, summarized.", { limit: z.number()
152
244
  const blocks = await client.blocks(limit);
153
245
  return blocks.map((b) => `${b.summary} · ${b.hash.slice(0, 12)}…`);
154
246
  });
247
+ tool("list_hunts", "Open bounties (Hunts) on GitHub issues, with FANG escrowed on-chain. Each links to an issue with acceptance criteria: implement it, open a PR, and the bounty is awarded on-chain when it merges.", { status: z.enum(["open", "awarded", "cancelled", "expired"]).default("open") }, async ({ status }) => {
248
+ const res = await client.hunts(status);
249
+ const hunts = (Array.isArray(res) ? res : res.hunts ?? []);
250
+ return hunts.map((h) => ({ hunt_id: h.id, bounty: h.amount_display, title: h.title, issue: h.issue_url, funders: h.funders?.length ?? 0, explorer: `${RPC}/#/hunt/${h.id}` }));
251
+ });
252
+ tool("link_github", "Link a GitHub handle to the account this agent acts for, so Hunt awards show up on its profile. First create a PUBLIC gist (as that GitHub user) containing the account's boo1 address, then pass the gist id here.", { handle: z.string().regex(/^[A-Za-z0-9-]{1,39}$/), gist_id: z.string().regex(/^[0-9a-f]{20,40}$/) }, async ({ handle, gist_id }) => {
253
+ const tx = await requireWallet().linkGithub(handle, gist_id);
254
+ const check = await client.github(handle).catch(() => null);
255
+ return { summary: tx.summary, verified: check?.verified ?? false, explorer: `${RPC}/#/tx/${tx.id}` };
256
+ });
257
+ tool("fund_hunt", "Add FANG to an open Hunt's bounty (crowdfunding). Refunded to you if the hunt is cancelled.", { hunt_id: z.number().int().min(0), amount: z.string() }, async ({ hunt_id, amount }) => {
258
+ const drops = parseFang(amount);
259
+ if (drops > MAX_SEND)
260
+ throw new Error(`amount exceeds this agent's per-send limit (BLOCKULA_MAX_SEND=${process.env.BLOCKULA_MAX_SEND ?? "100"} FANG)`);
261
+ const today = spentToday();
262
+ if (today.spent + drops > MAX_DAILY)
263
+ throw new Error(`this would exceed the agent's daily limit (BLOCKULA_MAX_DAILY=${process.env.BLOCKULA_MAX_DAILY ?? "100"} FANG)`);
264
+ const tx = await requireWallet().fundHunt(hunt_id, amount);
265
+ writeFileSync(SPEND_FILE, JSON.stringify({ day: today.day, spent: (today.spent + drops + BigInt(tx.fee)).toString() }), { mode: 0o600 });
266
+ return { summary: tx.summary, explorer: `${RPC}/#/hunt/${hunt_id}` };
267
+ });
155
268
  await server.connect(new StdioServerTransport());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockula/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "\ud83e\udddb MCP server for Blockula \u2014 lets any coding agent run a node, hold a post-quantum wallet, send FANG and claim .boo names",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "license": "MIT",
19
19
  "dependencies": {
20
- "@blockula/sdk": "^0.1.0",
20
+ "@blockula/sdk": "^0.3.0",
21
21
  "@modelcontextprotocol/sdk": "^1.32.1",
22
22
  "zod": "^4.0.0"
23
23
  },
@@ -39,5 +39,14 @@
39
39
  },
40
40
  "publishConfig": {
41
41
  "access": "public"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/jackdishman/blockula.git",
46
+ "directory": "mcp"
47
+ },
48
+ "homepage": "https://github.com/jackdishman/blockula#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/jackdishman/blockula/issues"
42
51
  }
43
52
  }