@aibtc/mcp-server 1.59.0 → 1.60.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/README.md CHANGED
@@ -69,6 +69,38 @@ npx @aibtc/mcp-server@latest --install --vscode # VS Code (writes ./.vscode/
69
69
 
70
70
  Each installer merges into the existing config — it won't clobber other servers or settings. Restart the client afterward.
71
71
 
72
+ ### OpenRouter (any model)
73
+
74
+ The clients above are MCP hosts — they connect to this server for you. To drive the tools with an [OpenRouter](https://openrouter.ai) model instead, the server ships a built-in bridge: it spawns itself in server mode, exposes the tools to the model as function tools, and runs the tool-call loop. This is the client-side pattern from [OpenRouter's MCP cookbook](https://openrouter.ai/docs/cookbook/coding-agents/mcp-servers), packaged into the binary.
75
+
76
+ ```bash
77
+ export OPENROUTER_API_KEY=sk-or-...
78
+ npx @aibtc/mcp-server@latest bridge "what's the STX balance of SP3...?"
79
+ ```
80
+
81
+ Safety flags (this server moves real funds, so the bridge defaults to nothing extra and lets you constrain it):
82
+
83
+ | Flag | Effect |
84
+ |------|--------|
85
+ | `--read-only` | Expose only read-only tools (no transfer/swap/deploy/etc.) |
86
+ | `--allow a,b` | Force-allow specific tool names (added to the set) |
87
+ | `--block a,b` | Force-remove specific tool names (wins over `--allow`) |
88
+ | `--max-spend-ustx <n>` / `--max-spend-sats <n>` | Cap spend via the server's spend-limit rail (enforced before signing) |
89
+ | `--list-tools` | Print the exposed tool set and exit (no API key needed) |
90
+ | `--model <id>` | OpenRouter model (default `anthropic/claude-3.5-haiku`) |
91
+ | `--network <net>` | `mainnet` or `testnet` (default `mainnet`) |
92
+ | `--max-turns <n>` | Tool-call loop cap (default 10) |
93
+
94
+ ```bash
95
+ # Preview what a read-only session would expose, no key needed:
96
+ npx @aibtc/mcp-server@latest bridge --read-only --list-tools
97
+
98
+ # Read-only chat, plus one explicitly allowed write tool:
99
+ npx @aibtc/mcp-server@latest bridge --read-only --allow transfer_stx "send 1 STX to SP3..."
100
+ ```
101
+
102
+ The allowlist is re-enforced at execution time, so a model can never call a tool outside the exposed set. Any MCP-capable agent framework (`@openrouter/agent`, OpenAI Agents SDK, Claude Agent SDK) can also point at this server directly — the bridge is for driving it through OpenRouter's raw API without adopting a framework.
103
+
72
104
  ### Testnet Mode
73
105
 
74
106
  Add `--testnet` to any install command:
@@ -512,9 +544,14 @@ Or use any SIP-010 token by contract ID: `SP2X...::token-name`
512
544
  | `API_URL` | Default x402 API base URL | `https://x402.biwas.xyz` |
513
545
  | `CLIENT_MNEMONIC` | (Optional) Pre-configured mnemonic | - |
514
546
  | `HIRO_API_KEY` | (Optional) Hiro API key for higher rate limits | - |
547
+ | `SPEND_LIMIT_ENABLED` | Set `false` to disable the wallet spending limit | `true` |
548
+ | `SPEND_LIMIT_DAILY_USTX` / `SPEND_LIMIT_SESSION_USTX` | STX spend cap per day / per unlock (micro-STX) | `10000000` (10 STX) |
549
+ | `SPEND_LIMIT_DAILY_SATS` / `SPEND_LIMIT_SESSION_SATS` | BTC spend cap per day / per unlock (sats) | `50000` |
515
550
 
516
551
  **Note on `NETWORK`:** The `--install` command writes `NETWORK=mainnet` by default (pass `--testnet` to use testnet). If you omit `NETWORK` from your config entirely, the runtime fallback is `testnet`. Most users should set this explicitly.
517
552
 
553
+ **Note on spending limits:** A default-on safety rail meters every outbound spend (`transfer_stx`, `transfer_btc`, x402/L402 auto-payments) against a cumulative per-session and per-day cap, so a single bad instruction or a malicious endpoint can't drain the wallet. A spend over the cap is blocked and reports the remaining budget. Raise the caps via the env vars above, or disable with `SPEND_LIMIT_ENABLED=false`. See [SECURITY.md](SECURITY.md#limit-blast-radius).
554
+
518
555
  **Note:** `CLIENT_MNEMONIC` is optional. The recommended approach is to let Claude create its own wallet. `HIRO_API_KEY` is optional but recommended for production use — without it, you may hit Hiro's public rate limits (429 responses). Get a key at [platform.hiro.so](https://platform.hiro.so).
519
556
 
520
557
  ## Architecture
@@ -538,8 +575,12 @@ You ←→ Claude ←→ aibtc-mcp-server
538
575
  - Mnemonics shown only once at creation
539
576
  - Auto-lock after 15 minutes (configurable)
540
577
  - Transactions signed locally before broadcast
578
+ - **Spending limit (default-on):** outbound spends are capped per session and per day so a single bad instruction can't drain the wallet — see [Configuration](#configuration)
579
+ - **Secret-scanning pre-commit hook:** contributors get a hook (auto-installed via `npm install`) that blocks committing a seed phrase or private key
541
580
  - For mainnet: Fund with small amounts first
542
581
 
582
+ See [SECURITY.md](SECURITY.md) for the full wallet-key protection model and key-leak recovery steps.
583
+
543
584
  ## Advanced: Pre-configured Mnemonic
544
585
 
545
586
  For automated setups where Claude needs immediate wallet access, add the `CLIENT_MNEMONIC` environment variable to your MCP server config (in `~/.claude.json` for Claude Code, or `claude_desktop_config.json` for Claude Desktop):
@@ -0,0 +1,31 @@
1
+ /**
2
+ * OpenRouter <-> MCP bridge (`aibtc-mcp-server bridge`).
3
+ *
4
+ * Lets any OpenRouter model call this server's tools. This is the canonical
5
+ * integration described in OpenRouter's own cookbook
6
+ * (https://openrouter.ai/docs/cookbook/coding-agents/mcp-servers): the CLIENT
7
+ * connects to the MCP server and routes LLM calls through OpenRouter — there is
8
+ * no OpenRouter-side MCP hosting. This is a Node port of that pattern with two
9
+ * upgrades: it handles every tool_call (not just the first) and loops to
10
+ * completion (not a single round).
11
+ *
12
+ * Flow:
13
+ * 1. Spawn this same binary in server mode over stdio, connect, list tools.
14
+ * 2. Apply safety filters, then convert each MCP tool -> OpenAI function tool.
15
+ * 3. POST conversation + tools to OpenRouter /chat/completions.
16
+ * 4. On tool_calls, execute via MCP and feed results back as role:"tool".
17
+ * 5. Loop until the model answers in plain text (capped by --max-turns).
18
+ *
19
+ * Safety flags:
20
+ * --read-only expose only read-only tools (heuristic allowlist)
21
+ * --allow a,b,c force-allow these tool names (added to the set)
22
+ * --block a,b,c force-remove these tool names (applied last, wins)
23
+ * --max-spend-ustx <n> forward to the server's SPEND_LIMIT_SESSION_USTX rail
24
+ * --max-spend-sats <n> forward to the server's SPEND_LIMIT_SESSION_SATS rail
25
+ * --list-tools print the exposed tool set and exit (no API key needed)
26
+ * --model <id> OpenRouter model (default anthropic/claude-3.5-haiku)
27
+ * --network <net> mainnet|testnet for the spawned server (default mainnet)
28
+ * --max-turns <n> tool-call loop cap (default 10)
29
+ */
30
+ export declare function runBridge(argv: string[]): Promise<void>;
31
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,285 @@
1
+ /**
2
+ * OpenRouter <-> MCP bridge (`aibtc-mcp-server bridge`).
3
+ *
4
+ * Lets any OpenRouter model call this server's tools. This is the canonical
5
+ * integration described in OpenRouter's own cookbook
6
+ * (https://openrouter.ai/docs/cookbook/coding-agents/mcp-servers): the CLIENT
7
+ * connects to the MCP server and routes LLM calls through OpenRouter — there is
8
+ * no OpenRouter-side MCP hosting. This is a Node port of that pattern with two
9
+ * upgrades: it handles every tool_call (not just the first) and loops to
10
+ * completion (not a single round).
11
+ *
12
+ * Flow:
13
+ * 1. Spawn this same binary in server mode over stdio, connect, list tools.
14
+ * 2. Apply safety filters, then convert each MCP tool -> OpenAI function tool.
15
+ * 3. POST conversation + tools to OpenRouter /chat/completions.
16
+ * 4. On tool_calls, execute via MCP and feed results back as role:"tool".
17
+ * 5. Loop until the model answers in plain text (capped by --max-turns).
18
+ *
19
+ * Safety flags:
20
+ * --read-only expose only read-only tools (heuristic allowlist)
21
+ * --allow a,b,c force-allow these tool names (added to the set)
22
+ * --block a,b,c force-remove these tool names (applied last, wins)
23
+ * --max-spend-ustx <n> forward to the server's SPEND_LIMIT_SESSION_USTX rail
24
+ * --max-spend-sats <n> forward to the server's SPEND_LIMIT_SESSION_SATS rail
25
+ * --list-tools print the exposed tool set and exit (no API key needed)
26
+ * --model <id> OpenRouter model (default anthropic/claude-3.5-haiku)
27
+ * --network <net> mainnet|testnet for the spawned server (default mainnet)
28
+ * --max-turns <n> tool-call loop cap (default 10)
29
+ */
30
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
31
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
32
+ const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
33
+ const DEFAULT_MODEL = "anthropic/claude-3.5-haiku";
34
+ /**
35
+ * Read-only heuristic. A tool counts as read-only only if it matches a known
36
+ * read pattern AND contains no mutating verb — deliberately conservative so a
37
+ * spend/write tool is never auto-included. Use --allow/--block for precision.
38
+ */
39
+ const READ_PATTERNS = [
40
+ /^get_/, /^list_/, /^check_/, /^lookup_/, /^reverse_/, /^probe_/, /^read_/,
41
+ /_status$/, /_info$/, /_quote$/, /_quote_/, /_models$/, /_guide$/, /_list$/,
42
+ /_balance$/, /_fees$/, /_utxos$/, /_history$/, /_holdings$/, /_overview$/,
43
+ /_summary$/, /_health$/, /_price$/, /_prices$/, /_state$/, /_positions$/,
44
+ /_breakdown$/, /_dashboard_/, /_stats$/, /_metadata$/, /_owner$/, /_holders$/,
45
+ /_search$/, /_feed$/, /_profile$/, /_pubkey$/, /_availability$/, /_count$/,
46
+ ];
47
+ const WRITE_VERB = /(transfer|send|swap|deploy|broadcast|buy|sell|pay|supply|withdraw|borrow|repay|create|delete|register|claim|deposit|stack|boost|unwind|invite|set_|sign|submit|enroll|opt_|file_|preorder|mint|inscribe|approve|revoke|connect|disconnect|rotate|export|import|unlock|lock|start|stop|join|settle|redeem|fund|accept|cancel|counter|append|give|add_|deactivate|record|compile|configure|heal|fill_gap|recover|rebalance|hunter|enable)/;
48
+ function isReadOnly(name) {
49
+ if (WRITE_VERB.test(name))
50
+ return false;
51
+ return READ_PATTERNS.some((p) => p.test(name));
52
+ }
53
+ /** MCP tool -> OpenRouter (OpenAI) function-tool schema (verbatim JSON Schema). */
54
+ function toOpenAITool(tool) {
55
+ // Clone so we never mutate the tool descriptor handed back by the MCP SDK.
56
+ const raw = tool.inputSchema ?? {};
57
+ const schema = { type: "object", properties: {}, ...raw };
58
+ return {
59
+ type: "function",
60
+ function: {
61
+ name: tool.name,
62
+ description: tool.description ?? "",
63
+ parameters: schema,
64
+ },
65
+ };
66
+ }
67
+ function parseArgs(argv) {
68
+ const opts = {
69
+ prompt: "",
70
+ model: process.env.OPENROUTER_MODEL || DEFAULT_MODEL,
71
+ network: process.env.NETWORK || "mainnet",
72
+ readOnly: false,
73
+ allow: new Set(),
74
+ block: new Set(),
75
+ maxTurns: 10,
76
+ listTools: false,
77
+ };
78
+ const promptWords = [];
79
+ const csv = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);
80
+ for (let i = 0; i < argv.length; i++) {
81
+ const a = argv[i];
82
+ // Value-taking flags must be followed by a value — otherwise the next loop
83
+ // iteration would silently consume the following flag (or leave the option
84
+ // unset, which for a spend cap means the rail is quietly disabled).
85
+ const value = (flag) => {
86
+ const v = argv[++i];
87
+ if (v === undefined || v.startsWith("--")) {
88
+ console.error(`Missing value for ${flag}`);
89
+ process.exit(1);
90
+ }
91
+ return v;
92
+ };
93
+ switch (a) {
94
+ case "--read-only":
95
+ opts.readOnly = true;
96
+ break;
97
+ case "--list-tools":
98
+ opts.listTools = true;
99
+ break;
100
+ case "--model":
101
+ opts.model = value(a);
102
+ break;
103
+ case "--network":
104
+ opts.network = value(a);
105
+ break;
106
+ case "--allow":
107
+ csv(value(a)).forEach((t) => opts.allow.add(t));
108
+ break;
109
+ case "--block":
110
+ csv(value(a)).forEach((t) => opts.block.add(t));
111
+ break;
112
+ case "--max-spend-ustx":
113
+ opts.maxSpendUstx = value(a);
114
+ break;
115
+ case "--max-spend-sats":
116
+ opts.maxSpendSats = value(a);
117
+ break;
118
+ case "--max-turns": {
119
+ const raw = value(a);
120
+ const n = parseInt(raw, 10);
121
+ if (!Number.isInteger(n) || n < 1) {
122
+ console.error(`--max-turns must be a positive integer (got "${raw}")`);
123
+ process.exit(1);
124
+ }
125
+ opts.maxTurns = n;
126
+ break;
127
+ }
128
+ default:
129
+ if (a.startsWith("--")) {
130
+ console.error(`Unknown flag: ${a}`);
131
+ process.exit(1);
132
+ }
133
+ promptWords.push(a);
134
+ }
135
+ }
136
+ opts.prompt = promptWords.join(" ").trim();
137
+ return opts;
138
+ }
139
+ /** Apply read-only + allow + block to the raw MCP tool list. */
140
+ function selectTools(tools, opts) {
141
+ return tools.filter((t) => {
142
+ if (opts.block.has(t.name))
143
+ return false;
144
+ if (opts.allow.has(t.name))
145
+ return true;
146
+ if (opts.readOnly)
147
+ return isReadOnly(t.name);
148
+ return true;
149
+ });
150
+ }
151
+ async function callOpenRouter(apiKey, model, messages, tools) {
152
+ const res = await fetch(OPENROUTER_URL, {
153
+ method: "POST",
154
+ headers: {
155
+ Authorization: `Bearer ${apiKey}`,
156
+ "Content-Type": "application/json",
157
+ "HTTP-Referer": "https://github.com/aibtcdev/aibtc-mcp",
158
+ "X-Title": "aibtc-mcp bridge",
159
+ },
160
+ body: JSON.stringify({ model, messages, tools, tool_choice: "auto" }),
161
+ });
162
+ if (!res.ok) {
163
+ throw new Error(`OpenRouter ${res.status}: ${await res.text()}`);
164
+ }
165
+ // OpenRouter can return HTTP 200 with an error payload (rate limit, moderation,
166
+ // provider failure) and no choices — surface it instead of crashing on [0].
167
+ const data = (await res.json());
168
+ const message = data.choices?.[0]?.message;
169
+ if (!message) {
170
+ const detail = data.error?.message ?? JSON.stringify(data);
171
+ throw new Error(`OpenRouter returned no message: ${detail}`);
172
+ }
173
+ return message;
174
+ }
175
+ export async function runBridge(argv) {
176
+ const opts = parseArgs(argv);
177
+ // Spawn this same binary in plain server mode (no extra args -> MCP server).
178
+ const serverEnv = {
179
+ ...process.env,
180
+ NETWORK: opts.network,
181
+ };
182
+ if (opts.maxSpendUstx)
183
+ serverEnv.SPEND_LIMIT_SESSION_USTX = opts.maxSpendUstx;
184
+ if (opts.maxSpendSats)
185
+ serverEnv.SPEND_LIMIT_SESSION_SATS = opts.maxSpendSats;
186
+ const transport = new StdioClientTransport({
187
+ command: process.execPath,
188
+ args: [process.argv[1]],
189
+ env: serverEnv,
190
+ });
191
+ const client = new Client({ name: "aibtc-openrouter-bridge", version: "1.0.0" });
192
+ await client.connect(transport);
193
+ const { tools: allTools } = await client.listTools();
194
+ const exposed = selectTools(allTools, opts);
195
+ if (opts.listTools) {
196
+ console.log(`Exposed ${exposed.length}/${allTools.length} tools` +
197
+ (opts.readOnly ? " (read-only)" : "") + ":");
198
+ for (const t of exposed)
199
+ console.log(` ${t.name}`);
200
+ await client.close();
201
+ return;
202
+ }
203
+ const apiKey = process.env.OPENROUTER_API_KEY;
204
+ if (!apiKey) {
205
+ console.error("Set OPENROUTER_API_KEY (or use --list-tools to preview without a model).");
206
+ await client.close();
207
+ process.exit(1);
208
+ }
209
+ if (!opts.prompt) {
210
+ console.error('Provide a prompt, e.g. aibtc-mcp-server bridge "what is the STX balance of SP3..."');
211
+ await client.close();
212
+ process.exit(1);
213
+ }
214
+ const writeCount = exposed.filter((t) => !isReadOnly(t.name)).length;
215
+ console.error(`Bridge: ${exposed.length} tools -> ${opts.model}` +
216
+ (opts.readOnly ? " [read-only]" : ` [${writeCount} can write/spend]`) +
217
+ (opts.maxSpendUstx || opts.maxSpendSats
218
+ ? ` (cap ${opts.maxSpendUstx ?? "-"} uSTX / ${opts.maxSpendSats ?? "-"} sats)`
219
+ : ""));
220
+ const openaiTools = exposed.map(toOpenAITool);
221
+ const messages = [
222
+ {
223
+ role: "system",
224
+ content: "You are an agent with access to aibtc-mcp tools for Stacks/Bitcoin and " +
225
+ "x402 actions. Use tools when needed. Be concise.",
226
+ },
227
+ { role: "user", content: opts.prompt },
228
+ ];
229
+ try {
230
+ let completed = false;
231
+ for (let turn = 0; turn < opts.maxTurns; turn++) {
232
+ const reply = await callOpenRouter(apiKey, opts.model, messages, openaiTools);
233
+ messages.push(reply);
234
+ const toolCalls = reply.tool_calls ?? [];
235
+ if (toolCalls.length === 0) {
236
+ console.log("\n" + (reply.content ?? ""));
237
+ completed = true;
238
+ break;
239
+ }
240
+ for (const call of toolCalls) {
241
+ const name = call.function.name;
242
+ // Enforce the allowlist at execution time too — never trust the model
243
+ // to stay inside the exposed set.
244
+ if (!exposed.some((t) => t.name === name)) {
245
+ messages.push({
246
+ role: "tool",
247
+ tool_call_id: call.id,
248
+ name,
249
+ content: `Tool ${name} is not permitted by the bridge.`,
250
+ });
251
+ continue;
252
+ }
253
+ let args = {};
254
+ try {
255
+ args = JSON.parse(call.function.arguments || "{}");
256
+ }
257
+ catch {
258
+ /* malformed JSON -> empty args */
259
+ }
260
+ console.error(`-> ${name}(${call.function.arguments || "{}"})`);
261
+ let content;
262
+ try {
263
+ const result = await client.callTool({ name, arguments: args });
264
+ content = (result.content ?? [])
265
+ .map((c) => (c?.type === "text" ? c.text : JSON.stringify(c)))
266
+ .join("\n");
267
+ }
268
+ catch (err) {
269
+ content = `Tool error: ${err.message}`;
270
+ }
271
+ messages.push({ role: "tool", tool_call_id: call.id, name, content });
272
+ }
273
+ }
274
+ // Don't let the cap swallow the session silently — the model was still
275
+ // calling tools when we ran out of turns.
276
+ if (!completed) {
277
+ console.error(`Bridge: reached --max-turns limit (${opts.maxTurns}) without a final response. ` +
278
+ "Re-run with a higher --max-turns to let it finish.");
279
+ }
280
+ }
281
+ finally {
282
+ await client.close();
283
+ }
284
+ }
285
+ //# sourceMappingURL=index.js.map
package/dist/index.js CHANGED
@@ -229,6 +229,16 @@ if (process.argv[2] === "yield-hunter") {
229
229
  process.exit(1);
230
230
  });
231
231
  }
232
+ // Check for bridge command (OpenRouter <-> MCP)
233
+ else if (process.argv[2] === "bridge") {
234
+ import("./bridge/index.js")
235
+ .then(({ runBridge }) => runBridge(process.argv.slice(3)))
236
+ .then(() => process.exit(0))
237
+ .catch((error) => {
238
+ console.error("❌ Bridge error:", redactSensitive(String(error)));
239
+ process.exit(1);
240
+ });
241
+ }
232
242
  // Check for --install flag
233
243
  else if (process.argv.includes("--install") || process.argv.includes("install")) {
234
244
  runInstall()
@@ -0,0 +1,45 @@
1
+ export type SpendUnit = "ustx" | "sats";
2
+ export declare class SpendLimitError extends Error {
3
+ readonly unit: SpendUnit;
4
+ readonly attempted: number;
5
+ readonly remaining: number;
6
+ readonly scope: "session" | "day";
7
+ constructor(message: string, unit: SpendUnit, attempted: number, remaining: number, scope: "session" | "day");
8
+ }
9
+ declare class SpendLimiter {
10
+ private static instance;
11
+ private stateFile;
12
+ private session;
13
+ private writeLock;
14
+ static getInstance(): SpendLimiter;
15
+ /** Test seam: redirect the state file. */
16
+ setStateFile(file: string): void;
17
+ private readState;
18
+ private writeState;
19
+ private getSession;
20
+ /** Reset the in-memory session ledger for a wallet (call on unlock/lock/switch). */
21
+ resetSession(addr: string): void;
22
+ /**
23
+ * Throw SpendLimitError if `amount` would push the session OR day total over
24
+ * its cap. Does NOT record — call recordSpend after a successful broadcast.
25
+ */
26
+ check(unit: SpendUnit, amount: bigint, addr: string): Promise<void>;
27
+ /** Record a completed spend against both the session and day ledgers. */
28
+ record(unit: SpendUnit, amount: bigint, addr: string): Promise<void>;
29
+ /** Remaining session/day budget for status reporting. */
30
+ status(addr: string): Promise<{
31
+ enabled: boolean;
32
+ ustx: {
33
+ sessionRemaining: number;
34
+ dailyRemaining: number;
35
+ };
36
+ sats: {
37
+ sessionRemaining: number;
38
+ dailyRemaining: number;
39
+ };
40
+ }>;
41
+ private message;
42
+ }
43
+ export declare function getSpendLimiter(): SpendLimiter;
44
+ export {};
45
+ //# sourceMappingURL=spend-limiter.d.ts.map
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Default-on cumulative spending limit for the wallet.
3
+ *
4
+ * The MCP server lets an LLM move real funds with no human click-through, so a
5
+ * single poisoned instruction (or a malicious x402 endpoint) could otherwise
6
+ * drain the wallet. This module is the safety rail: every outbound spend path
7
+ * (native STX transfer, BTC L1 transfer, x402/L402 auto-payments, Lightning
8
+ * pays) routes through it, and the limiter blocks once cumulative outflow would
9
+ * exceed a per-session OR per-day cap.
10
+ *
11
+ * Two independent ledgers are tracked — micro-STX (`ustx`) and satoshis
12
+ * (`sats`); there is no cross-asset USD normalization. Daily totals are
13
+ * persisted to ~/.aibtc/spend-state.json (keyed by wallet address + UTC day) so
14
+ * the cap survives process restarts; session totals live in memory and reset on
15
+ * wallet unlock/lock/switch.
16
+ *
17
+ * Exceeding the cap is the intended human-in-the-loop checkpoint: it blocks and
18
+ * surfaces the remaining budget + the env var to raise. An LLM cannot set env
19
+ * vars mid-session, so it cannot raise its own ceiling.
20
+ *
21
+ * Disable entirely with SPEND_LIMIT_ENABLED=false. Override caps with
22
+ * SPEND_LIMIT_DAILY_USTX / _SESSION_USTX / _DAILY_SATS / _SESSION_SATS.
23
+ */
24
+ import { promises as fs } from "fs";
25
+ import os from "os";
26
+ import path from "path";
27
+ const STORAGE_DIR = path.join(os.homedir(), ".aibtc");
28
+ const DEFAULT_STATE_FILE = path.join(STORAGE_DIR, "spend-state.json");
29
+ /**
30
+ * Parse a positive-integer env override, falling back (with a warning) on any
31
+ * invalid value — never silently disable a cap. Mirrors parseSatsCap in
32
+ * x402.service.ts.
33
+ */
34
+ function parseLimit(envName, fallback) {
35
+ const raw = process.env[envName];
36
+ if (raw === undefined || raw === "")
37
+ return fallback;
38
+ const parsed = Number(raw);
39
+ if (!Number.isFinite(parsed) || parsed <= 0) {
40
+ console.error(`[spend-limit] Invalid ${envName}="${raw}", falling back to ${fallback}`);
41
+ return fallback;
42
+ }
43
+ return Math.floor(parsed);
44
+ }
45
+ function isEnabled() {
46
+ return process.env.SPEND_LIMIT_ENABLED !== "false";
47
+ }
48
+ // Conservative defaults (~$30/day at the time of writing): tight enough to stop
49
+ // a drain, generous for the micro-payment use case (registry endpoints cost
50
+ // <=0.02 STX / 100 sats). Raise per wallet via env.
51
+ const DEFAULTS = {
52
+ dailyUstx: 10_000_000, // 10 STX
53
+ sessionUstx: 10_000_000, // 10 STX
54
+ dailySats: 50_000, // ~$30 BTC
55
+ sessionSats: 50_000,
56
+ };
57
+ function capsFor(unit) {
58
+ if (unit === "ustx") {
59
+ return {
60
+ daily: parseLimit("SPEND_LIMIT_DAILY_USTX", DEFAULTS.dailyUstx),
61
+ session: parseLimit("SPEND_LIMIT_SESSION_USTX", DEFAULTS.sessionUstx),
62
+ };
63
+ }
64
+ return {
65
+ daily: parseLimit("SPEND_LIMIT_DAILY_SATS", DEFAULTS.dailySats),
66
+ session: parseLimit("SPEND_LIMIT_SESSION_SATS", DEFAULTS.sessionSats),
67
+ };
68
+ }
69
+ /** UTC day bucket, e.g. "2026-06-11". */
70
+ function todayKey() {
71
+ return new Date().toISOString().slice(0, 10);
72
+ }
73
+ function unitLabel(unit) {
74
+ return unit === "ustx" ? "uSTX" : "sats";
75
+ }
76
+ export class SpendLimitError extends Error {
77
+ unit;
78
+ attempted;
79
+ remaining;
80
+ scope;
81
+ constructor(message, unit, attempted, remaining, scope) {
82
+ super(message);
83
+ this.unit = unit;
84
+ this.attempted = attempted;
85
+ this.remaining = remaining;
86
+ this.scope = scope;
87
+ this.name = "SpendLimitError";
88
+ }
89
+ }
90
+ class SpendLimiter {
91
+ static instance;
92
+ stateFile = DEFAULT_STATE_FILE;
93
+ // In-memory per-session totals, keyed by wallet address.
94
+ session = new Map();
95
+ writeLock = Promise.resolve();
96
+ static getInstance() {
97
+ if (!SpendLimiter.instance)
98
+ SpendLimiter.instance = new SpendLimiter();
99
+ return SpendLimiter.instance;
100
+ }
101
+ /** Test seam: redirect the state file. */
102
+ setStateFile(file) {
103
+ this.stateFile = file;
104
+ }
105
+ async readState() {
106
+ try {
107
+ const content = await fs.readFile(this.stateFile, "utf8");
108
+ return JSON.parse(content);
109
+ }
110
+ catch {
111
+ return {};
112
+ }
113
+ }
114
+ async writeState(state) {
115
+ const dir = path.dirname(this.stateFile);
116
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
117
+ const tmp = `${this.stateFile}.tmp`;
118
+ await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
119
+ await fs.rename(tmp, this.stateFile);
120
+ }
121
+ getSession(addr) {
122
+ let s = this.session.get(addr);
123
+ if (!s) {
124
+ s = { ustx: 0, sats: 0 };
125
+ this.session.set(addr, s);
126
+ }
127
+ return s;
128
+ }
129
+ /** Reset the in-memory session ledger for a wallet (call on unlock/lock/switch). */
130
+ resetSession(addr) {
131
+ this.session.delete(addr);
132
+ }
133
+ /**
134
+ * Throw SpendLimitError if `amount` would push the session OR day total over
135
+ * its cap. Does NOT record — call recordSpend after a successful broadcast.
136
+ */
137
+ async check(unit, amount, addr) {
138
+ if (!isEnabled())
139
+ return;
140
+ if (amount <= 0n)
141
+ return;
142
+ const amt = Number(amount);
143
+ const { daily, session } = capsFor(unit);
144
+ const sessionSpent = this.getSession(addr)[unit];
145
+ if (sessionSpent + amt > session) {
146
+ const remaining = Math.max(0, session - sessionSpent);
147
+ throw new SpendLimitError(this.message(unit, amt, remaining, "session", session), unit, amt, remaining, "session");
148
+ }
149
+ const state = await this.readState();
150
+ const daySpent = state[addr]?.[todayKey()]?.[unit] ?? 0;
151
+ if (daySpent + amt > daily) {
152
+ const remaining = Math.max(0, daily - daySpent);
153
+ throw new SpendLimitError(this.message(unit, amt, remaining, "day", daily), unit, amt, remaining, "day");
154
+ }
155
+ }
156
+ /** Record a completed spend against both the session and day ledgers. */
157
+ async record(unit, amount, addr) {
158
+ if (!isEnabled())
159
+ return;
160
+ if (amount <= 0n)
161
+ return;
162
+ const amt = Number(amount);
163
+ this.getSession(addr)[unit] += amt;
164
+ // Serialize read-modify-write to avoid lost updates under concurrency.
165
+ this.writeLock = this.writeLock.then(async () => {
166
+ const state = await this.readState();
167
+ const today = todayKey();
168
+ // Prune stale days so the file does not grow unbounded.
169
+ for (const a of Object.keys(state)) {
170
+ for (const day of Object.keys(state[a])) {
171
+ if (day !== today)
172
+ delete state[a][day];
173
+ }
174
+ if (Object.keys(state[a]).length === 0)
175
+ delete state[a];
176
+ }
177
+ if (!state[addr])
178
+ state[addr] = {};
179
+ if (!state[addr][today])
180
+ state[addr][today] = { ustx: 0, sats: 0 };
181
+ state[addr][today][unit] += amt;
182
+ await this.writeState(state);
183
+ });
184
+ await this.writeLock;
185
+ }
186
+ /** Remaining session/day budget for status reporting. */
187
+ async status(addr) {
188
+ const state = await this.readState();
189
+ const mk = (unit) => {
190
+ const { daily, session } = capsFor(unit);
191
+ const sessionSpent = this.session.get(addr)?.[unit] ?? 0;
192
+ const daySpent = state[addr]?.[todayKey()]?.[unit] ?? 0;
193
+ return {
194
+ sessionRemaining: Math.max(0, session - sessionSpent),
195
+ dailyRemaining: Math.max(0, daily - daySpent),
196
+ };
197
+ };
198
+ return { enabled: isEnabled(), ustx: mk("ustx"), sats: mk("sats") };
199
+ }
200
+ message(unit, attempted, remaining, scope, cap) {
201
+ const envVar = unit === "ustx"
202
+ ? scope === "day"
203
+ ? "SPEND_LIMIT_DAILY_USTX"
204
+ : "SPEND_LIMIT_SESSION_USTX"
205
+ : scope === "day"
206
+ ? "SPEND_LIMIT_DAILY_SATS"
207
+ : "SPEND_LIMIT_SESSION_SATS";
208
+ const label = unitLabel(unit);
209
+ return (`Spending limit reached: this ${attempted} ${label} transfer would exceed the ` +
210
+ `per-${scope} cap of ${cap} ${label} (${remaining} ${label} remaining). ` +
211
+ `This is a safety rail against draining the wallet. To allow a larger spend, ` +
212
+ `raise ${envVar} (or set SPEND_LIMIT_ENABLED=false to disable), then retry.`);
213
+ }
214
+ }
215
+ export function getSpendLimiter() {
216
+ return SpendLimiter.getInstance();
217
+ }
218
+ //# sourceMappingURL=spend-limiter.js.map
@@ -5,6 +5,7 @@ import { encrypt, decrypt, generateWalletId, initializeStorage, readWalletIndex,
5
5
  import { WalletError, WalletNotFoundError, InvalidPasswordError, InvalidMnemonicError, } from "../utils/errors.js";
6
6
  import { NETWORK } from "../config/networks.js";
7
7
  import { resetPendingNonce } from "../transactions/builder.js";
8
+ import { getSpendLimiter } from "./spend-limiter.js";
8
9
  import { deriveBitcoinAddress, deriveBitcoinKeyPair, deriveTaprootAddress, deriveTaprootKeyPair, deriveNostrKeyPair } from "../utils/bitcoin.js";
9
10
  /**
10
11
  * Wallet manager singleton - handles wallet creation, encryption, and session management
@@ -170,6 +171,8 @@ class WalletManager {
170
171
  await writeAppConfig(config);
171
172
  // Reset pending nonce counter so the new session re-syncs with the chain
172
173
  await resetPendingNonce(account.address);
174
+ // Fresh unlock starts a fresh per-session spend budget.
175
+ getSpendLimiter().resetSession(account.address);
173
176
  return account;
174
177
  }
175
178
  /**
@@ -180,6 +183,8 @@ class WalletManager {
180
183
  // Reset pending nonce counter before clearing session
181
184
  if (this.session?.account) {
182
185
  await resetPendingNonce(this.session.account.address);
186
+ // Clear the per-session spend budget so the next unlock starts fresh.
187
+ getSpendLimiter().resetSession(this.session.account.address);
183
188
  }
184
189
  // Zero out sensitive key buffers before dropping references
185
190
  if (this.session?.account) {
@@ -5,6 +5,7 @@ import { generateWallet, getStxAddress } from "@stacks/wallet-sdk";
5
5
  import { NETWORK, API_URL, getStacksNetwork } from "../config/networks.js";
6
6
  import { getNetworkFromStacksChainId } from "../config/caip.js";
7
7
  import { getWalletManager } from "./wallet-manager.js";
8
+ import { getSpendLimiter } from "./spend-limiter.js";
8
9
  import { formatStx, formatSbtc } from "../utils/formatting.js";
9
10
  import { getSbtcService } from "./sbtc.service.js";
10
11
  import { getHiroApi } from "./hiro-api.js";
@@ -365,6 +366,13 @@ export async function createApiClient(baseUrl, options) {
365
366
  `of ${L402_MAX_SATS_PER_INVOICE} sats (L402_MAX_SATS_PER_INVOICE). ` +
366
367
  `Refusing to pay.`));
367
368
  }
369
+ // Cumulative spending limit (sats ledger) — bounds a drain-by-loop of
370
+ // sub-cap L402 invoices. Keyed by the wallet's Stacks address so all sats
371
+ // spends (BTC L1, sBTC, L402) share one ledger.
372
+ const l402Addr = getWalletManager().getActiveAccount()?.address;
373
+ if (l402Addr) {
374
+ await getSpendLimiter().check("sats", BigInt(amountSats), l402Addr);
375
+ }
368
376
  // Pay the Lightning invoice.
369
377
  let payment;
370
378
  try {
@@ -373,6 +381,9 @@ export async function createApiClient(baseUrl, options) {
373
381
  catch (payErr) {
374
382
  return Promise.reject(new Error(`L402 payment failed: ${payErr instanceof Error ? payErr.message : String(payErr)}`));
375
383
  }
384
+ if (l402Addr) {
385
+ await getSpendLimiter().record("sats", BigInt(amountSats), l402Addr);
386
+ }
376
387
  cacheL402Auth(method, canonicalUrlKey, challenge.macaroon, payment.preimage);
377
388
  const originalRequest = error.config;
378
389
  originalRequest.headers = originalRequest.headers || {};
@@ -486,6 +497,10 @@ export async function createApiClient(baseUrl, options) {
486
497
  return Promise.reject(new Error(`Network mismatch: endpoint requires ${paymentNetwork} but wallet is configured for ${acct.network}. ` +
487
498
  `Switch to a ${paymentNetwork} wallet or use a ${acct.network} endpoint.`));
488
499
  }
500
+ // Cumulative spending limit: the per-payment cap above bounds a single
501
+ // 402, but a malicious endpoint can loop sub-cap payments. This blocks
502
+ // once the session/day total would be exceeded.
503
+ await getSpendLimiter().check(isSbtc ? "sats" : "ustx", amount, acct.address);
489
504
  // Invoke pre-payment callback (e.g. balance check) before signing/broadcasting.
490
505
  // If the callback throws, the payment is aborted and the error propagates to the caller.
491
506
  if (options?.onBeforePayment) {
@@ -561,6 +576,9 @@ export async function createApiClient(baseUrl, options) {
561
576
  action: "payment_submitted",
562
577
  compatShimUsed: false,
563
578
  });
579
+ // Record the spend against the cumulative ledger now that the signed
580
+ // payment tx is submitted to the facilitator.
581
+ await getSpendLimiter().record(isSbtc ? "sats" : "ustx", amount, acct.address);
564
582
  // Retry the original request with the payment header
565
583
  const originalRequest = error.config;
566
584
  originalRequest.headers = originalRequest.headers || {};
@@ -14,6 +14,7 @@ import { z } from "zod";
14
14
  import { NETWORK } from "../config/networks.js";
15
15
  import { createJsonResponse, createErrorResponse } from "../utils/index.js";
16
16
  import { getWalletManager } from "../services/wallet-manager.js";
17
+ import { getSpendLimiter } from "../services/spend-limiter.js";
17
18
  import { MempoolApi, getMempoolAddressUrl, getMempoolTxUrl, } from "../services/mempool-api.js";
18
19
  import { buildAndSignBtcTransaction } from "../transactions/bitcoin-builder.js";
19
20
  import { UnisatIndexer } from "../services/unisat-indexer.js";
@@ -251,6 +252,9 @@ export function registerBitcoinTools(server) {
251
252
  if (!account) {
252
253
  throw new Error("Wallet is not unlocked. Use wallet_unlock first to enable transactions.");
253
254
  }
255
+ // Safety rail: block before signing if this would exceed the wallet's
256
+ // cumulative spending limit (per-session or per-day).
257
+ await getSpendLimiter().check("sats", BigInt(amount), account.address);
254
258
  if (!account.btcAddress || !account.btcPrivateKey || !account.btcPublicKey) {
255
259
  throw new Error("Bitcoin keys not available. Please unlock your wallet again.");
256
260
  }
@@ -308,6 +312,7 @@ export function registerBitcoinTools(server) {
308
312
  }, account.btcPrivateKey);
309
313
  // Broadcast the transaction
310
314
  const txid = await api.broadcastTransaction(txResult.txHex);
315
+ await getSpendLimiter().record("sats", BigInt(amount), account.address);
311
316
  const response = {
312
317
  success: true,
313
318
  txid,
@@ -463,10 +463,11 @@ Fields:
463
463
  server.registerTool("news_file_signal", {
464
464
  description: `File a signal on a beat at aibtc.news.
465
465
 
466
- Requires an unlocked wallet with a P2WPKH (bc1q) BTC address and sBTC balance
467
- for the x402 payment. The tool handles the full payment flow:
468
- 1. POST with BIP-322 auth → receive 402 payment challenge
469
- 2. Build sponsored sBTC transfer (relay pays gas)
466
+ Filing is free — no payment required. Requires an unlocked wallet with a
467
+ P2WPKH (bc1q) BTC address for BIP-322 authentication. If the endpoint ever
468
+ returns a 402 payment challenge, the tool falls back to the x402 sBTC flow:
469
+ 1. POST with BIP-322 auth → signal filed (or receive 402 payment challenge)
470
+ 2. On 402: build sponsored sBTC transfer (relay pays gas)
470
471
  3. Retry with payment proof → signal filed
471
472
 
472
473
  Authentication: BIP-322 signed headers (X-BTC-Address, X-BTC-Signature, X-BTC-Timestamp).
@@ -3,6 +3,7 @@ import { hexToBytes } from "@stacks/common";
3
3
  import { getStacksNetwork, getApiBaseUrl } from "../config/networks.js";
4
4
  import { getHiroApi } from "../services/hiro-api.js";
5
5
  import { resolveDefaultFee } from "../utils/fee.js";
6
+ import { getSpendLimiter } from "../services/spend-limiter.js";
6
7
  import { getTrackedNonce, recordNonceUsed, resetTrackedNonce, reconcileWithChain, } from "../services/nonce-tracker.js";
7
8
  // ---------------------------------------------------------------------------
8
9
  // Pending nonce tracking (unified via SharedNonceTracker, issue #413)
@@ -82,6 +83,9 @@ export function advancePendingNonce(address, nonce, txid = "") {
82
83
  * @param fee Optional fee in micro-STX. If omitted, a medium-priority clamped fee is resolved.
83
84
  */
84
85
  export async function transferStx(account, recipient, amount, memo, fee) {
86
+ // Safety rail: block before signing if this would exceed the wallet's
87
+ // cumulative spending limit (per-session or per-day).
88
+ await getSpendLimiter().check("ustx", amount, account.address);
85
89
  const networkName = getStacksNetwork(account.network);
86
90
  const nonce = await getNextNonce(account.address, account.network);
87
91
  // Always resolve a clamped fee — prevents @stacks/transactions from over-estimating.
@@ -103,6 +107,7 @@ export async function transferStx(account, recipient, amount, memo, fee) {
103
107
  throw new Error(`Broadcast failed: ${broadcastResponse.error} - ${broadcastResponse.reason}`);
104
108
  }
105
109
  advancePendingNonce(account.address, nonce, broadcastResponse.txid);
110
+ await getSpendLimiter().record("ustx", amount, account.address);
106
111
  return {
107
112
  txid: broadcastResponse.txid,
108
113
  rawTx: transaction.serialize(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aibtc/mcp-server",
3
- "version": "1.59.0",
3
+ "version": "1.60.0",
4
4
  "description": "Bitcoin-native MCP server for AI agents: BTC/STX wallets, DeFi yield, sBTC peg, NFTs, and x402 payments.",
5
5
  "mcpName": "io.github.aibtcdev/mcp-server",
6
6
  "type": "module",
@@ -14,6 +14,7 @@
14
14
  "dev": "tsx src/index.ts",
15
15
  "test": "vitest run",
16
16
  "test:watch": "vitest",
17
+ "prepare": "git config core.hooksPath >/dev/null 2>&1 || git config core.hooksPath .githooks 2>/dev/null || true",
17
18
  "prepublishOnly": "npm run build"
18
19
  },
19
20
  "files": [
package/skill/SKILL.md CHANGED
@@ -4,7 +4,7 @@ description: Bitcoin L1 wallet for agents - check balances, send BTC, manage UTX
4
4
  license: MIT
5
5
  metadata:
6
6
  author: aibtcdev
7
- version: 1.59.0 # x-release-please-version
7
+ version: 1.60.0 # x-release-please-version
8
8
  npm: "@aibtc/mcp-server"
9
9
  github: https://github.com/aibtcdev/aibtc-mcp-server
10
10
  ---