From 4e696c6610737e81956c017645526fd99e1e0849 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:18:46 -0500 Subject: [PATCH 1/5] feat(confirm): gate every paid tool behind confirmSpend, not just image confirmSpend (MCP elicitation, BLOCKRUN_CONFIRM_SPEND=on) shipped in 0.25.0 wired into blockrun_image only. The other 13 paid tools reserved budget and signed the x402 payment without asking. Each now asks at its budget gate, inside the try so a decline releases the reservation via finally and sends nothing. Free paths (chat mode:free, crypto price, free phone lookups) never prompt because usd<=0 short-circuits inside confirmSpend. test/confirm-spend-coverage.test.ts guards it both ways: statically (every tool that calls reserveBudget also calls confirmSpend) and behaviourally (with a declining client, all 14 tools return a non-error decline, leave budget.spent at 0, and never reach the network). The behavioural half caught the first draft of this change, which placed nine of the confirms before their try and leaked the reservation on decline. --- src/tools/chat.ts | 6 ++ src/tools/defi.ts | 6 ++ src/tools/exa.ts | 6 ++ src/tools/markets.ts | 6 ++ src/tools/modal.ts | 6 ++ src/tools/music.ts | 6 ++ src/tools/phone.ts | 6 ++ src/tools/price.ts | 6 ++ src/tools/realface.ts | 11 +++ src/tools/rpc.ts | 6 ++ src/tools/search.ts | 6 ++ src/tools/speech.ts | 6 ++ src/tools/surf.ts | 6 ++ src/tools/video.ts | 6 ++ test/confirm-spend-coverage.test.ts | 132 ++++++++++++++++++++++++++++ 15 files changed, 221 insertions(+) create mode 100644 test/confirm-spend-coverage.test.ts 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`); + }); +} From 68ea2ee027687ce2cfa22791f12dc1c93a4be301 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:21:03 -0500 Subject: [PATCH 2/5] feat(cli): blockrun-mcp skills list | install The skills have shipped inside the npm tarball since files: ["skills"] was added, but the only ways to use them were cloning the repo or the Claude Code plugin marketplace. Codex (~/.codex/skills), Cursor, CI images and projects that want skills checked in under .claude/skills had no path. 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 npx -y @blockrun/mcp@latest skills install --only blockrun,blockrun-debug Existing skill dirs are skipped unless --force; --force overwrites the files we ship and leaves the user's other files alone. It never deletes. The skills/ dir is located by walking up to the package root so the same code works from src/ under tsx and from the tsup bundle in dist/. The subcommand is handled before the stdio server would start. --- src/cli/skills.ts | 258 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 13 +- test/skills-cli.test.ts | 116 ++++++++++++++++++ 3 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 src/cli/skills.ts create mode 100644 test/skills-cli.test.ts 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/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 }); +}); From 0b122e8748db137118a8c75beddf764895b1207f Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:27:45 -0500 Subject: [PATCH 3/5] feat(skills): blockrun-setup, blockrun-debug, blockrun-upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All thirteen shipped skills were 'how to call tool X'. Nothing helped the agent that runs the install, hits 'Failed to connect', or sees the Update available notice — the README has that content, but only a human reads it. Each skill was written against a baseline run without it. The unaided agent reached for nvm alias default instead of the -e PATH="$PATH" passthrough, described the key as keychain-only (the file stays authoritative unless BLOCKRUN_KEYCHAIN=strict), skipped blockrun_wallet action:setup, hand-wrote a Codex config.toml, and refreshed copied skills with npm pack + tar. With the skills loaded all three scenarios came back correct; the review pass added Codex --env PATH, claude mcp get to verify scope and env, diagnostics before fixes, and the --force-overwrites-your-edits warning. Registered in .claude-plugin/marketplace.json (marketplace.test.ts) and frontmatter-checked (skill-frontmatter.test.ts). --- .claude-plugin/marketplace.json | 15 ++++ CONTRIBUTING.md | 2 +- skills/blockrun-debug/SKILL.md | 88 +++++++++++++++++++ skills/blockrun-setup/SKILL.md | 122 ++++++++++++++++++++++++++ skills/blockrun-upgrade/SKILL.md | 141 +++++++++++++++++++++++++++++++ 5 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 skills/blockrun-debug/SKILL.md create mode 100644 skills/blockrun-setup/SKILL.md create mode 100644 skills/blockrun-upgrade/SKILL.md 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/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/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`. | From ef326e42931765af454dcc9f883efda97a6dfe48 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:27:45 -0500 Subject: [PATCH 4/5] docs: headline human-in-the-loop payments; skills install; verified client matrix README gains a feature bullet, a comparison-table row, a Human-in-the-loop payments section with the dialog rendered as text and a per-client support table, a Quick Start step for installing the skills, BLOCKRUN_BUDGET_LIMIT / BLOCKRUN_CONFIRM_SPEND / BLOCKRUN_CONFIRM_THRESHOLD in the configuration table (the first had never been documented), a Troubleshooting pointer to the debug skill, and two FAQ entries. docs/spend-confirmation.md is the long form: when to use the gate versus budgets, the enable snippets per client, the support matrix with sources (verified 2026-08-29 against each client's own docs: Claude Code, Cursor and VS Code render it; Claude Desktop renders it but reports cancel on OK; Windsurf, Codex and Gemini CLI do not document elicitation), what fail-open means, and the limitations. No screenshot: a real dialog needs a live session and a mocked one would mislead. --- README.md | 60 ++++++++++++- docs/spend-confirmation.md | 167 +++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 docs/spend-confirmation.md 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. From 644df5e9fe6c0154406ef70f1db473ad6bc4aa05 Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:27:45 -0500 Subject: [PATCH 5/5] =?UTF-8?q?0.43.0=20=E2=80=94=20every=20paid=20tool=20?= =?UTF-8?q?asks=20before=20it=20spends;=20skills=20install;=20setup/debug/?= =?UTF-8?q?upgrade=20skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 47 insertions(+), 3 deletions(-) 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/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",