diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fbe2018..c23f0d7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,6 +10,21 @@ "source": "./skills/blockrun", "description": "Start here. Pay-per-call access to AI models, real-time data, media generation and multi-chain RPC over x402 — which tool to reach for, how the wallet works, and how to get a first call working without spending anything." }, + { + "name": "blockrun-setup", + "source": "./skills/blockrun-setup", + "description": "Use when asked to install, add, or set up the BlockRun MCP server in Claude Code, Cursor, Codex or another client — the nvm/Homebrew PATH fix, first-run wallet, funding with USDC, profiles, and proving the install works." + }, + { + "name": "blockrun-debug", + "source": "./skills/blockrun-debug", + "description": "Use when BlockRun MCP is installed but misbehaving — Failed to connect, spawn npx ENOENT, HTTP 402, fetch failed, media timeouts, missing spend dialogs, or a Polymarket order failing after funding. Symptom → cause → fix, plus what never to do." + }, + { + "name": "blockrun-upgrade", + "source": "./skills/blockrun-upgrade", + "description": "Use when the server prints 'Update available', when asked to upgrade, pin or roll back @blockrun/mcp, when a fix should be in the new version but the client still runs the old one, or when refreshing skills copied into a project." + }, { "name": "exa-research", "source": "./skills/exa-research", diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd4245..ec38fec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ All notable changes to BlockRun MCP will be documented in this file. +## 0.43.0 + +**Every paid tool now asks before it spends.** Spend confirmation via MCP +elicitation (`BLOCKRUN_CONFIRM_SPEND=on`) shipped in 0.25.0 wired into +`blockrun_image` alone; the other thirteen paid tools — chat, video, music, +speech, realface, search, exa, markets, surf, defi, rpc, modal, phone — reserved +budget and signed the x402 payment without a word. Each now pauses at its budget +gate with the estimated charge, before the first request leaves the machine. A +decline sends nothing, charges nothing, and releases the reservation; free +paths (`chat mode:"free"`, crypto `price`, free `phone` and `realface` actions) +never prompt, and `blockrun_polymarket` keeps its own per-order `confirm:true`. +Still off by default, still fails open on clients that cannot render a dialog — +the README now says which can (Claude Code, Cursor, VS Code) and which cannot, +and `docs/spend-confirmation.md` says when to use it and when to use budgets +instead. `test/confirm-spend-coverage.test.ts` makes the coverage a guarantee +rather than a habit: every tool that reserves budget must confirm, and with a +declining client every one of them must leave `budget.spent` at zero and the +network untouched. That second check caught the first draft of this change, +which had placed nine of the confirms outside their `try` and leaked the +reservation on decline. + +**`blockrun-mcp skills list | install`.** The skills have shipped inside the npm +tarball for months with no way to use them short of cloning the repo or the +Claude Code plugin marketplace. `npx -y @blockrun/mcp@latest skills install` +copies them into `./.claude/skills`; `--global` targets `~/.claude/skills`, +`--to ~/.codex/skills` any other directory, `--only a,b` a subset, and +`--force` refreshes copies after an upgrade while leaving the user's other files +in those directories alone. It never deletes. + +**Three skills for the agent that runs the install, not the tool calls.** +`blockrun-setup` (the nvm/Homebrew `-e PATH="$PATH"` rule first, one command per +client, prove it with the free `blockrun_wallet` call, back up the key), +`blockrun-debug` (symptom → cause → fix for every Troubleshooting row, plus the +three things never to do: retry a 402, delete `~/.blockrun/.session`, or +suggest a Polymarket withdraw), and `blockrun-upgrade` (npx caches by spec, so +`@latest` restarts upgrade and a bare spec never does; how to prove the +version, what changed, how to roll back). Each was written against a baseline +run without it: the unaided agent reached for `nvm alias default` instead of +the PATH passthrough, described the key as keychain-only, and refreshed copied +skills with `npm pack` and `tar`. + +`BLOCKRUN_BUDGET_LIMIT`, which the server has honoured since 0.2x, finally +appears in the README configuration table alongside the two confirm variables. + ## 0.42.0 **`blockrun_video` now pays on Solana as well as Base.** The Solana route uses diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec733a5..93a8d07 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to BlockRun MCP -PRs welcome. This server is small (~4,400 lines of tool code + 8 skills); reviews are usually fast. +PRs welcome. This server is small (~4,400 lines of tool code + 16 skills); reviews are usually fast. ## Setup diff --git a/README.md b/README.md index 85eba41..4af0e18 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Every other data integration was built for **human developers** — create an ac - **No credit cards** — pay per request in USDC via [x402](https://x402.org), fractions of a cent each. - **Starts free** — the free tier (`blockrun_chat mode:"free"`, `blockrun_dex`, crypto `blockrun_price`, `blockrun_models`) costs $0. - **Reads *and* acts** — most tools deliver data; `blockrun_polymarket` places real, confirm-gated trades. +- **Human-in-the-loop payments** — turn on `BLOCKRUN_CONFIRM_SPEND=on` and the agent pauses before any paid call above your threshold; nothing is signed until you approve. [Details ↓](#%EF%B8%8F-human-in-the-loop-payments) - **Self-custody** — your key never leaves your machine (`~/.blockrun/.session`, `0600` — or the OS keychain once you opt into `BLOCKRUN_KEYCHAIN=strict`). BlockRun can't move your funds. --- @@ -75,6 +76,7 @@ Every other data integration was built for **human developers** — create an ac | **Place real bets** | Build it yourself | Rare | **Yes — Polymarket CLOB, confirm-gated** | | **Pay-chain** | — | — | **Base + Solana** | | **Agent budgets** | Manual | — | **Built-in per-agent delegation** | +| **Spend approval** | — | — | **Ask-before-pay dialog (MCP elicitation)** | | **Open source** | Varies | Varies | **Yes (MIT)** | ✓ One wallet · ✓ Pay-per-call · ✓ Reads **and** trades · ✓ Multi-chain · ✓ Agent-ready · ✓ Open source @@ -179,6 +181,19 @@ Prefer Solana? See [Fund your wallet](#fund-your-wallet) — two tool calls, no Claude reads the odds with `blockrun_markets` and — with your confirmation — places the trade with `blockrun_polymarket`. One wallet. Gasless. Confirm-gated. +### 5. Install the agent skills (optional) + +The package ships 16 skills — which tool answers what, worked examples, and a setup / debug / upgrade trio so the agent can install, troubleshoot and update the server on its own. + +```bash +/plugin marketplace add BlockRunAI/blockrun-mcp # Claude Code +npx -y @blockrun/mcp@latest skills install # any project → ./.claude/skills +npx -y @blockrun/mcp@latest skills install --global # ~/.claude/skills +npx -y @blockrun/mcp@latest skills install --to ~/.codex/skills +``` + +`skills list` shows what ships; `--only a,b` picks; `--force` refreshes copies after an upgrade. + --- ## Demo @@ -273,6 +288,39 @@ blockrun_polymarket action:"buy" token_id:"" amount_usd:5 order_type:"FOK" c --- +## 🛡️ Human-in-the-loop payments + +Turn on `BLOCKRUN_CONFIRM_SPEND=on` and **every paid tool pauses before it signs**. The server sends an MCP elicitation; your client renders it as a dialog with the estimated charge: + +``` +💸 BlockRun charge — video · bytedance/seedance-2.5 · 10s +Estimated: $2.6500 +Approve this spend? (USDC is debited per call.) +To stop the charge, choose Decline — Cancel/ESC lets it proceed. + +[ ] Approve all BlockRun charges for the rest of this session (don't ask again) + + [ Decline ] [ Approve ] +``` + +**Decline** → nothing is sent, nothing is charged, the tool reports *"Charge declined"*. **Approve** → the call proceeds. Tick the box and you're not asked again for the session. Free calls never prompt. Set `BLOCKRUN_CONFIRM_THRESHOLD=0.05` to only be asked above $0.05. + +```bash +claude mcp add blockrun -s user -e BLOCKRUN_CONFIRM_SPEND=on -e BLOCKRUN_CONFIRM_THRESHOLD=0.05 -- npx -y @blockrun/mcp@latest +``` + +| Client | Dialog | | Client | Dialog | +|---|---|---|---|---| +| Claude Code | ✅ | | Claude Desktop | ⚠️ renders; OK reports *cancel* → proceeds | +| Cursor | ✅ | | Windsurf | ❌ proceeds without asking | +| VS Code Copilot | ✅ | | Codex CLI · Gemini CLI | ❌ proceeds without asking | + +On a client that can't ask, the gate **fails open** — the call proceeds and the cost footer reports the charge. The hard stop on every client is the budget: `BLOCKRUN_BUDGET_LIMIT` for the process, `blockrun_wallet action:"delegate"` per sub-agent. `blockrun_polymarket` keeps its own, stronger per-order `confirm:true`. + +**📖 When to use it, sources for the matrix, limitations:** [`docs/spend-confirmation.md`](docs/spend-confirmation.md) + +--- + ## Fund your wallet Run `blockrun_wallet` to see your address. The server pays on **Base** by default. @@ -309,6 +357,7 @@ Then send USDC (SPL) on the **Solana** network — from Coinbase (pick "Solana") - **CRITICAL: `blockrun_music` and `blockrun_video` are payment-on-completion async.** Failures / client timeouts do NOT charge. Don't retry-loop — they may take 60–180s. - **CRITICAL: Before spawning child agents, allocate per-agent budget:** `blockrun_wallet action:"delegate" agent_id:"X" agent_limit:1.00`, then pass `agent_id:"X"` to every downstream call. The child is auto-blocked at zero. - **Free tier first for drafts:** `blockrun_chat mode:"free"` (NVIDIA), `blockrun_dex`, `blockrun_price` (crypto/FX/commodity), and `blockrun_models` are $0. +- **A declined spend confirmation is the user's decision.** Report it and stop — never re-issue the call with a cheaper model, smaller parameters, or split requests to get under their threshold. --- @@ -357,6 +406,9 @@ One wallet. All sources. No dashboards. | `SOLANA_WALLET_KEY` | unset | Env override of `.solana-session`. Set → use Solana. | | `BLOCKRUN_KEYCHAIN` | `auto` | Key storage. `auto` — mirror the key into the OS keychain (macOS Keychain / Linux `secret-tool`) and keep the plaintext file, which stays authoritative so other BlockRun tools keep working and so replacing it still rotates your wallet. `off` — file only. `strict` — also delete `~/.blockrun/.session` once a read-back proves the keychain holds the same key; **this breaks other tools that read that file directly**. | | `BLOCKRUN_MCP_PROFILE` | `full` | Tool profile (`media` / `trading` / `research` / `chat`). | +| `BLOCKRUN_BUDGET_LIMIT` | unset (unlimited) | Hard USD cap on x402 spend for this server process. In-memory; resets on restart. Per-agent caps via `blockrun_wallet action:"delegate"`. | +| `BLOCKRUN_CONFIRM_SPEND` | off | `on` — ask before every paid call via MCP elicitation. [Details](#%EF%B8%8F-human-in-the-loop-payments). Fails open on clients without elicitation. | +| `BLOCKRUN_CONFIRM_THRESHOLD` | `0` | Only ask for calls estimated above this many USD. Malformed values fall back to `0` (ask for everything), never to "off". | | `POLYMARKET_CLOB_HOST` | BlockRun Finland relay | Geoblock egress for order placement — **defaulted for you**. Override to go direct (`https://clob.polymarket.com`) or your own egress. | | `POLYMARKET_MAX_BET_USD` | `25` | Hard per-order notional cap. | | `POLYMARKET_MAX_SESSION_USD` | unset | Optional cumulative per-process betting cap. | @@ -375,6 +427,8 @@ The server runs a non-blocking npm registry check at startup and prints an `Upda ## Troubleshooting +> 🤖 Hand this to the agent: the [`blockrun-debug`](skills/blockrun-debug/SKILL.md) skill carries every row below as symptom → cause → fix, plus the diagnostics it can run itself. [`blockrun-setup`](skills/blockrun-setup/SKILL.md) and [`blockrun-upgrade`](skills/blockrun-upgrade/SKILL.md) cover the other two halves. Install: `npx -y @blockrun/mcp@latest skills install`. + - **`Insufficient balance` / HTTP 402 after retry** → Run `blockrun_wallet action:"setup"`, send USDC on Base (or Solana). - **`blockrun` doesn't connect / "MCP server failed" / `spawn npx ENOENT`** → Almost always a **PATH issue**: Claude Code can't find `node`/`npx` on its launcher PATH (common with Homebrew / nvm, on CLI *and* desktop). Fix by passing your shell PATH at install: ```bash @@ -385,6 +439,7 @@ The server runs a non-blocking npm registry check at startup and prints an `Upda - **`claude mcp list` doesn't show `blockrun`** → Check `node -v` (≥20.19). Clear the npx cache: `rm -rf ~/.npm/_npx`. Re-run the install. - **`fetch failed` / balance-check timeout** → Base RPC transient outage. The tool falls through 3 public RPCs; retry after 30s. Persistent = local proxy / firewall blocking outbound RPC. - **`Video`/`Music generation timed out`** → Upstream queue congestion. **No charge** (payment-on-completion). Retry, or pick a faster model. +- **No spend-confirmation dialog although `BLOCKRUN_CONFIRM_SPEND=on`** → Your client doesn't support MCP elicitation (Windsurf, Codex, Gemini CLI); the server proceeds without asking by design. Use `BLOCKRUN_BUDGET_LIMIT` as the guard, or a client from the [support table](#%EF%B8%8F-human-in-the-loop-payments). - **Polymarket: neg-risk ("winner") market buy fails, or `redeem` reverts, though setup shows ready** → Re-run `action:"setup" confirm:true` once (grants the on-chain approvals a pre-upgrade deposit wallet may lack — including the collateral-adapter approvals `redeem` needs). See the [setup guide](docs/polymarket-trading-setup.md). --- @@ -404,7 +459,10 @@ Pay-per-call — fractions of a cent to a few cents. The free tier (`blockrun_ch Yes. Your private key never leaves your machine (`~/.blockrun/.session` by default, `0600`). x402 payments and Polymarket orders are signed locally — BlockRun forwards signed payloads and cannot move your funds. **Which clients work?** -Claude Code, Claude Desktop, Cursor, Windsurf, and any MCP-compatible client. +Claude Code, Claude Desktop, Cursor, Windsurf, Codex CLI, and any MCP-compatible client. The spend-confirmation dialog needs a client with MCP elicitation — Claude Code, Cursor, VS Code; see the [support table](#%EF%B8%8F-human-in-the-loop-payments). + +**Can I make the agent ask before it spends?** +Yes — `BLOCKRUN_CONFIRM_SPEND=on`. Every paid tool pauses with the estimated charge and nothing is signed until you approve. [Human-in-the-loop payments ↑](#%EF%B8%8F-human-in-the-loop-payments) **Can it really place real bets?** Yes. `blockrun_polymarket` places real, USDC-settled orders on Polymarket's CLOB — confirm-gated and capped. Read the odds with `blockrun_markets`, place with `blockrun_polymarket`. diff --git a/docs/spend-confirmation.md b/docs/spend-confirmation.md new file mode 100644 index 0000000..098e7a7 --- /dev/null +++ b/docs/spend-confirmation.md @@ -0,0 +1,167 @@ +# Human-in-the-loop payments — spend confirmation + +BlockRun MCP can pause before every paid call and ask you, in your MCP client, whether to +go ahead. The server signs nothing until you approve. This document covers what the gate +does, when to use it, which clients can show the dialog, and what happens on those that +can't. + +Implementation: [`src/utils/confirm-spend.ts`](../src/utils/confirm-spend.ts). Every paid +tool calls it at its budget gate, before the first network request — enforced by +[`test/confirm-spend-coverage.test.ts`](../test/confirm-spend-coverage.test.ts). + +## What it does + +With confirmation on, a paid tool call goes: + +1. The tool validates its arguments and **estimates** the charge (the same estimate the + budget gate reserves). +2. If the estimate is above your threshold, the server sends an MCP **elicitation** request. + Your client renders it as a dialog: + + ``` + 💸 BlockRun charge — exa · search + Estimated: $0.0100 + Approve this spend? (USDC is debited per call.) + To stop the charge, choose Decline — Cancel/ESC lets it proceed. + + [ ] Approve all BlockRun charges for the rest of this session (don't ask again) + + [ Decline ] [ Approve ] + ``` + +3. **Decline** → the tool returns *"Charge declined — nothing was generated or charged."* + No request is sent, no payment is signed, and the budget reservation is released. +4. **Approve** → the call proceeds exactly as it would have without the gate. Tick the + checkbox and you will not be asked again for the lifetime of this server process — in + practice, this client session. + +Free calls never prompt: `blockrun_chat mode:"free"`, crypto/FX/commodity `blockrun_price`, +`blockrun_dex`, `blockrun_models`, `blockrun_wallet`, and the free `blockrun_phone` and +`blockrun_realface` actions. + +`blockrun_polymarket` is not behind this gate. Bets are real funds on Polygon, not x402 +fees, and they already require an explicit `confirm:true` on every order, approval and +redemption — a per-order contract that is stronger than a session-wide dialog. + +## Enable it + +Two environment variables, read once at server start: + +| Variable | Default | Effect | +|---|---|---| +| `BLOCKRUN_CONFIRM_SPEND` | off | `on` / `1` / `true` / `yes` turns the gate on. | +| `BLOCKRUN_CONFIRM_THRESHOLD` | `0` | Only ask for calls estimated **above** this many USD. `0` asks for every paid call. A value that isn't a plain positive number (`$0.05`, `5c`) falls back to `0`, i.e. ask for everything — it never silently disables the gate. | + +Claude Code: + +```bash +claude mcp remove blockrun -s user +claude mcp add blockrun -s user \ + -e BLOCKRUN_CONFIRM_SPEND=on \ + -e BLOCKRUN_CONFIRM_THRESHOLD=0.05 \ + -- npx -y @blockrun/mcp@latest +``` + +JSON-configured clients (Cursor, Claude Desktop, Windsurf): + +```json +{ + "mcpServers": { + "blockrun": { + "command": "npx", + "args": ["-y", "@blockrun/mcp@latest"], + "env": { "BLOCKRUN_CONFIRM_SPEND": "on", "BLOCKRUN_CONFIRM_THRESHOLD": "0.05" } + } + } +} +``` + +Restart the client after changing either variable. + +## When should I use this? + +**Use it when a person is in the loop and the calls are not all cheap.** The dialog costs a +few seconds of attention; a `blockrun_video` render costs up to $0.32 per second and a +`blockrun_phone` number costs $5. A threshold around `0.05` skips the fractions-of-a-cent +data calls and catches everything you would actually want to see. + +**Use it while you are learning what things cost.** Set the threshold to `0` for a session +and every paid call shows its estimate before it happens. Turn it back up once you trust +the agent's judgement. + +**Don't rely on it for unattended agents.** An autonomous pipeline has nobody to click +Approve, and on a client that can't render the dialog the call proceeds anyway (see +below). The unattended guard is the budget: `BLOCKRUN_BUDGET_LIMIT` caps the whole +process, and `blockrun_wallet action:"delegate" agent_id:"X" agent_limit:1.00` caps each +sub-agent. Those are enforced server-side regardless of client. + +**Don't stack it on a plugin that already gates spend.** If your Claude Code plugin runs a +`PreToolUse` hook that shows the cost and asks, turning this on too means two prompts per +call. Pick one — the hook is honoured on more clients; this gate works from the bare MCP +without a plugin. + +**Claude Code automation:** Claude Code lets you auto-answer elicitation requests with an +[`Elicitation` hook](https://code.claude.com/docs/en/hooks#elicitation). That is the way +to keep the gate on in scripted runs while still logging each proposed charge. + +## Client support + +The dialog is an MCP *elicitation* (form mode). Whether you see it depends entirely on the +client. Verified against each client's own documentation on 2026-08-29: + +| Client | Shows the dialog? | Notes | +|---|---|---| +| **Claude Code** | ✅ Yes | Form and URL elicitation are documented; dialogs "appear automatically when a server requests them". Auto-answer via the `Elicitation` hook. | +| **Cursor** | ✅ Yes | Elicitation is listed as **Supported** in Cursor's MCP feature table. | +| **VS Code (GitHub Copilot)** | ✅ Yes | Elicitation support landed in VS Code 1.102; URL-mode elicitation in 1.107. | +| **Claude Desktop** | ⚠️ Partial | Observed while building this feature: a form dialog renders, but confirming it reports `cancel` rather than `accept`, so the call proceeds. Only an explicit **Decline** stops the charge. | +| **Windsurf** | ❌ No | Windsurf documents support for tools, resources and prompts only. | +| **Codex CLI** | ❌ Not documented | OpenAI's MCP docs list tool calling and server instructions; elicitation is not mentioned. | +| **Gemini CLI** | ❌ Not documented | Tools, resources and prompts are documented; elicitation is not. | + +Sources: [Claude Code MCP docs](https://code.claude.com/docs/en/mcp) · +[Cursor MCP docs](https://cursor.com/docs/context/mcp) · +[VS Code 1.102](https://code.visualstudio.com/updates/v1_102) / +[1.107 release notes](https://code.visualstudio.com/updates/v1_107) · +[Windsurf MCP docs](https://docs.windsurf.com/windsurf/cascade/mcp) · +[Codex MCP docs](https://developers.openai.com/codex/mcp) · +[Gemini CLI MCP docs](https://geminicli.com/docs/tools/mcp-server/). +If your client has since added elicitation, please open an issue or PR and we'll update +the row. + +### What happens on a client that can't ask + +The gate **fails open**. The MCP handshake tells the server whether the client advertises +the `elicitation` capability: + +- Client doesn't advertise it → the call proceeds with no prompt. The tool result still + ends with the cost footer, so you see what was charged after the fact. +- Client advertises it but can't render the form (the request throws) → the call proceeds. +- Client returns `cancel` (the user hit ESC, or the client maps "OK" to cancel, as Claude + Desktop does) → the call proceeds. **Only `decline` stops the charge.** + +This is deliberate. On a client that can't ask, failing *closed* would make every paid +tool unusable, and the user would have no way to say yes. The budget caps above are the +hard stop; the dialog is the soft one. + +## Limitations + +- **The dialog shows the estimate, not the settled price.** For flat-priced tools they are + the same. For token-priced video (`blockrun_video` at 1080p / 4K) the 402 quote can come + in above the per-second estimate; the tool still re-checks the true amount against your + budget cap before paying, and the result footer reports the actual charge, but the + dialog is not re-shown for the difference. +- **"Approve all" is per process.** Each MCP client session spawns its own server, so the + latch resets whenever the client restarts. There is no way to persist it — set + `BLOCKRUN_CONFIRM_THRESHOLD` instead if you want fewer prompts permanently. +- **The threshold is read at startup.** Changing the env var needs a client restart. +- **Sub-agents share the latch.** A session-wide approval covers every call in that + server process, including ones a delegated `agent_id` makes. Use per-agent budgets to + bound them. + +## For agents + +If a spend confirmation is declined, that is the user's decision about that charge. Report +it and stop. Do not re-issue the call with different parameters, a cheaper model, or a +split into smaller requests to get under the threshold — the threshold is the user's, not +yours. diff --git a/package-lock.json b/package-lock.json index 72c79c8..6bac37f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blockrun/mcp", - "version": "0.42.0", + "version": "0.43.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/mcp", - "version": "0.42.0", + "version": "0.43.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/package.json b/package.json index ed01f70..b25eec0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/mcp", - "version": "0.42.0", + "version": "0.43.0", "mcpName": "io.github.BlockRunAI/blockrun-mcp", "description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, and crypto data. Paid via x402 micropayments.", "type": "module", diff --git a/skills/blockrun-debug/SKILL.md b/skills/blockrun-debug/SKILL.md new file mode 100644 index 0000000..39afa45 --- /dev/null +++ b/skills/blockrun-debug/SKILL.md @@ -0,0 +1,88 @@ +--- +name: blockrun-debug +description: "Use when the BlockRun MCP server (@blockrun/mcp) is installed but misbehaving — 'Failed to connect', spawn npx ENOENT, blockrun missing from claude mcp list, HTTP 402 / Insufficient balance, fetch failed, video or music timeouts, spend-confirmation dialogs not appearing, or a Polymarket buy/redeem failing after funding. Symptom → cause → fix, plus what never to do." +triggers: + - "blockrun failed to connect" + - "blockrun not working" + - "spawn npx ENOENT" + - "402 payment required" + - "insufficient balance" + - "blockrun 402" + - "fetch failed blockrun" + - "video generation timed out" + - "polymarket buy failed" + - "insufficient allowance" + - "redeem reverts" + - "debug blockrun" +--- + +# Debugging BlockRun MCP + +Find the row, run the fix, stop. Most "broken" reports are one of the first three rows. + +## Rules before touching anything + +1. **A 402 / "Insufficient balance" is a funding state, not a bug.** Call + `blockrun_wallet` first. Never retry the failing tool in a loop — it will 402 + identically every time and the failed attempts cost nothing, but they burn turns. +2. **Never delete or regenerate `~/.blockrun/.session`.** It is the only copy of the + key that holds the user's USDC and their Polymarket deposit wallet. There is no recovery. +3. **Never advise withdrawing from Polymarket to "start over".** Every post-funding + trade failure seen so far was a missing approval; a withdraw does not fix it and + costs a round trip. +4. Reproduce with the **free** call `blockrun_wallet` before anything paid. + +## Run these first (read-only, 10 seconds) + +```bash +node -v && which npx # runtime present, ≥ 20.19? +claude mcp get blockrun # registered command, env, AND scope (-s user / project / local) +npx -y @blockrun/mcp@latest --version # does the package itself run? (prints e.g. 0.43.0) +``` + +Use the scope `claude mcp get` reports in every `remove`/`add` below — the examples say +`-s user` because that is the documented install, but a project-scoped install +re-added at user scope leaves a duplicate. Then, in the session: `blockrun_wallet` +(free) → `blockrun_models` (free) → the tool that failed, **once**. + +## Symptom → cause → fix + +| Symptom | Cause | Fix | +|---|---|---| +| `claude mcp list` → `blockrun … ✗ Failed to connect`, or logs show `spawn npx ENOENT` | The client's launcher can't find `node`/`npx` — nvm, Homebrew, fnm, volta. Interactive shell has them; the spawner does not. | `claude mcp remove blockrun -s user` then `claude mcp add blockrun -s user -e PATH="$PATH" -- npx -y @blockrun/mcp@latest`. Quit and relaunch the client. Codex: `codex mcp add blockrun --env PATH="$PATH" -- …`. JSON clients (Claude Desktop / Cursor / Windsurf): set `"command"` to the output of `which npx`; file paths are in the `blockrun-setup` skill. | +| `blockrun` absent from `claude mcp list` | Wrong scope, or Node < 20.19, or a corrupt npx cache | `node -v` (≥ 20.19). `claude mcp get blockrun`. `rm -rf ~/.npm/_npx`. Re-run the install line. | +| Connects, but every paid tool → `HTTP 402` / `Insufficient balance` | Wallet is empty (or on the wrong chain for this tool) | `blockrun_wallet` → read balance + chain. `blockrun_wallet action:"setup"` → address + QR. Fund with USDC **on Base** (or switch to Solana). Then retry **once**. | +| 402 although balance shows funds | Chain mismatch: a Base-only tool (`music`, `speech`, `modal`, `defi`, paid `realface`, stock `price`, native `claude-*` chat) while active chain is Solana. Every other tool pays on either chain. | The error names it. `blockrun_wallet action:"chain" chain:"base"`. | +| `fetch failed` / balance-check timeout | Base RPC blip; the tool rotates through 3 public RPCs | Wait 30 s, retry once. Persistent → a local proxy/firewall is blocking outbound RPC. | +| `Video`/`Music generation timed out` | Upstream queue. **Not charged** — payment settles on completion only. | Retry, or pick a faster model. Do not retry-loop; jobs take 60–180 s. | +| Model id 404s | Delisted upstream | `blockrun_models` for the live list. | +| Startup prints `🚨 WALLET PRIVATE KEY DETECTED IN CONFIG FILE` | The key was pasted into `~/.claude.json` (old hosted-auth flow) | Treat the key as compromised: move funds to a new wallet, remove it from the config. | +| No spend-confirmation dialog with `BLOCKRUN_CONFIRM_SPEND=on` | Client doesn't support MCP elicitation (Windsurf, Codex, Gemini CLI) — the server proceeds without asking, by design | Use `BLOCKRUN_BUDGET_LIMIT` / `blockrun_wallet action:"delegate"` as the guard, or use Claude Code / Cursor / VS Code where the dialog renders. | +| Dialog appears, user clicks OK, tool says "declined" | Only an explicit **Decline** stops a charge; Cancel/ESC proceeds. If it says declined, Decline was pressed. | Re-run the call; approve it. | +| `Update available: vX → vY` on stderr | Informational | Switch to the `blockrun-upgrade` skill. | + +## Polymarket (`blockrun_polymarket`) + +| Symptom | Cause | Fix | +|---|---|---| +| Funded the deposit wallet, `buy` fails with `insufficient allowance` / neg-risk market rejects | The one-time gasless approval batch hasn't run, or predates an upgrade that added the NegRisk-adapter and collateral-adapter grants | `blockrun_polymarket action:"setup" confirm:true` **once** (idempotent, gasless, signs approvals — tell the user before running). Wait for it to report ready. Retry the buy. **Do not withdraw.** Still failing → check the signer row below: the funded wallet and the signing wallet must be the same address. | +| `redeem` reverts or redeems 0 | Same missing approvals (collateral adapter) | Same fix: `setup confirm:true`, then `redeem` again. | +| Order rejected by region | Order placement is geoblocked by IP. The MCP routes CLOB traffic via BlockRun's Finland egress by default. | Check `POLYMARKET_CLOB_HOST` was not overridden. `setup` prints the region status. | +| `setup` shows a different signer address than expected | Signer precedence: `BLOCKRUN_WALLET_KEY` env → agent `wallet.json` → `~/.blockrun/.session` | Unset the override, or fund the address `setup` actually prints. | + +The full walkthrough is `docs/polymarket-trading-setup.md` in the package repo. + +## Why "retried 5 times" happens and how to stop it + +A 402 is free — nothing settles — so a retry loop costs turns, not money, and no budget +cap will interrupt it. The stop is behavioural: the README's "For agents" block and the +`blockrun` skill both say *call `blockrun_wallet` first, never retry a 402 blindly*. +If a client keeps looping, install the skills so that rule is in context +(`npx -y @blockrun/mcp@latest skills install`). + +## Red flags — stop + +- You are about to retry a 402 a second time without checking the wallet. +- You are about to `rm` anything under `~/.blockrun/`. +- You are about to suggest a Polymarket `withdraw` to fix a failed order. +- You are about to test with `blockrun_video` or `blockrun_phone` — $0.30+/s and $5 numbers. diff --git a/skills/blockrun-setup/SKILL.md b/skills/blockrun-setup/SKILL.md new file mode 100644 index 0000000..69d8f0a --- /dev/null +++ b/skills/blockrun-setup/SKILL.md @@ -0,0 +1,122 @@ +--- +name: blockrun-setup +description: "Use when asked to install, add, configure, or set up the BlockRun MCP server (@blockrun/mcp) in Claude Code, Claude Desktop, Cursor, Windsurf, Codex CLI or another MCP client — including first-run wallet creation, funding with USDC, choosing a tool profile, and proving the install works. Also use when a fresh install 'doesn't show up' or a user asks how to pay for calls." +triggers: + - "install blockrun" + - "add blockrun mcp" + - "set up blockrun" + - "blockrun setup" + - "claude mcp add blockrun" + - "@blockrun/mcp" + - "fund my blockrun wallet" + - "how do I pay for blockrun" + - "blockrun profile" +--- + +# Installing BlockRun MCP + +One command per client, then one tool call to see the wallet. Do it in this order; the +PATH step is the one people skip and then spend an hour on. + +## 1. Check Node first + +```bash +node -v # must print v20.19 or newer +which npx +``` + +If `node` is from **nvm, Homebrew, fnm, volta or asdf**, assume the client's launcher +will NOT find it. GUI-launched apps and Claude Code's MCP spawner do not source your +shell profile. The fix is to pass your shell's PATH through at install time — not to +pin `nvm alias default`, not to edit `.zshrc`, not to symlink node into `/usr/local/bin`. + +## 2. Install — pick the client + +**Claude Code** (recommended; `-s user` = every project): + +```bash +claude mcp add blockrun -s user -e PATH="$PATH" -- npx -y @blockrun/mcp@latest +``` + +The `--` matters: it stops `-y` being parsed by `claude mcp add`. The `-e PATH="$PATH"` +is the nvm/Homebrew fix from step 1; it is harmless on a system Node, so always include it. + +**Codex CLI** (`--env` is Codex's equivalent of `-e`; config lands in `~/.codex/config.toml`): + +```bash +codex mcp add blockrun --env PATH="$PATH" -- npx -y @blockrun/mcp@latest +``` + +**Claude Desktop / Cursor / Windsurf** — JSON, in the client's MCP config file: + +```json +{ "mcpServers": { "blockrun": { "command": "npx", "args": ["-y", "@blockrun/mcp@latest"] } } } +``` + +| Client | File | +|---|---| +| Claude Desktop | `claude_desktop_config.json` (Settings → Developer → Edit Config) | +| Cursor | `~/.cursor/mcp.json` · Windows `%APPDATA%\Cursor\mcp.json` | +| Windsurf | `~/.codeium/windsurf/mcp_config.json` · Linux `~/.config/.codeium/windsurf/mcp_config.json` | + +For a JSON client with nvm/Homebrew Node, put the absolute `npx` path (`which npx`) in +`command` — there is no `-e PATH` equivalent there. + +**Optional flags** (append after `@latest`): `--profile trading|research|media|chat` +exposes a smaller tool set so the client loads fewer schemas. Omit for all 20 tools. + +**Optional env** (`-e KEY=value` on Claude Code, `"env": {}` in JSON): +`BLOCKRUN_CONFIRM_SPEND=on` asks before each paid call on clients that support MCP +elicitation; `BLOCKRUN_CONFIRM_THRESHOLD=0.05` limits that to calls above $0.05; +`BLOCKRUN_BUDGET_LIMIT=5` hard-caps the process at $5. + +## 3. Restart, then prove it + +Restart the client — for Claude Code that means quit and relaunch `claude`; a running +session does not pick up a new server. Then: + +```bash +claude mcp list # expect: blockrun: npx -y @blockrun/mcp@latest - ✓ Connected +claude mcp get blockrun # confirms the scope and that PATH / any -e vars were captured +``` + +Inside the session, call **`blockrun_wallet`** with no arguments. It prints the wallet +address, chain, and balance. That call is free and needs no funds — if it returns, the +install works. Do not "test" with a paid tool. + +## 4. The wallet and how paying works + +- The server **creates a wallet on first run**: an EVM key in `~/.blockrun/.session` + (`0600`). On macOS/Linux it is also mirrored into the OS keychain, but the file stays + authoritative unless the user opts into `BLOCKRUN_KEYCHAIN=strict`. Tell the user to + **back that file up** — it is the only copy of the key; BlockRun cannot recover it. +- Payment is **USDC per call over x402**, on **Base** by default. There is no account, + API key, or card. Free tools (`blockrun_wallet`, `blockrun_models`, `blockrun_dex`, + crypto `blockrun_price`, `blockrun_chat mode:"free"`) work with a $0 balance. +- To fund: `blockrun_wallet action:"setup"` shows the address and a QR. Send USDC on the + **Base** network (Coinbase → Send → USDC → Base). $5 covers hundreds of data calls. +- Prefer Solana: `blockrun_wallet action:"chain" chain:"solana"` then `action:"setup"`. + Send USDC (SPL) on Solana. No restart. A few media/paid tools are Base-only and say so. + +## 5. Optional: install the agent skills + +The package ships 16 skills (which tool to use, worked examples, this one). Claude Code +users: `/plugin marketplace add BlockRunAI/blockrun-mcp`. Everyone else: + +```bash +npx -y @blockrun/mcp@latest skills install # → ./.claude/skills +npx -y @blockrun/mcp@latest skills install --global # → ~/.claude/skills +npx -y @blockrun/mcp@latest skills install --to ~/.codex/skills +``` + +## Common mistakes + +| Mistake | Why it bites | +|---|---| +| Omitting `-e PATH="$PATH"` on nvm/Homebrew | `spawn npx ENOENT` / "Failed to connect" — the #1 support issue | +| Omitting `--` before `npx` | `-y` is eaten by `claude mcp add`; npx then prompts and hangs | +| Testing with a paid tool | You cannot tell "unfunded" from "broken". Use `blockrun_wallet`. | +| Sending USDC on Ethereum mainnet | Wrong network; the Base address is the same string but the funds are elsewhere. Say "Base". | +| Funding inside a sandboxed client (Claude Desktop / Cowork / Web bash) | The wallet lives in the sandbox and dies with it. Test: if `~/.blockrun/.session` is gone in the next session, you are sandboxed — install on the user's real machine. | + +If the install is done and something still fails, switch to the `blockrun-debug` skill. diff --git a/skills/blockrun-upgrade/SKILL.md b/skills/blockrun-upgrade/SKILL.md new file mode 100644 index 0000000..6069f61 --- /dev/null +++ b/skills/blockrun-upgrade/SKILL.md @@ -0,0 +1,141 @@ +--- +name: blockrun-upgrade +description: "Use when the BlockRun MCP server prints 'Update available', when asked to upgrade, update, or pin @blockrun/mcp, when a fix 'should be in the new version' but the client still runs the old one, when refreshing skills that were copied into a project, or when rolling back to a previous version." +triggers: + - "update available blockrun" + - "upgrade blockrun" + - "update blockrun mcp" + - "blockrun new version" + - "still on old version" + - "pin blockrun version" + - "rollback blockrun" + - "update blockrun skills" +--- + +# Upgrading BlockRun MCP + +The server is run by `npx`, so "upgrading" means making npx fetch a newer version and +the client restart into it. Which of those two is the problem depends on how it was +installed. + +## 1. Read how it was installed + +```bash +claude mcp get blockrun # registered command + args + env + scope +npm view @blockrun/mcp version # what "latest" is on the registry right now +``` + +| Registered spec | What happens at each client start | +|---|---| +| `npx -y @blockrun/mcp@latest` | npx re-resolves `latest` against the registry and downloads if newer. **A client restart is the upgrade.** | +| `npx -y @blockrun/mcp` (no tag) | npx reuses whatever is in `~/.npm/_npx` — the version from the day it was installed. Restarting changes nothing. | +| `npx -y @blockrun/mcp@0.39.2` | Pinned on purpose. Only changes if you re-register. | + +The startup notice is the server telling you it is not on `latest`: + +``` +[BlockRun] Update available: v0.39.2 → v0.42.0 +[BlockRun] Run: claude mcp add blockrun -s user -- npx -y @blockrun/mcp@latest +``` + +## 2. Upgrade + +**Registered with `@latest`:** restart the client. Done. If the notice persists, the cache +is stale or the registry was unreachable at start: + +```bash +rm -rf ~/.npm/_npx # safe — it is only a download cache +``` + +then restart again. + +**Registered without a tag, or pinned:** re-register with `@latest`. `claude mcp add` +refuses a name that already exists, so remove first — at the scope `claude mcp get` +reported — and carry every env var and flag it printed across, one `-e` each, flags +after the package: + +```bash +claude mcp remove blockrun -s user +claude mcp add blockrun -s user \ + -e PATH="$PATH" -e BLOCKRUN_KEYCHAIN=auto -e BLOCKRUN_CONFIRM_SPEND=on \ + -- npx -y @blockrun/mcp@latest --profile trading +``` + +Codex: `codex mcp remove blockrun` then `codex mcp add blockrun -- npx -y @blockrun/mcp@latest`. +JSON clients (Claude Desktop / Cursor / Windsurf): edit `args` to `["-y", "@blockrun/mcp@latest"]`. + +Then quit and relaunch the client (Claude Code: `/mcp reconnect blockrun` respawns just +this server). A running session keeps the old process until you do. + +## 3. Prove the version + +```bash +npx -y @blockrun/mcp@latest --version # what npx would launch now, e.g. 0.42.0 +``` + +That proves what npx *would* launch. What the client *is* running is the server's +startup line on stderr — `BlockRun MCP Server started (v0.42.0) — stdio transport — 20 +tools` (Claude Code: `claude --debug` or the MCP log; `/mcp` shows connection status, not +the version). If the two disagree, the client was not restarted; the stderr line wins. +No `Update available` line after it = you are current. + +## 4. What can break + +Read the **CHANGELOG** for the versions you are crossing: +. Each entry +explains the behaviour change, not just the diff. Things that have changed across +versions and are worth checking: + +- **Tool set / profiles** — `--profile` names and tool membership; a trimmed profile + may gain or lose a tool. +- **Env-var semantics** — the kind of change to look for: `BLOCKRUN_KEYCHAIN` arrived in + 0.41; `BLOCKRUN_CONFIRM_SPEND` widened from one tool to all paid tools in 0.43. Read + the entries for the versions *you* cross and re-check `claude mcp get blockrun` env + against the README Configuration table. +- **Wallet file precedence** — `BLOCKRUN_WALLET_KEY` → agent `wallet.json` → + `~/.blockrun/.session`. An upgrade never moves or rewrites the key; if a balance + "disappears", the signer changed, not the funds. `blockrun_wallet` prints the address. +- **Polymarket approvals** — new adapter grants are added over time. After an upgrade, + `blockrun_polymarket action:"setup" confirm:true` once before trading: idempotent and + gasless, but it signs on-chain approvals, so say so to the user before running it. + +Your USDC and keys are in `~/.blockrun/`, not in the package. Upgrading cannot lose them. + +## 5. Refresh copied skills + +Skills copied into a project or user directory do **not** update with the package. +Marketplace-installed skills (`/plugin marketplace add BlockRunAI/blockrun-mcp`) +refresh with `/plugin marketplace update blockrun-mcp` followed by `/reload-plugins` +(or uninstall + `/plugin install @blockrun-mcp`). For copied ones, re-run the install with `--force` — it overwrites +the files the package ships and leaves any other files in those directories alone: + +```bash +ls .claude/skills ~/.claude/skills 2>/dev/null # where were they copied? +npx -y @blockrun/mcp@latest skills list # what this version ships +npx -y @blockrun/mcp@latest skills install --force # ./.claude/skills +npx -y @blockrun/mcp@latest skills install --global --force # ~/.claude/skills +``` + +`--force` replaces the shipped files. If the user edited their copies, diff before +overwriting: `skills install --to /tmp/br-skills` and `diff -r /tmp/br-skills .claude/skills`. + +## Rollback + +Pin the previous version and restart: + +```bash +claude mcp remove blockrun -s user +claude mcp add blockrun -s user -e PATH="$PATH" -- npx -y @blockrun/mcp@0.41.1 +``` + +Versions: . Report what +broke at with the two version numbers. + +## Common mistakes + +| Mistake | Reality | +|---|---| +| `npm install -g @blockrun/mcp` to upgrade | The client runs `npx`, which ignores the global install. Re-register instead. | +| Editing `~/.npm/_npx/**/package.json` | It is a cache. Delete it, don't edit it. | +| Restarting only the terminal | The MCP process belongs to the client session. Restart the client (or `/mcp` reconnect in Claude Code). | +| Assuming the skills upgraded too | They are files you copied. `skills install --force`. | diff --git a/src/cli/skills.ts b/src/cli/skills.ts new file mode 100644 index 0000000..f6ee540 --- /dev/null +++ b/src/cli/skills.ts @@ -0,0 +1,258 @@ +// src/cli/skills.ts +// +// `blockrun-mcp skills list | install` — copy the skills that ship inside the +// npm tarball into a project or user skills directory. +// +// The skills have been in the package since `files: ["skills"]` was added, and +// Claude Code users get them via `/plugin marketplace add BlockRunAI/blockrun-mcp`. +// Everyone else — Codex (~/.codex/skills), Cursor, a CI image, a project that +// wants them checked in under .claude/skills — had no path short of cloning +// the repo. This is that path. +// +// Pure core (installSkills / listSkills / resolveSkillsTarget) takes explicit +// paths so tests run against tmp dirs; the argv shim (runSkillsCli) is what +// index.ts calls before it would otherwise start the stdio server. + +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Where the shipped skills live. This module runs from two places: src/cli/ + * under tsx (skills/ is two levels up) and dist/index.js under npx, where tsup + * has bundled it (skills/ is one level up). Rather than hard-code either, walk + * up from the module until the directory that holds package.json AND skills/ + * — the package root — is found. + */ +export const SKILLS_SOURCE_DIR = locateSkillsDir(fileURLToPath(import.meta.url)); + +function locateSkillsDir(start: string): string { + let dir = dirname(start); + for (let i = 0; i < 5; i++) { + if (existsSync(join(dir, "package.json")) && existsSync(join(dir, "skills"))) return join(dir, "skills"); + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + // Fall back to the bundled layout so the error message names a real path. + return join(dirname(dirname(start)), "skills"); +} + +export interface SkillInfo { + name: string; + description: string; +} + +/** Skills = subdirectories carrying a SKILL.md. Sorted for stable output. */ +export function listSkills(from: string): SkillInfo[] { + if (!existsSync(from)) return []; + return readdirSync(from) + .filter((d) => { + const p = join(from, d); + return statSync(p).isDirectory() && existsSync(join(p, "SKILL.md")); + }) + .sort() + .map((name) => ({ name, description: readDescription(join(from, name, "SKILL.md")) })); +} + +// The first `description:` scalar in the frontmatter, single-line form only. +// Multi-line (`description: |`) descriptions fall back to the first non-empty +// continuation line. Display-only — the authoritative parse is the client's. +function readDescription(skillMd: string): string { + const text = readFileSync(skillMd, "utf8"); + const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!fm) return ""; + const lines = fm[1].split(/\r?\n/); + const i = lines.findIndex((l) => /^description:/.test(l)); + if (i < 0) return ""; + const inline = lines[i].replace(/^description:\s*/, "").replace(/^["']|["']$/g, "").trim(); + if (inline && inline !== "|" && inline !== ">") return inline; + const next = lines.slice(i + 1).find((l) => l.trim().length > 0); + return (next ?? "").trim(); +} + +export interface InstallOptions { + from: string; + to: string; + only?: string[]; + force?: boolean; +} + +export interface InstallResult { + installed: string[]; + skipped: string[]; + to: string; +} + +/** + * Copy each skill directory from `from` into `to//`. An existing + * destination is skipped unless `force`; with `force`, files WE ship are + * overwritten and anything else the user put there is left alone (cpSync + * merges, it does not replace the directory). + */ +export function installSkills(opts: InstallOptions): InstallResult { + const from = resolve(opts.from); + const to = resolve(opts.to); + const rel = relative(from, to); + if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) { + throw new Error(`Refusing to install into "${to}": it is inside the source directory "${from}".`); + } + + const available = listSkills(from); + const byName = new Map(available.map((s) => [s.name, s])); + let selected = available.map((s) => s.name); + if (opts.only && opts.only.length > 0) { + const unknown = opts.only.filter((n) => !byName.has(n)); + if (unknown.length > 0) { + throw new Error(`Unknown skill${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. Available: ${selected.join(", ")}`); + } + selected = selected.filter((n) => opts.only!.includes(n)); + } + + mkdirSync(to, { recursive: true }); + const installed: string[] = []; + const skipped: string[] = []; + for (const name of selected) { + const dest = join(to, name); + if (existsSync(dest) && !opts.force) { + skipped.push(name); + continue; + } + cpSync(join(from, name), dest, { recursive: true, force: true, errorOnExist: false }); + installed.push(name); + } + return { installed, skipped, to }; +} + +export interface SkillsArgs { + cmd: "list" | "install" | "help"; + to?: string; + global: boolean; + force: boolean; + only?: string[]; +} + +/** Parse everything after the `skills` word. Unknown subcommand → help. */ +export function parseSkillsArgs(argv: string[]): SkillsArgs { + const out: SkillsArgs = { cmd: "help", global: false, force: false, only: undefined, to: undefined }; + const [first, ...rest] = argv; + if (first === "list" || first === "install") out.cmd = first; + else return out; + + for (let i = 0; i < rest.length; i++) { + const a = rest[i]; + if (a === "--global" || a === "-g") out.global = true; + else if (a === "--force" || a === "-f") out.force = true; + else if (a === "--to") { + const v = rest[++i]; + if (!v || v.startsWith("--")) throw new Error("--to requires a directory path"); + out.to = v; + } else if (a.startsWith("--to=")) out.to = a.slice("--to=".length); + else if (a === "--only") { + const v = rest[++i]; + if (!v || v.startsWith("--")) throw new Error("--only requires a comma-separated list of skill names"); + out.only = splitList(v); + } else if (a.startsWith("--only=")) out.only = splitList(a.slice("--only=".length)); + else if (a === "--help" || a === "-h") out.cmd = "help"; + else throw new Error(`Unknown option for "skills ${first}": ${a}`); + } + return out; +} + +function splitList(v: string): string[] { + return v.split(",").map((s) => s.trim()).filter(Boolean); +} + +/** + * Destination precedence: explicit --to (with ~ expansion, relative to cwd) + * → --global (~/.claude/skills, where Claude Code loads personal skills) → + * project ./.claude/skills. + */ +export function resolveSkillsTarget( + opts: { to?: string; global?: boolean }, + cwd: string = process.cwd(), + home: string = homedir(), +): string { + if (opts.to) { + const expanded = opts.to === "~" ? home : opts.to.startsWith(`~${sep}`) || opts.to.startsWith("~/") ? join(home, opts.to.slice(2)) : opts.to; + return isAbsolute(expanded) ? expanded : join(cwd, expanded); + } + return opts.global ? join(home, ".claude", "skills") : join(cwd, ".claude", "skills"); +} + +export function skillsUsage(): string { + return [ + "Usage:", + " blockrun-mcp skills list Show the skills shipped in this package", + " blockrun-mcp skills install [options] Copy them into a skills directory", + "", + "Options (install):", + " --to Destination directory (default: ./.claude/skills)", + " -g, --global Install to ~/.claude/skills instead of the project", + " --only a,b Install only these skills", + " -f, --force Overwrite skills that are already there", + "", + "Examples:", + " npx -y @blockrun/mcp@latest skills install", + " npx -y @blockrun/mcp@latest skills install --global", + " npx -y @blockrun/mcp@latest skills install --to ~/.codex/skills", + " npx -y @blockrun/mcp@latest skills install --only blockrun,blockrun-setup,blockrun-debug", + "", + "Claude Code users can instead run: /plugin marketplace add BlockRunAI/blockrun-mcp", + "", + ].join("\n"); +} + +/** + * Entry point for `blockrun-mcp skills …`. Returns the process exit code; + * writes to stdout/stderr directly. Never starts the MCP server. + */ +export function runSkillsCli(argv: string[], io: { out: (s: string) => void; err: (s: string) => void } = { + out: (s) => process.stdout.write(s), + err: (s) => process.stderr.write(s), +}): number { + let args: SkillsArgs; + try { + args = parseSkillsArgs(argv); + } catch (e) { + io.err(`${(e as Error).message}\n\n${skillsUsage()}`); + return 2; + } + + if (args.cmd === "help") { + io.out(skillsUsage()); + return argv.length === 0 || argv[0] === "--help" || argv[0] === "-h" ? 0 : 2; + } + + const skills = listSkills(SKILLS_SOURCE_DIR); + if (skills.length === 0) { + io.err(`No skills found at ${SKILLS_SOURCE_DIR} — the package may be corrupted. Reinstall with: npx -y @blockrun/mcp@latest\n`); + return 1; + } + + if (args.cmd === "list") { + const width = Math.max(...skills.map((s) => s.name.length)); + io.out(`${skills.length} skills in ${SKILLS_SOURCE_DIR}\n\n`); + for (const s of skills) { + // One line each; the full text is in the SKILL.md frontmatter. + const d = s.description.length > 110 ? `${s.description.slice(0, 109).trimEnd()}…` : s.description; + io.out(` ${s.name.padEnd(width)} ${d}\n`); + } + io.out(`\nInstall with: blockrun-mcp skills install [--global | --to ]\n`); + return 0; + } + + const to = resolveSkillsTarget(args); + try { + const r = installSkills({ from: SKILLS_SOURCE_DIR, to, only: args.only, force: args.force }); + for (const n of r.installed) io.out(` installed ${join(to, n)}\n`); + for (const n of r.skipped) io.out(` skipped ${join(to, n)} (exists — use --force to overwrite)\n`); + io.out(`\n${r.installed.length} installed, ${r.skipped.length} skipped → ${to}\n`); + if (r.installed.length > 0) io.out(`Restart your client (or start a new session) so it picks the skills up.\n`); + return 0; + } catch (e) { + io.err(`${(e as Error).message}\n`); + return 1; + } +} diff --git a/src/index.ts b/src/index.ts index 60b505c..e2f90f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { initializeMcpServer } from "./mcp-handler.js"; import { warnOnLeakedKeys } from "./utils/key-leak-scanner.js"; import { installBlockrunMcpUserAgent } from "./utils/user-agent.js"; import { PROFILES } from "./profiles.js"; +import { runSkillsCli } from "./cli/skills.js"; // Read version from package.json so it can never drift from the published version. const { version: VERSION } = JSON.parse( @@ -28,13 +29,17 @@ function printHelp(): void { "", "Usage:", " blockrun-mcp [options]", + " blockrun-mcp skills list | install [--global | --to ] [--only a,b] [--force]", "", "Options:", " -h, --help Show this help message", " -v, --version Print the package version", ` --profile Tool profile to expose: ${Object.keys(PROFILES).join(" | ")} (default: full)`, "", - "When no metadata flag is provided, the server starts on stdio for MCP clients.", + "Commands:", + " skills List or install the bundled agent skills (run `skills --help`)", + "", + "When no metadata flag or command is provided, the server starts on stdio for MCP clients.", "", ].join("\n"), ); @@ -43,6 +48,12 @@ function printHelp(): void { function handleCliMetadataFlags(argv: string[]): void { const args = argv.slice(2); + // Subcommands run and exit; they never start the stdio server. Checked + // first so `skills install --help` shows the skills usage, not the server's. + if (args[0] === "skills") { + process.exit(runSkillsCli(args.slice(1))); + } + if (args.includes("--version") || args.includes("-v")) { process.stdout.write(`${VERSION}\n`); process.exit(0); diff --git a/src/tools/chat.ts b/src/tools/chat.ts index 7938731..0308e56 100644 --- a/src/tools/chat.ts +++ b/src/tools/chat.ts @@ -19,6 +19,7 @@ import { type RoutingMode, } from "../utils/constants.js"; import { reserveBudget, recordActualSpend } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import type { ApiClient } from "../utils/wallet.js"; import type { BudgetState } from "../types.js"; @@ -270,6 +271,11 @@ Run blockrun_models to see all available models with pricing.`, }; } try { + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: estimatedCost, label: `chat · ${model ?? mode ?? "auto"}` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; // Native Anthropic passthrough (EVM/Base only). // An explicit anthropic/claude-* model goes DIRECT to the gateway's diff --git a/src/tools/defi.ts b/src/tools/defi.ts index 2845cac..833e408 100644 --- a/src/tools/defi.ts +++ b/src/tools/defi.ts @@ -9,6 +9,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { reserveBudget, recordSpending } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { getClient, getChain } from "../utils/wallet.js"; import { formatError, extractErrorMessage } from "../utils/errors.js"; @@ -77,6 +78,11 @@ Use blockrun_price (free) for plain spot quotes, blockrun_dex (free) for DEX pai }; } try { + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: estimatedCost, label: `defi · ${cleanPath}` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const client = getClient() as unknown as RawClient; const result = await client.getWithPaymentRaw(`/v1/defillama/${cleanPath}`); recordSpending(budget, estimatedCost, agent_id); diff --git a/src/tools/exa.ts b/src/tools/exa.ts index 5ecda9b..5637cee 100644 --- a/src/tools/exa.ts +++ b/src/tools/exa.ts @@ -8,6 +8,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { reserveBudget, recordSpending } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; import { getClient } from "../utils/wallet.js"; @@ -77,6 +78,11 @@ Full request/response shapes + worked research workflows in the \`exa-research\` }; } try { + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: estimatedCost, label: `exa · ${cleanPath}` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const client = getClient() as unknown as RawClient; const endpoint = `/v1/exa/${cleanPath}`; const result = await client.requestWithPaymentRaw(endpoint, body ?? {}); diff --git a/src/tools/markets.ts b/src/tools/markets.ts index fe49e23..5480621 100644 --- a/src/tools/markets.ts +++ b/src/tools/markets.ts @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { reserveBudget, recordSpending } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; import { getClient } from "../utils/wallet.js"; import { extractErrorMessage, formatError } from "../utils/errors.js"; @@ -116,6 +117,11 @@ Pass query params via 'params' (GET). Use 'body' only for POST endpoints (e.g. p }; } try { + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: estimatedCost, label: `markets · ${path}` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const llm = getClient(); const result = body !== undefined ? await llm.pmQuery(path, body) diff --git a/src/tools/modal.ts b/src/tools/modal.ts index cbf9aa4..2e56a4e 100644 --- a/src/tools/modal.ts +++ b/src/tools/modal.ts @@ -8,6 +8,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { reserveBudget, recordSpending } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; import { buildClientWithTimeout, getChain } from "../utils/wallet.js"; @@ -161,6 +162,11 @@ Full pricing tables + GPU details in the \`modal\` skill.`, }; } try { + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: estimatedCost, label: `modal · ${cleanPath}` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; // Dedicated client whose timeout covers a long synchronous exec, without // lengthening the 60s timeout the shared getClient() gives every other tool. const client = buildClientWithTimeout(modalTimeoutMs(body)) as unknown as RawClient; diff --git a/src/tools/music.ts b/src/tools/music.ts index b510c3e..d5d9d30 100644 --- a/src/tools/music.ts +++ b/src/tools/music.ts @@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { amountToUsd, reserveBudget, recordActualSpend } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { formatError, isPaymentRejectionError } from "../utils/errors.js"; import { launchTopUp } from "../utils/onramp.js"; @@ -91,6 +92,11 @@ Returns a permanent BlockRun-hosted URL.`, isError: true, }; } + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: MUSIC_COST, label: `music · ${model}` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const privateKey = getOrCreateWalletKey(); const account = privateKeyToAccount(privateKey); diff --git a/src/tools/phone.ts b/src/tools/phone.ts index 735259c..aa59c0b 100644 --- a/src/tools/phone.ts +++ b/src/tools/phone.ts @@ -9,6 +9,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { reserveBudget, recordSpending } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; import { getClient } from "../utils/wallet.js"; @@ -99,6 +100,11 @@ Voice call flow + voice preset details + full body shapes in the \`phone\` skill }; } try { + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: estimatedCost, label: `phone · ${cleanPath}` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const client = getClient() as unknown as RawClient; const endpoint = `/v1/${cleanPath}`; const result = body !== undefined diff --git a/src/tools/price.ts b/src/tools/price.ts index df04cf3..9c000bc 100644 --- a/src/tools/price.ts +++ b/src/tools/price.ts @@ -15,6 +15,7 @@ import type { MarketSession, } from "@blockrun/llm"; import { reserveBudget, recordSpending } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import type { BudgetState } from "../types.js"; import { getChain, getPriceClient } from "../utils/wallet.js"; @@ -96,6 +97,11 @@ Examples: }; } try { + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: estimatedCost, label: `price · ${category} ${symbol ?? query ?? ""}`.trim() }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const priceClient = getPriceClient(paid); if (action === "price") { diff --git a/src/tools/realface.ts b/src/tools/realface.ts index 52c01dc..fbe0911 100644 --- a/src/tools/realface.ts +++ b/src/tools/realface.ts @@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { amountToUsd, reserveBudget, recordActualSpend } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { formatError, isPaymentRejectionError } from "../utils/errors.js"; import { fetchWithTimeout } from "../utils/http.js"; @@ -270,6 +271,11 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, if (!gate.allowed) { return { content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }], isError: true }; } + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: ENROLLMENT_PRICE_USD, label: `realface · ${action}` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const { status, data, settledUsd } = await payAndPostJson( `${BLOCKRUN_API}/v1/portrait/enroll`, @@ -328,6 +334,11 @@ Privacy: BlockRun does not store face/liveness data — only the asset id, name, if (!gate.allowed) { return { content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }], isError: true }; } + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: ENROLLMENT_PRICE_USD, label: `realface · ${action}` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; // Same x402 probe/sign/resubmit flow as the portrait action — shared // via payAndPostJson. The server uploads the photo, waits for the diff --git a/src/tools/rpc.ts b/src/tools/rpc.ts index ce0552a..6375445 100644 --- a/src/tools/rpc.ts +++ b/src/tools/rpc.ts @@ -11,6 +11,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { reserveBudget, recordSpending } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { coerceBody } from "../utils/body.js"; import { getClient } from "../utils/wallet.js"; @@ -90,6 +91,11 @@ Prefer blockrun_price (free quotes), blockrun_dex (free DEX data), or blockrun_s }; } try { + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: estimatedCost, label: `rpc · ${cleanNetwork}` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const client = getClient() as unknown as RawClient; const result = await client.requestWithPaymentRaw(`/v1/rpc/${cleanNetwork}`, body); recordSpending(budget, estimatedCost, agent_id); diff --git a/src/tools/search.ts b/src/tools/search.ts index a8c6aa7..1a67cab 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -9,6 +9,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { reserveBudget, recordSpending } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; import { getClient } from "../utils/wallet.js"; import { formatError, extractErrorMessage } from "../utils/errors.js"; @@ -99,6 +100,11 @@ Full request shape + worked examples in the \`search\` skill (\`skills/search/SK }; } try { + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: estimatedCost, label: `search · ${cleanPath || "search"}` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const client = getClient() as unknown as RawClient; const endpoint = cleanPath ? `/v1/search/${cleanPath}` : "/v1/search"; const result = await client.requestWithPaymentRaw(endpoint, body ?? {}); diff --git a/src/tools/speech.ts b/src/tools/speech.ts index d50f27c..657be9f 100644 --- a/src/tools/speech.ts +++ b/src/tools/speech.ts @@ -14,6 +14,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { amountToUsd, reserveBudget, recordActualSpend } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { formatError, isPaymentRejectionError } from "../utils/errors.js"; import { launchTopUp } from "../utils/onramp.js"; @@ -201,6 +202,11 @@ Returns a hosted audio URL — download immediately if you need to keep the file isError: true, }; } + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: cost, label: `speech · ${action === "sound_effect" ? "sound effect" : model}` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const privateKey = getOrCreateWalletKey(); const account = privateKeyToAccount(privateKey); diff --git a/src/tools/surf.ts b/src/tools/surf.ts index e31a933..81c6d5e 100644 --- a/src/tools/surf.ts +++ b/src/tools/surf.ts @@ -14,6 +14,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { reserveBudget, recordSpending } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; import { getClient } from "../utils/wallet.js"; import { formatError, extractErrorMessage } from "../utils/errors.js"; @@ -98,6 +99,11 @@ Each Surf endpoint pre-validates required params before settling — you get a 4 }; } try { + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: estimatedCost, label: `surf · ${cleanPath}` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const client = getClient() as unknown as SurfClient; const endpoint = `/v1/surf/${cleanPath}`; const result = body !== undefined diff --git a/src/tools/video.ts b/src/tools/video.ts index adf69b3..e8621c3 100644 --- a/src/tools/video.ts +++ b/src/tools/video.ts @@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; import { z } from "zod"; import { amountToUsd, reserveBudget, recordActualSpend } from "../utils/budget.js"; +import { confirmSpend } from "../utils/confirm-spend.js"; import { withTxFee } from "../utils/tx-fee.js"; import { formatError, isPaymentRejectionError } from "../utils/errors.js"; import { launchTopUp } from "../utils/onramp.js"; @@ -424,6 +425,11 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC isError: true, }; } + // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A + // decline returns here — nothing is sent, and the finally releases the + // reservation. No-ops when off, sub-threshold, or unsupported by the client. + const confirm = await confirmSpend(server, { usd: estimatedCost, label: `video · ${selectedModel} · ${billedSeconds}s` }); + if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] }; const body: Record = { model: selectedModel, prompt }; if (image_url) body.image_url = image_url; diff --git a/test/confirm-spend-coverage.test.ts b/test/confirm-spend-coverage.test.ts new file mode 100644 index 0000000..c35fd58 --- /dev/null +++ b/test/confirm-spend-coverage.test.ts @@ -0,0 +1,132 @@ +// Run with: npm test (tsx --experimental-test-module-mocks --test) +// +// confirmSpend (src/utils/confirm-spend.ts) is the human-in-the-loop gate: with +// BLOCKRUN_CONFIRM_SPEND=on the server asks the user, via MCP elicitation, before +// it signs a paid x402 call. From 0.25.0 to 0.42.0 exactly ONE tool called it — +// blockrun_image — while thirteen other paid tools reserved budget and charged +// without asking. Nothing failed, because nothing looked; the README could only +// have described a feature 1 of 15 paid tools delivered. +// +// Two guards, because the failure mode is silence in both directions: +// +// 1. STATIC — every src/tools/*.ts that reserves budget must also call +// confirmSpend. A new paid tool that copies the reserve/record shape but +// forgets the confirm would otherwise ship un-gated forever. +// 2. BEHAVIORAL — with confirmation on and a client that answers "decline", +// every paid tool must return a non-error "declined" result, release its +// reservation (budget.spent back to 0), and never reach the network. +// +// Sibling: confirm-spend.test.ts proves the gate's own semantics (threshold, +// fail-open, session latch). This file proves every paid tool actually USES it. +process.env.BLOCKRUN_CONFIRM_SPEND = "on"; +process.env.BLOCKRUN_CONFIRM_THRESHOLD = "0"; + +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { BudgetState } from "../src/types.js"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const TOOLS_DIR = join(ROOT, "src", "tools"); + +// --------------------------------------------------------------------------- +// 1. Static guard +// --------------------------------------------------------------------------- +test("every tool that reserves budget also asks the user (confirmSpend)", () => { + const offenders: string[] = []; + for (const file of readdirSync(TOOLS_DIR).filter((f) => f.endsWith(".ts"))) { + const src = readFileSync(join(TOOLS_DIR, file), "utf8"); + const reserves = (src.match(/reserveBudget\(budget/g) ?? []).length; + if (reserves === 0) continue; + const imports = /from "\.\.\/utils\/confirm-spend\.js"/.test(src); + const calls = (src.match(/confirmSpend\(server/g) ?? []).length; + if (!imports || calls === 0) offenders.push(`${file} (reserves=${reserves}, confirms=${calls})`); + } + assert.deepEqual( + offenders, + [], + `paid tools that charge without confirmSpend — they bypass BLOCKRUN_CONFIRM_SPEND:\n ${offenders.join("\n ")}`, + ); +}); + +// --------------------------------------------------------------------------- +// 2. Behavioral guard +// --------------------------------------------------------------------------- +const TEST_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; +let networkCalls = 0; +const boom = () => { networkCalls++; throw new Error("UNEXPECTED_NETWORK_CALL"); }; +// Any method on any client is a network call. +const trap = new Proxy({}, { get: () => boom }); + +mock.module("../src/utils/wallet.js", { + namedExports: { + getChain: () => "base", + getClient: () => trap, + buildClient: () => trap, + buildClientWithTimeout: () => trap, + getPriceClient: () => trap, + getAnthropicClient: () => trap, + baseOnlyMessage: () => null, + getOrCreateWalletKey: () => TEST_KEY, + getWalletInfo: async () => ({ address: "0xTEST" }), + }, +}); +mock.module("../src/utils/http.js", { + namedExports: { fetchWithTimeout: async () => boom(), isTimeoutError: () => false }, +}); +mock.module("../src/utils/ssrf.js", { + namedExports: { isBlockedFetchHostResolved: async () => false, isBlockedFetchHost: () => false }, +}); + +type Handler = (args: Record) => Promise<{ content: Array<{ type: string; text?: string }>; isError?: boolean }>; + +function harness(register: (server: unknown, budget: BudgetState) => void) { + let handler: Handler | undefined; + const server = { + registerTool: (_n: string, _c: unknown, h: Handler) => { handler = h; }, + server: { + getClientCapabilities: () => ({ elicitation: {} }), + elicitInput: async () => ({ action: "decline" }), + }, + }; + const budget: BudgetState = { limit: null, spent: 0, calls: 0, agents: new Map() }; + register(server, budget); + assert.ok(handler, "tool did not register a handler"); + return { call: (args: Record) => handler!(args), budget }; +} + +// One valid PAID request per tool — each must clear its own pre-gate +// validation (chain, path, schema) so the only thing standing between it and +// the network is the confirm dialog. +const CASES: Array<{ tool: string; mod: string; register: string; args: Record }> = [ + { tool: "blockrun_defi", mod: "defi", register: "registerDefiTool", args: { path: "protocols" } }, + { tool: "blockrun_markets", mod: "markets", register: "registerMarketsTool", args: { path: "markets", params: { q: "fed" } } }, + { tool: "blockrun_chat", mod: "chat", register: "registerChatTool", args: { message: "hi", model: "openai/gpt-5.6-terra" } }, + { tool: "blockrun_exa", mod: "exa", register: "registerExaTool", args: { path: "search", body: { query: "rag papers" } } }, + { tool: "blockrun_phone", mod: "phone", register: "registerPhoneTool", args: { path: "phone/lookup", body: { phone: "+14155550100" } } }, + { tool: "blockrun_modal", mod: "modal", register: "registerModalTool", args: { path: "sandbox/create", body: {} } }, + { tool: "blockrun_rpc", mod: "rpc", register: "registerRpcTool", args: { network: "ethereum", method: "eth_blockNumber" } }, + { tool: "blockrun_price", mod: "price", register: "registerPriceTool", args: { action: "price", category: "stocks", symbol: "AAPL", market: "US" } }, + { tool: "blockrun_surf", mod: "surf", register: "registerSurfTool", args: { path: "market/price", params: { symbol: "ETH" } } }, + { tool: "blockrun_search", mod: "search", register: "registerSearchTool", args: { body: { query: "fed decision" } } }, + { tool: "blockrun_music", mod: "music", register: "registerMusicTool", args: { prompt: "lofi", instrumental: true, model: "minimax/music-2.5+" } }, + { tool: "blockrun_speech", mod: "speech", register: "registerSpeechTool", args: { action: "speak", input: "hello", model: "elevenlabs/flash-v2.5", response_format: "mp3" } }, + { tool: "blockrun_realface", mod: "realface", register: "registerRealfaceTool", args: { action: "portrait", name: "Ada", image_url: "https://example.com/ada.png" } }, + { tool: "blockrun_video", mod: "video", register: "registerVideoTool", args: { prompt: "a cube", model: "bytedance/seedance-2.0" } }, +]; + +for (const c of CASES) { + test(`${c.tool}: a declined confirmation charges nothing, releases the reservation, and never touches the network`, async () => { + const mod = (await import(`../src/tools/${c.mod}.js`)) as Record void>; + networkCalls = 0; + const { call, budget } = harness(mod[c.register]); + const res = await call(c.args); + const text = res.content.map((p) => p.text ?? "").join("\n"); + assert.notEqual(res.isError, true, `${c.tool} returned an error instead of a decline: ${text}`); + assert.match(text, /declined/i, `${c.tool} did not report the decline: ${text}`); + assert.equal(networkCalls, 0, `${c.tool} reached the network after a decline`); + assert.equal(budget.spent, 0, `${c.tool} left a reservation behind after a decline`); + }); +} diff --git a/test/skills-cli.test.ts b/test/skills-cli.test.ts new file mode 100644 index 0000000..2a61a7d --- /dev/null +++ b/test/skills-cli.test.ts @@ -0,0 +1,116 @@ +// Run with: npm test (tsx --test) +// +// `blockrun-mcp skills install` copies the skills that ship inside the npm +// tarball (package.json "files" has carried `skills/` since 0.2x) into a +// project or user skills directory. Until this command existed the only ways +// to get them were cloning the repo or `/plugin marketplace add` — neither +// works for Codex, Cursor, or a CI image that just ran `npx @blockrun/mcp`. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { installSkills, listSkills, parseSkillsArgs, resolveSkillsTarget, SKILLS_SOURCE_DIR } from "../src/cli/skills.js"; + +function fixture(): { from: string; to: string } { + const root = mkdtempSync(join(tmpdir(), "br-skills-")); + const from = join(root, "skills"); + const to = join(root, "dest"); + for (const name of ["alpha", "beta"]) { + mkdirSync(join(from, name, "rules"), { recursive: true }); + writeFileSync(join(from, name, "SKILL.md"), `---\nname: ${name}\ndescription: "x"\n---\n# ${name}\n`); + writeFileSync(join(from, name, "rules", "r.md"), `rule for ${name}\n`); + } + // Noise that must NOT be treated as a skill: a stray file and a dir without SKILL.md. + writeFileSync(join(from, "README.md"), "not a skill\n"); + mkdirSync(join(from, "not-a-skill")); + return { from, to }; +} + +test("listSkills returns only directories that carry a SKILL.md, sorted", () => { + const { from } = fixture(); + assert.deepEqual(listSkills(from).map((s) => s.name), ["alpha", "beta"]); +}); + +test("listSkills reads the description out of the frontmatter", () => { + const { from } = fixture(); + assert.equal(listSkills(from)[0].description, "x"); +}); + +test("installSkills copies every skill directory recursively", () => { + const { from, to } = fixture(); + const r = installSkills({ from, to }); + assert.deepEqual(r.installed, ["alpha", "beta"]); + assert.deepEqual(r.skipped, []); + assert.equal(readFileSync(join(to, "alpha", "SKILL.md"), "utf8").includes("name: alpha"), true); + assert.equal(readFileSync(join(to, "beta", "rules", "r.md"), "utf8"), "rule for beta\n"); + assert.equal(existsSync(join(to, "README.md")), false, "stray files are not skills"); + assert.equal(existsSync(join(to, "not-a-skill")), false, "a dir without SKILL.md is not a skill"); +}); + +test("installSkills skips an existing skill unless --force, and never deletes user files", () => { + const { from, to } = fixture(); + mkdirSync(join(to, "alpha"), { recursive: true }); + writeFileSync(join(to, "alpha", "SKILL.md"), "user-edited\n"); + writeFileSync(join(to, "alpha", "NOTES.md"), "keep me\n"); + + const first = installSkills({ from, to }); + assert.deepEqual(first.installed, ["beta"]); + assert.deepEqual(first.skipped, ["alpha"]); + assert.equal(readFileSync(join(to, "alpha", "SKILL.md"), "utf8"), "user-edited\n"); + + const forced = installSkills({ from, to, force: true }); + assert.deepEqual(forced.installed, ["alpha", "beta"]); + assert.equal(readFileSync(join(to, "alpha", "SKILL.md"), "utf8").includes("name: alpha"), true); + assert.equal(readFileSync(join(to, "alpha", "NOTES.md"), "utf8"), "keep me\n", "--force overwrites ours, keeps theirs"); +}); + +test("installSkills --only restricts to the named skills and rejects unknown names", () => { + const { from, to } = fixture(); + const r = installSkills({ from, to, only: ["beta"] }); + assert.deepEqual(r.installed, ["beta"]); + assert.equal(existsSync(join(to, "alpha")), false); + assert.throws(() => installSkills({ from, to, only: ["nope"] }), /unknown skill.*nope/i); +}); + +test("installSkills refuses a destination inside the source tree", () => { + const { from } = fixture(); + assert.throws(() => installSkills({ from, to: join(from, "alpha") }), /inside the source/i); +}); + +test("parseSkillsArgs: subcommands, --to, --global, --force, --only", () => { + assert.deepEqual(parseSkillsArgs(["list"]), { cmd: "list", force: false, global: false, only: undefined, to: undefined }); + assert.deepEqual(parseSkillsArgs(["install"]), { cmd: "install", force: false, global: false, only: undefined, to: undefined }); + assert.deepEqual(parseSkillsArgs(["install", "--global", "--force"]), { cmd: "install", force: true, global: true, only: undefined, to: undefined }); + assert.deepEqual(parseSkillsArgs(["install", "--to", "/x/y"]).to, "/x/y"); + assert.deepEqual(parseSkillsArgs(["install", "--to=/x/y"]).to, "/x/y"); + assert.deepEqual(parseSkillsArgs(["install", "--only", "a,b"]).only, ["a", "b"]); + assert.deepEqual(parseSkillsArgs(["install", "--only=a, b"]).only, ["a", "b"]); + assert.equal(parseSkillsArgs([]).cmd, "help"); + assert.equal(parseSkillsArgs(["bogus"]).cmd, "help"); + assert.throws(() => parseSkillsArgs(["install", "--to"]), /--to requires/); +}); + +test("resolveSkillsTarget: project .claude/skills by default, ~/.claude/skills with --global, --to wins", () => { + const cwd = "/proj"; + const home = "/home/me"; + assert.equal(resolveSkillsTarget({ global: false }, cwd, home), join(cwd, ".claude", "skills")); + assert.equal(resolveSkillsTarget({ global: true }, cwd, home), join(home, ".claude", "skills")); + assert.equal(resolveSkillsTarget({ global: true, to: "/elsewhere" }, cwd, home), "/elsewhere"); + assert.equal(resolveSkillsTarget({ to: "~/.codex/skills" }, cwd, home), join(home, ".codex", "skills")); + assert.equal(resolveSkillsTarget({ to: "rel/skills" }, cwd, home), join(cwd, "rel", "skills")); +}); + +test("the shipped skills directory resolves and contains the real skills", () => { + // Resolved relative to the module the way index.ts resolves package.json, + // so it must work from src/ under tsx and from dist/ under npx alike. + assert.ok(existsSync(join(SKILLS_SOURCE_DIR, "blockrun", "SKILL.md")), SKILLS_SOURCE_DIR); + const names = listSkills(SKILLS_SOURCE_DIR).map((s) => s.name); + const onDisk = readdirSync(join(SKILLS_SOURCE_DIR)).filter((d) => existsSync(join(SKILLS_SOURCE_DIR, d, "SKILL.md"))).sort(); + assert.deepEqual(names, onDisk); + // and a real install round-trips + const to = mkdtempSync(join(tmpdir(), "br-skills-real-")); + const r = installSkills({ from: SKILLS_SOURCE_DIR, to }); + assert.deepEqual(r.installed, onDisk); + rmSync(to, { recursive: true, force: true }); +});