Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ server.json
docs/plans/
pnpm-workspace.yaml
.claude/
ui/
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@

All notable changes to BlockRun MCP will be documented in this file.

## 0.44.0

**Two MCP Apps: a Polymarket order card and a wallet panel.** Hosts that
implement the MCP Apps extension — Claude Desktop, claude.ai, VS Code,
Cursor, ChatGPT — now render `blockrun_polymarket_read` previews and
`blockrun_wallet` results as interactive cards instead of text. The order
card shows the question, outcome, side, best quote in cents, estimated
shares, notional, a per-order cap meter and the session ledger; the amount
is editable and re-quotes through the read-only tool; *Place order* is a
two-click arm/confirm that asks the host to call `blockrun_polymarket` with
`confirm:true`, so the host's own consent prompt and every server cap still
apply, and the result (order id, status, tx) lands back in the card and in
the model's context. The wallet panel shows both chains, switches the active
one, copies an address, shows an EIP-681 / Solana Pay QR, opens the explorer,
and mints the card on-ramp link. Hosts without the extension — Claude Code,
Codex, Gemini CLI, Windsurf — ignore `_meta.ui` and see exactly the text
they saw before. Bundles are single-file HTML built from `apps/` into `ui/`,
shipped in the tarball, registered only for profiles that expose the tool,
and pinned by `test/apps.test.ts`. Rendered and screenshotted in the MCPJam
inspector against the published server; `docs/mcp-apps.md` has the host
table and the money-path explanation.

**A bare `token_id` now resolves to its market.** `resolveToken` returned
only the id, so the card could not name the market and the closed /
accepting-orders guard never ran on that path. Gamma indexes markets by CLOB
token id; the preview asks it best-effort (4 s, spread-safe `{}` on failure)
and fills question, outcome, conditionId, closed and acceptingOrders. The
preview payload also carries bestQuote, minSize, maxBetUsd and the session
ledger so nothing downstream has to parse prose.

## 0.43.0

**Every paid tool now asks before it spends.** Spend confirmation via MCP
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Every other data integration was built for **human developers** — create an ac
- **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)
- **Generative UI** — on Claude Desktop, claude.ai, VS Code and Cursor the Polymarket preview is a live order card with a Place button, and the wallet is a panel with balances, QR and card top-up. [MCP Apps ↓](#-mcp-apps-order-card--wallet-panel)
- **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.

---
Expand All @@ -77,6 +78,7 @@ Every other data integration was built for **human developers** — create an ac
| **Pay-chain** | — | — | **Base + Solana** |
| **Agent budgets** | Manual | — | **Built-in per-agent delegation** |
| **Spend approval** | — | — | **Ask-before-pay dialog (MCP elicitation)** |
| **Generative UI** | — | Rare | **Order card + wallet panel (MCP Apps)** |
| **Open source** | Varies | Varies | **Yes (MIT)** |

✓ One wallet · ✓ Pay-per-call · ✓ Reads **and** trades · ✓ Multi-chain · ✓ Agent-ready · ✓ Open source
Expand Down Expand Up @@ -329,6 +331,23 @@ On a client that can't ask, the gate **fails open** — the call proceeds and th

---

## 🧩 MCP Apps — order card & wallet panel

On hosts that support the [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview) — Claude Desktop, claude.ai, VS Code, Cursor, ChatGPT — two tools render as interactive cards instead of text. Everywhere else (Claude Code, Codex, terminals) nothing changes.

<p align="center">
<img src="assets/mcp-apps/order-card.png" width="320" alt="Polymarket order card: BUY Market FOK, you spend $5.00, best ask 57.0¢, ≈8.77 shares, per-order cap meter, Re-quote and Place buy">
&nbsp;&nbsp;&nbsp;
<img src="assets/mcp-apps/wallet-panel.png" width="320" alt="Wallet panel: Base active $34.96 USDC, Solana $0.05 low balance, Copy / QR / Use Solana, Refresh, Basescan, Buy USDC with card">
</p>

- **Order card** on `blockrun_polymarket_read action:"preview"` — question, outcome, side, best quote, est. shares, notional, cap meter, session ledger. Edit the amount → **Re-quote**. **Place order** is arm-then-confirm and asks the *host* to call `blockrun_polymarket … confirm:true`, so the host's consent prompt and every server cap (`POLYMARKET_MAX_BET_USD`, session cap) still apply.
- **Wallet panel** on `blockrun_wallet` — both chains' balances, switch chain, copy address, EIP-681 / Solana Pay QR, explorer, **Buy USDC with card**.

**📖 Hosts, money path, local testing:** [`docs/mcp-apps.md`](docs/mcp-apps.md)

---

## Fund your wallet

Run `blockrun_wallet` to see your address. The server pays on **Base** by default.
Expand Down Expand Up @@ -469,6 +488,9 @@ Yes. Your private key never leaves your machine (`~/.blockrun/.session` by defau
**Which clients work?**
Any MCP client that can spawn a stdio server. Verified on Claude Code and Codex CLI, in daily use on OpenClaw; install paths documented for Claude Desktop, Cursor, VS Code, Gemini CLI and Windsurf — see the [client table](#1-install). The spend-confirmation dialog additionally needs MCP elicitation (Claude Code, Cursor, VS Code).

**Does it have a UI?**
On MCP-Apps hosts (Claude Desktop, claude.ai, VS Code, Cursor) the Polymarket preview is a live order card and the wallet is a panel — see [MCP Apps ↑](#-mcp-apps-order-card--wallet-panel). Terminal clients get the same information as text.

**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)

Expand Down
22 changes: 22 additions & 0 deletions apps/order-preview.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BlockRun — Polymarket order</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="card" id="root">
<div class="head">
<div>
<p class="title" id="question">Loading order preview…</p>
<p class="sub" id="subtitle"></p>
</div>
<span class="brand">BlockRun · Polymarket</span>
</div>
<div id="body"></div>
</div>
<script type="module" src="./order-preview.ts"></script>
</body>
</html>
220 changes: 220 additions & 0 deletions apps/order-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// apps/order-preview.ts — the Polymarket order card.
//
// Rendered by the host for every blockrun_polymarket_read result. For the
// `preview` action it shows the live quote and lets the user re-quote a
// different amount or place the order. Placing goes through the host as a
// tools/call on blockrun_polymarket with confirm:true — the host's consent
// prompt and the server's caps (POLYMARKET_MAX_BET_USD, session cap) are
// unchanged; this card only replaces the model typing the call.
import { $, autoSize, bootApp, el, resultText, setBusy, structured, usd, type ToolResult } from "./shared";

interface Preview {
dryRun: true;
action: "buy" | "sell";
tokenId: string;
price?: number;
size?: number;
amountUsd?: number;
estimatedSize?: number;
orderType: string;
notionalUsd: number;
tickSize: string;
negRisk: boolean;
question?: string;
outcome?: string;
conditionId?: string;
bestQuote?: number | null;
minSize?: number;
maxBetUsd?: number;
sessionSpentUsd?: number;
sessionCapUsd?: number | null;
expiresAt?: number;
postOnly?: boolean;
}

interface Placed {
orderID?: string;
status?: string;
success?: boolean;
transactionsHashes?: string[];
notionalUsd?: number;
session?: { totalUsd: number; count: number };
}

const app = await bootApp("BlockRun Polymarket order");
autoSize(app);

const questionEl = $("question");
const subtitleEl = $("subtitle");
const body = $("body");

/** The arguments the model passed to blockrun_polymarket_read (we re-use them to re-quote). */
let toolArgs: Record<string, unknown> = {};
app.ontoolinput = (p) => { toolArgs = { ...(p.arguments ?? {}) }; };
app.ontoolresult = (r) => render(r as ToolResult);

function render(r: ToolResult): void {
const s = structured<Record<string, unknown>>(r);
if (r.isError) return renderFallback(resultText(r), true);
if (s && s.dryRun === true) return renderPreview(s as unknown as Preview);
if (s && Array.isArray(s.positions)) return renderTable("Positions", s.positions as Array<Record<string, unknown>>);
if (s && Array.isArray(s.orders)) return renderTable("Open orders", s.orders as Array<Record<string, unknown>>);
renderFallback(resultText(r), false);
}

function renderFallback(text: string, isError: boolean): void {
questionEl.textContent = isError ? "Polymarket" : "Polymarket";
subtitleEl.textContent = isError ? "Error" : "";
body.replaceChildren(el("pre", { class: "fallback" }, text || "(empty result)"));
if (isError) body.firstElementChild?.classList.add("err");
}

function renderTable(title: string, rows: Array<Record<string, unknown>>): void {
questionEl.textContent = title;
subtitleEl.textContent = rows.length ? `${rows.length} item${rows.length === 1 ? "" : "s"}` : "None";
if (!rows.length) { body.replaceChildren(el("div", { class: "note" }, `No ${title.toLowerCase()}.`)); return; }
const cols = Object.keys(rows[0]).filter((k) => typeof rows[0][k] !== "object").slice(0, 6);
const table = el("table");
table.append(el("thead", {}, el("tr", {}, ...cols.map((c) => el("th", {}, c)))));
const tb = el("tbody");
for (const row of rows) {
tb.append(el("tr", {}, ...cols.map((c) => {
const v = row[c];
const isNum = typeof v === "number";
return el("td", { class: isNum ? "num" : "" }, isNum ? String(Number(v.toFixed(4))) : String(v ?? ""));
})));
}
table.append(tb);
body.replaceChildren(table);
}

function kv(k: string, v: string | Node, big = false): HTMLElement {
return el("div", { class: "kv" }, el("span", { class: "k" }, k), el("span", { class: `v${big ? " big" : ""}` }, v));
}

function renderPreview(p: Preview): void {
const isLimit = p.price !== undefined;
const isBuy = p.action === "buy";
questionEl.textContent = p.question ?? `Token ${p.tokenId.slice(0, 12)}…`;
subtitleEl.replaceChildren(
el("span", { class: `pill ${p.action}` }, p.action.toUpperCase()),
" ",
p.outcome ? el("span", { class: "pill" }, p.outcome) : "",
" ",
el("span", {}, `${isLimit ? "Limit" : "Market"} ${p.orderType}${p.postOnly ? " · post-only" : ""}`),
);

const priceLabel = isLimit ? "Limit price" : isBuy ? "Best ask" : "Best bid";
const priceVal = isLimit ? p.price! : p.bestQuote ?? NaN;
const prob = Number.isFinite(priceVal) ? `${(priceVal * 100).toFixed(1)}¢` : "—";
const shares = p.estimatedSize ?? p.size;
const cap = p.maxBetUsd ?? null;
const capPct = cap ? Math.min(100, (p.notionalUsd / cap) * 100) : 0;

const grid = el("div", { class: "grid" },
kv(isBuy ? "You spend" : "You receive (est.)", usd(p.notionalUsd), true),
kv(priceLabel, `${prob} · ${Number.isFinite(priceVal) ? priceVal.toFixed(3) : "—"}`, true),
kv("Shares", shares !== undefined ? `${isLimit ? "" : "≈ "}${shares.toFixed(4)}` : "—"),
kv("Max payout if right", isBuy && shares !== undefined ? usd(shares) : "—"),
kv("Per-order cap", el("span", {}, `${usd(p.notionalUsd)} of ${cap ? usd(cap) : "—"}`, el("div", { class: "meter" }, el("i", { style: `width:${capPct}%` })))),
kv("Session bets", p.sessionCapUsd ? `${usd(p.sessionSpentUsd ?? 0)} of ${usd(p.sessionCapUsd)}` : `${usd(p.sessionSpentUsd ?? 0)} so far`),
kv("Tick · neg-risk · min size", `${p.tickSize} · ${p.negRisk ? "yes" : "no"} · ${p.minSize ?? "n/a"}`),
kv("Fees", "taker-only (CLOB)"),
);

// Editable amount → re-quote through the read-only tool.
const amountField = el("input", { type: "number", min: "0", step: isBuy && !isLimit ? "0.5" : "1", id: "amount" }) as HTMLInputElement;
amountField.value = String(isLimit ? p.size ?? "" : isBuy ? p.amountUsd ?? "" : p.size ?? "");
const amountLabel = isLimit ? "shares" : isBuy ? "USD" : "shares";
const requote = el("button", { class: "small", id: "requote" }, "Re-quote") as HTMLButtonElement;
const place = el("button", { class: "primary", id: "place" }, `Place ${p.action} · ${usd(p.notionalUsd)}`) as HTMLButtonElement;
const cancel = el("button", { class: "small", id: "cancel", hidden: "" }, "Cancel") as HTMLButtonElement;
const note = el("div", { class: "note" }, "Nothing is signed until you place the order. The host will ask for permission before the order tool runs.");

const controls = el("div", { class: "row" },
el("label", { for: "amount" }, isBuy && !isLimit ? "Amount" : "Size"), amountField, el("span", { class: "mono" }, amountLabel), requote,
el("span", { class: "spacer" }), cancel, place,
);
body.replaceChildren(grid, controls, note);

const currentArgs = (): Record<string, unknown> => {
const n = parseFloat(amountField.value);
const base: Record<string, unknown> = {
side: p.action,
token_id: p.tokenId,
order_type: p.orderType,
};
if (isLimit) { base.price = p.price; base.size = n; }
else if (isBuy) base.amount_usd = n;
else base.size = n;
if (p.expiresAt) base.expires_at = p.expiresAt;
if (p.postOnly) base.post_only = true;
return base;
};

requote.addEventListener("click", async () => {
setBusy(requote, true, "Quoting…");
try {
const r = (await app.callServerTool({ name: "blockrun_polymarket_read", arguments: { action: "preview", ...currentArgs() } })) as ToolResult;
if (r.isError) { note.className = "note err"; note.textContent = resultText(r); }
else render(r);
} catch (e) {
note.className = "note err"; note.textContent = String((e as Error).message ?? e);
} finally {
setBusy(requote, false, "Re-quote");
}
});

// Two-step arm → confirm, so a stray click never signs.
let armed = false;
const disarm = () => { armed = false; place.textContent = `Place ${p.action} · ${usd(p.notionalUsd)}`; place.classList.remove("danger"); cancel.hidden = true; };
cancel.addEventListener("click", disarm);
place.addEventListener("click", async () => {
if (!armed) {
armed = true;
place.textContent = `Confirm — sign & submit ${usd(p.notionalUsd)}`;
place.classList.add("danger");
cancel.hidden = false;
return;
}
const args = { action: p.action, ...currentArgs(), confirm: true };
delete (args as Record<string, unknown>).side;
setBusy(place, true, "Submitting…"); setBusy(requote, true); cancel.hidden = true;
try {
const r = (await app.callServerTool({ name: "blockrun_polymarket", arguments: args })) as ToolResult;
if (r.isError) {
note.className = "note err"; note.textContent = resultText(r);
disarm(); setBusy(place, false); setBusy(requote, false);
return;
}
renderPlaced(p, structured<Placed>(r) ?? {}, resultText(r));
void app.updateModelContext({
content: [{ type: "text", text: `User placed the order from the order card: ${resultText(r)}` }],
structuredContent: (r.structuredContent ?? {}) as Record<string, unknown>,
}).catch(() => {});
} catch (e) {
note.className = "note err"; note.textContent = String((e as Error).message ?? e);
disarm(); setBusy(place, false); setBusy(requote, false);
}
});
}

function renderPlaced(p: Preview, r: Placed, text: string): void {
subtitleEl.replaceChildren(el("span", { class: `pill ${p.action}` }, p.action.toUpperCase()), " ", el("span", { class: "pill active" }, r.status ?? "submitted"));
const txs = (r.transactionsHashes ?? []).map((h) => {
const a = el("a", { href: "#", class: "mono" }, `${h.slice(0, 10)}…${h.slice(-6)}`);
a.addEventListener("click", (ev) => { ev.preventDefault(); void app.openLink({ url: `https://polygonscan.com/tx/${h}` }); });
return a;
});
body.replaceChildren(
el("div", { class: "note ok" }, "✅ Order submitted"),
el("div", { class: "grid" },
kv("Order ID", el("span", { class: "mono" }, r.orderID ?? "n/a")),
kv("Notional", usd(r.notionalUsd ?? p.notionalUsd)),
kv("Status", r.status ?? "submitted"),
kv("Session bets", r.session ? `${usd(r.session.totalUsd)} across ${r.session.count}` : "—"),
...(txs.length ? [kv("Transactions", el("span", {}, ...txs.flatMap((a, i) => (i ? [", ", a] : [a]))))] : []),
),
el("details", {}, el("summary", {}, "Raw result"), el("pre", { class: "fallback" }, text)),
);
}
Loading
Loading