From b70a6d99b801ce00f34f998cded465e7455fc5aa Mon Sep 17 00:00:00 2001 From: krishna Date: Sat, 13 Jun 2026 16:50:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(talent):=20blockrun=5Ftalent=20=E2=80=94?= =?UTF-8?q?=20discover=20+=20hire=20marketplace=20skills=20=E2=80=94=20v0.?= =?UTF-8?q?22.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New MCP tool exposing the BlockRun agent marketplace (business.blockrun.ai) to any MCP host. action:"list" (default) browses/searches the catalog for free, ranked by call volume; action:"run" hires a skill by slug, signing ONE standard single-leg `exact` USDC x402 payment from the wallet on Base and returning its output — budget-gated against the quoted price, charged only on a successful run (the marketplace settles only on success, so a failed run is free). Mirrors the manual-402 pattern in speech.ts/music.ts (same @blockrun/llm signer, checkBudget/recordSpending, getChain Base guard), with the two marketplace deltas: the 402 challenge arrives in the JSON body (not a PAYMENT-REQUIRED header) and the signed payment goes in the `x-payment` header (not PAYMENT-SIGNATURE). ASCII-safe payment description (createPaymentPayload base64s with btoa). scripts/smoke-talent.ts added. Live-tested end-to-end against real Coinbase CDP /verify on Base mainnet: 402 -> @blockrun/llm-signed x-payment -> {"valid":true}, exact-price gate 10000 == 10000, result returned, $0.01 recorded. tools/list handshake shows blockrun_talent among 19 tools. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 + package.json | 2 +- scripts/smoke-talent.ts | 27 +++++ src/mcp-handler.ts | 2 + src/tools/talent.ts | 231 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 scripts/smoke-talent.ts create mode 100644 src/tools/talent.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f9fac..f7c08ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to BlockRun MCP will be documented in this file. +## 0.22.0 + +- **`feat(talent)` — new `blockrun_talent` tool: discover and hire skills from the BlockRun agent marketplace.** Opens business.blockrun.ai's paid skills (other creators' specialized agents) to any MCP host. `action:"list"` (default) browses or searches the catalog for free, ranked by call volume; `action:"run"` hires a skill by slug, signing ONE standard single-leg `exact` USDC x402 payment from the wallet on Base and returning its output — budget-gated against the quoted price and charged only on a successful run (the marketplace settles only on success, so a failed run is free). Base only. Live-tested end-to-end against real Coinbase CDP `/verify` on Base mainnet (402 → @blockrun/llm-signed `x-payment` → `valid:true`, exact-price gate `10000 == 10000`, result returned). + ## 0.21.2 - **`fix` — `--version`/`-v` and `--help`/`-h` CLI flags exit cleanly.** Metadata flags are now handled before the stdio transport connects, so version/help inspection no longer boots the full MCP server. Thanks @xianzuyang9-blip! (#16) diff --git a/package.json b/package.json index 02aea07..1beb0ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/mcp", - "version": "0.21.2", + "version": "0.22.0", "mcpName": "io.github.BlockRunAI/blockrun-mcp", "description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, crypto data, X/Twitter intelligence. Paid via x402 micropayments.", "type": "module", diff --git a/scripts/smoke-talent.ts b/scripts/smoke-talent.ts new file mode 100644 index 0000000..49426a1 --- /dev/null +++ b/scripts/smoke-talent.ts @@ -0,0 +1,27 @@ +// One-off smoke test for blockrun_talent. Run: +// BLOCKRUN_MARKET_URL= BLOCKRUN_WALLET_KEY=0x npx tsx scripts/smoke-talent.ts +// Exercises: list (free discovery), list+query (filter), run (paid hire via x402). +import { registerTalentTool } from "../src/tools/talent.js"; +import type { BudgetState } from "../src/types.js"; + +type Handler = (args: Record) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>; +let handler: Handler; +const fakeServer = { + registerTool: (_name: string, _cfg: unknown, h: Handler) => { handler = h; }, +} as never; + +const budget: BudgetState = { limit: null, spent: 0, calls: 0, agents: new Map() }; +registerTalentTool(fakeServer, budget); + +async function run(label: string, args: Record) { + console.log(`\n=== ${label} ===`); + const res = await handler!(args); + console.log(`isError: ${res.isError ?? false}`); + console.log(res.content[0].text.slice(0, 600)); +} + +await run("list (free)", { action: "list" }); +await run("list query", { action: "list", query: "e2e" }); +await run("missing slug (free, errors)", { action: "run", input: "x" }); +await run("run (paid x402)", { action: "run", slug: "franklin-e2e", input: "ping from mcp" }); +console.log(`\nBudget spent: $${budget.spent.toFixed(4)} across ${budget.calls} call(s)`); diff --git a/src/mcp-handler.ts b/src/mcp-handler.ts index 1727fe5..52b0c22 100644 --- a/src/mcp-handler.ts +++ b/src/mcp-handler.ts @@ -22,6 +22,7 @@ import { registerPhoneTool } from "./tools/phone.js"; import { registerSurfTool } from "./tools/surf.js"; import { registerRpcTool } from "./tools/rpc.js"; import { registerDefiTool } from "./tools/defi.js"; +import { registerTalentTool } from "./tools/talent.js"; export function initializeMcpServer(server: McpServer): void { const budget: BudgetState = { limit: null, spent: 0, calls: 0, agents: new Map() }; const modelCache: { models: Array | null } = { models: null }; @@ -45,6 +46,7 @@ export function initializeMcpServer(server: McpServer): void { registerSurfTool(server, budget); registerRpcTool(server, budget); registerDefiTool(server, budget); + registerTalentTool(server, budget); // Register resources server.registerResource( diff --git a/src/tools/talent.ts b/src/tools/talent.ts new file mode 100644 index 0000000..4c40f3f --- /dev/null +++ b/src/tools/talent.ts @@ -0,0 +1,231 @@ +// src/tools/talent.ts +// +// BlockRun agent marketplace (business.blockrun.ai) via x402 — discover and +// hire other creators' paid AI skills ("talents"). Mirrors the manual-402 +// pattern in speech.ts / music.ts (Base only). +// +// The marketplace speaks standard single-leg `exact` x402, so we sign with the +// same @blockrun/llm primitives the other tools use — with two differences from +// the gateway: the 402 challenge comes back in the JSON body (not a +// PAYMENT-REQUIRED header), and the signed payment goes in the `x-payment` +// header (not PAYMENT-SIGNATURE). Discovery (list) is a free GET; hiring (run) +// pays the quoted price and settles only on a successful run. + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { checkBudget, recordSpending } from "../utils/budget.js"; +import { formatError } from "../utils/errors.js"; +import type { BudgetState } from "../types.js"; +import { getChain, getOrCreateWalletKey } from "../utils/wallet.js"; +import { privateKeyToAccount } from "viem/accounts"; +import { createPaymentPayload, extractPaymentDetails } from "@blockrun/llm"; + +const MARKET_API = (process.env.BLOCKRUN_MARKET_URL || "https://business.blockrun.ai").replace(/\/+$/, ""); +const LIST_TIMEOUT = 15_000; +const RUN_TIMEOUT = 120_000; + +interface MarketSkill { + slug: string; + name: string; + description: string; + price_usd: number; + backing_model: string; + run_count: number; + execution_type: string; // "prompt" | "agent" + data_sources: string[]; + sample_input?: string; + sample_output?: string; + creator: { wallet: string; x: string | null }; + run_url: string; +} + +type ToolResult = { + content: Array<{ type: "text"; text: string }>; + structuredContent?: Record; + isError?: boolean; +}; + +function fmtUsd(n: number): string { + if (!Number.isFinite(n) || n <= 0) return "$0.00"; + return n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`; +} + +function errResult(msg: string): ToolResult { + return { content: [{ type: "text", text: formatError(msg) }], isError: true }; +} + +export function registerTalentTool(server: McpServer, budget: BudgetState): void { + server.registerTool( + "blockrun_talent", + { + description: `BlockRun agent marketplace — discover and hire other creators' paid AI skills ("talents"). + +Actions: +- list (default): browse or search the catalog (free). Optional 'query' filters by name, description, and data source. Results are ranked by call volume, most-used first. +- run: hire a skill by 'slug' with your 'input'. Signs ONE standard USDC x402 payment from the wallet on Base and returns the skill's output. Charged automatically and ONLY on a successful run (the response reports the USD paid); a failed run is free. + +Use list whenever you need talent for a sub-task you cannot do well yourself (live market/on-chain data, a domain-specific analysis, a niche transform) or when the user asks you to find an agent for some domain; then run the best match. Prefer listing first to get the exact slug and price.`, + inputSchema: { + action: z.enum(["list", "run"]).optional().default("list").describe("list: browse/search the catalog (free). run: hire a skill (paid)."), + query: z.string().optional().describe("list: keyword filter over name, description, and data source."), + slug: z.string().optional().describe("run: the skill slug to hire (get it from list)."), + input: z.string().optional().describe("run: the input text to send the skill."), + limit: z.number().optional().describe("list: max skills to return (default 20, max 200)."), + agent_id: z.string().optional().describe("Agent identifier for budget tracking and enforcement."), + }, + }, + async ({ action, query, slug, input, limit, agent_id }) => { + try { + if (action === "run") return await hireSkill({ slug, input, agent_id, budget }); + return await listTalents({ query, limit }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return errResult(`Marketplace request failed: ${msg}`); + } + }, + ); +} + +// ─── list (free) ───────────────────────────────────────────────────────────── + +async function listTalents({ query, limit }: { query?: string; limit?: number }): Promise { + const n = Math.min(Math.max(typeof limit === "number" ? limit : 20, 1), 200); + const resp = await fetchWithTimeout(`${MARKET_API}/api/v1/skills?limit=${n}`, { + method: "GET", + headers: { Accept: "application/json" }, + }, LIST_TIMEOUT); + if (!resp.ok) throw new Error(`marketplace returned HTTP ${resp.status}`); + + const body = (await resp.json()) as { skills?: MarketSkill[] }; + let skills = Array.isArray(body.skills) ? body.skills : []; + // Rank by call volume, highest first (stable — keeps the API's recency tiebreak). + skills.sort((a, b) => (b.run_count ?? 0) - (a.run_count ?? 0)); + + const q = query?.trim().toLowerCase(); + if (q) { + const terms = q.split(/\s+/); + skills = skills.filter((s) => { + const hay = [s.slug, s.name, s.description, ...(s.data_sources || [])].join(" ").toLowerCase(); + return terms.every((t) => hay.includes(t)); + }); + } + + if (skills.length === 0) { + return { content: [{ type: "text", text: query ? `No talents match "${query}".` : "No talents are available right now." }] }; + } + + const lines = skills.map((s) => { + const type = s.execution_type === "agent" && s.data_sources?.length ? `live-data(${s.data_sources.join(",")})` : s.execution_type; + const by = s.creator?.x ? ` by @${s.creator.x}` : ""; + const eg = s.sample_input ? ` | e.g. ${JSON.stringify(s.sample_input)}` : ""; + return `- ${s.slug} — ${s.name} [${type}] ${fmtUsd(s.price_usd)}/run${by}\n ${s.description}${eg}`; + }); + const head = query ? `BlockRun talents matching "${query}" (${skills.length}):` : `BlockRun talents (${skills.length}):`; + return { + content: [{ type: "text", text: `${head}\n${lines.join("\n")}\n\nTo hire: blockrun_talent action:"run" slug:"" input:"".` }], + structuredContent: { skills }, + }; +} + +// ─── run (paid) ────────────────────────────────────────────────────────────── + +async function hireSkill( + { slug, input, agent_id, budget }: { slug?: string; input?: string; agent_id?: string; budget: BudgetState }, +): Promise { + if (getChain() !== "base") { + return errResult("blockrun_talent settles on Base only. Switch BlockRun to Base (blockrun_wallet action:chain chain:base) and fund the Base wallet with USDC."); + } + if (!slug?.trim()) return errResult(`'slug' is required for action:"run" — run action:"list" first to find it.`); + if (!input?.trim()) return errResult(`'input' is required for action:"run".`); + + const runUrl = `${MARKET_API}/api/v1/skills/${encodeURIComponent(slug)}/run`; + const payload = JSON.stringify({ input }); + + // Step 1: unpaid POST → 402 with the exact quoted price (or a free prefilter refusal). + const resp402 = await fetchWithTimeout(runUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload, + }, 15_000); + + if (resp402.status === 200) { + // The route can refuse an input for free (no payment) — surface that result. + const d = (await resp402.json().catch(() => ({}))) as Record; + return runSuccess(slug, 0, null, typeof d.result === "string" ? d.result : ""); + } + if (resp402.status !== 402) { + const d = (await resp402.json().catch(() => ({}))) as Record; + return errResult(`Could not start '${slug}' (HTTP ${resp402.status}): ${typeof d.error === "string" ? d.error : "marketplace error"}.`); + } + + const challenge = (await resp402.json().catch(() => null)) as { accepts?: unknown[] } | null; + if (!challenge?.accepts?.length) return errResult("Could not read payment requirements from the marketplace."); + + const details = extractPaymentDetails(challenge as Parameters[0]); + const cost = Number(details.amount) / 1_000_000; + + // Budget gate against the REAL quoted price (the 402 above was free — no charge yet). + const bc = checkBudget(budget, agent_id, cost); + if (!bc.allowed) { + return { content: [{ type: "text", text: `${bc.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }], isError: true }; + } + + const privateKey = getOrCreateWalletKey(); + const account = privateKeyToAccount(privateKey); + const paymentPayload = await createPaymentPayload( + privateKey, + account.address, + details.recipient, + details.amount, + details.network || "eip155:8453", + { + resourceUrl: details.resource?.url || runUrl, + // createPaymentPayload base64s the payload with btoa (Latin1-only), so the + // description must stay ASCII — strip anything else out of the slug. + resourceDescription: `Run ${slug}`.replace(/[^\x20-\x7E]/g, ""), + maxTimeoutSeconds: details.maxTimeoutSeconds || 300, + extra: details.extra, + }, + ); + + // Step 2: pay + run (the marketplace reads `x-payment`). + const resp = await fetchWithTimeout(runUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "x-payment": paymentPayload }, + body: payload, + }, RUN_TIMEOUT); + + const txHash = resp.headers.get("x-payment-receipt") || resp.headers.get("X-Payment-Receipt"); + const data = (await resp.json().catch(() => ({}))) as Record; + if (!resp.ok) { + // The route fails closed before settle on every error path → no charge. + return errResult(`Hiring '${slug}' failed (HTTP ${resp.status}): ${typeof data.error === "string" ? data.error : "run error"}. No charge — the marketplace settles only on a successful run.`); + } + + recordSpending(budget, cost, agent_id); + return runSuccess(slug, cost, txHash, typeof data.result === "string" ? data.result : ""); +} + +function runSuccess(slug: string, cost: number, txHash: string | null, result: string): ToolResult { + const lines = [ + `🤝 Hired ${slug}`, + `Paid: ${fmtUsd(cost)}`, + ...(txHash ? [`Tx: ${txHash}`] : []), + ``, + result, + ]; + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { slug, cost_usd: cost, ...(txHash ? { txHash } : {}), result }, + }; +} + +async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(id); + } +}