diff --git a/.gitignore b/.gitignore
index 406f2de..3e67ea3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ server.json
docs/plans/
pnpm-workspace.yaml
.claude/
+ui/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec38fec..ea3fcbb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index f251f1b..2f07173 100644
--- a/README.md
+++ b/README.md
@@ -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.
---
@@ -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
@@ -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.
+
+
+
+
+
+
+
+- **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.
@@ -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)
diff --git a/apps/order-preview.html b/apps/order-preview.html
new file mode 100644
index 0000000..c0b218d
--- /dev/null
+++ b/apps/order-preview.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ BlockRun — Polymarket order
+
+
+
+
+
+
+
Loading order preview…
+
+
+
BlockRun · Polymarket
+
+
+
+
+
+
diff --git a/apps/order-preview.ts b/apps/order-preview.ts
new file mode 100644
index 0000000..46be763
--- /dev/null
+++ b/apps/order-preview.ts
@@ -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 = {};
+app.ontoolinput = (p) => { toolArgs = { ...(p.arguments ?? {}) }; };
+app.ontoolresult = (r) => render(r as ToolResult);
+
+function render(r: ToolResult): void {
+ const s = structured>(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>);
+ if (s && Array.isArray(s.orders)) return renderTable("Open orders", s.orders as Array>);
+ 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>): 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 => {
+ const n = parseFloat(amountField.value);
+ const base: Record = {
+ 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).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(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,
+ }).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)),
+ );
+}
diff --git a/apps/shared.ts b/apps/shared.ts
new file mode 100644
index 0000000..408fca1
--- /dev/null
+++ b/apps/shared.ts
@@ -0,0 +1,98 @@
+// apps/shared.ts — what both MCP Apps have in common: host handshake, theme
+// wiring, tool-result plumbing, and a few DOM helpers. No framework.
+import {
+ App,
+ applyDocumentTheme,
+ applyHostFonts,
+ applyHostStyleVariables,
+ type McpUiHostContext,
+} from "@modelcontextprotocol/ext-apps";
+import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+
+export type ToolResult = CallToolResult & { structuredContent?: Record };
+
+/** Create the App, wire host theme/styles, connect. */
+export async function bootApp(name: string): Promise {
+ const app = new App({ name, version: "1" });
+ const applyContext = (ctx: McpUiHostContext | undefined) => {
+ if (!ctx) return;
+ if (ctx.theme) applyDocumentTheme(ctx.theme);
+ if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
+ if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
+ };
+ app.onhostcontextchanged = (ctx) => applyContext(ctx);
+ await app.connect();
+ applyContext(app.getHostContext());
+ return app;
+}
+
+/** Text content of a tool result, joined. */
+export function resultText(r: ToolResult | undefined): string {
+ if (!r) return "";
+ return (r.content ?? [])
+ .map((c) => (c.type === "text" ? (c as { text: string }).text : ""))
+ .filter(Boolean)
+ .join("\n");
+}
+
+export function structured>(r: ToolResult | undefined): T | undefined {
+ return (r?.structuredContent ?? undefined) as T | undefined;
+}
+
+export function $(id: string): T {
+ const el = document.getElementById(id);
+ if (!el) throw new Error(`missing #${id}`);
+ return el as T;
+}
+
+export function el(tag: string, attrs: Record = {}, ...children: Array): HTMLElement {
+ const n = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs)) {
+ if (k === "class") n.className = v;
+ else n.setAttribute(k, v);
+ }
+ for (const c of children) n.append(c);
+ return n;
+}
+
+export function usd(n: number | null | undefined, digits = 2): string {
+ if (n === null || n === undefined || !Number.isFinite(n)) return "—";
+ return `$${n.toFixed(digits)}`;
+}
+
+export function shortAddr(a: string): string {
+ return a.length > 14 ? `${a.slice(0, 6)}…${a.slice(-4)}` : a;
+}
+
+/** Tell the host our height so the iframe fits the card instead of scrolling. */
+export function reportSize(app: App): void {
+ const h = document.documentElement.scrollHeight;
+ void app.sendSizeChanged({ height: h }).catch(() => {});
+}
+
+/** Debounced size reporting on any DOM change. */
+export function autoSize(app: App): void {
+ let t: number | undefined;
+ const kick = () => {
+ if (t) window.clearTimeout(t);
+ t = window.setTimeout(() => reportSize(app), 30);
+ };
+ new MutationObserver(kick).observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true });
+ window.addEventListener("resize", kick);
+ kick();
+}
+
+export function setBusy(button: HTMLButtonElement, busy: boolean, label?: string): void {
+ button.disabled = busy;
+ if (label !== undefined) button.textContent = label;
+ button.classList.toggle("busy", busy);
+}
+
+export async function copyText(text: string): Promise {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/apps/styles.css b/apps/styles.css
new file mode 100644
index 0000000..ab9e064
--- /dev/null
+++ b/apps/styles.css
@@ -0,0 +1,85 @@
+/* apps/styles.css — shared look for both BlockRun MCP Apps.
+ Every colour/size falls back sensibly when the host sends no style vars. */
+:root {
+ --bg: var(--color-background-primary, #ffffff);
+ --bg2: var(--color-background-secondary, #f5f5f7);
+ --bg3: var(--color-background-tertiary, #ececf1);
+ --fg: var(--color-text-primary, #111111);
+ --fg2: var(--color-text-secondary, #5f6368);
+ --fg3: var(--color-text-tertiary, #8a8f98);
+ --border: var(--color-border-primary, #e3e3e8);
+ --ok: var(--color-text-success, #1a7f37);
+ --okbg: var(--color-background-success, #e6f4ea);
+ --danger: var(--color-text-danger, #b42318);
+ --dangerbg: var(--color-background-danger, #fdecea);
+ --warn: var(--color-text-warning, #9a6700);
+ --warnbg: var(--color-background-warning, #fff8e1);
+ --info: var(--color-text-info, #1b5fd9);
+ --infobg: var(--color-background-info, #e8f0fe);
+ --accent: #2563eb;
+ --sans: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif);
+ --mono: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
+ --r: var(--border-radius-md, 10px);
+ --rs: var(--border-radius-sm, 6px);
+}
+html[data-theme="dark"], .dark {
+ --bg: var(--color-background-primary, #171717);
+ --bg2: var(--color-background-secondary, #202020);
+ --bg3: var(--color-background-tertiary, #2a2a2a);
+ --fg: var(--color-text-primary, #f2f2f2);
+ --fg2: var(--color-text-secondary, #b3b3b3);
+ --fg3: var(--color-text-tertiary, #808080);
+ --border: var(--color-border-primary, #333333);
+ --okbg: var(--color-background-success, #10321a);
+ --dangerbg: var(--color-background-danger, #3a1512);
+ --warnbg: var(--color-background-warning, #3a2e0a);
+ --infobg: var(--color-background-info, #12244a);
+ --accent: #60a5fa;
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; background: transparent; color: var(--fg); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
+.card { background: var(--bg); border: 1px solid var(--border); border-radius: var(--r); padding: 16px; max-width: 640px; }
+.head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
+.title { font-weight: 600; font-size: 15px; margin: 0; }
+.sub { color: var(--fg2); font-size: 12px; margin: 2px 0 0; }
+.brand { color: var(--fg3); font-size: 11px; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; }
+.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; border: 1px solid var(--border); background: var(--bg2); }
+.pill.buy { color: var(--ok); background: var(--okbg); border-color: transparent; }
+.pill.sell { color: var(--danger); background: var(--dangerbg); border-color: transparent; }
+.pill.active { color: var(--info); background: var(--infobg); border-color: transparent; }
+.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 16px; margin: 12px 0; }
+.kv { display: flex; flex-direction: column; gap: 1px; }
+.kv .k { color: var(--fg3); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
+.kv .v { font-variant-numeric: tabular-nums; font-size: 15px; }
+.kv .v.big { font-size: 20px; font-weight: 600; }
+.mono { font-family: var(--mono); font-size: 12px; }
+.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.row.end { justify-content: flex-end; }
+.spacer { flex: 1; }
+input[type="number"] { width: 120px; padding: 6px 8px; border: 1px solid var(--border); border-radius: var(--rs); background: var(--bg2); color: var(--fg); font: inherit; font-variant-numeric: tabular-nums; }
+button { font: inherit; font-weight: 600; padding: 7px 14px; border-radius: var(--rs); border: 1px solid var(--border); background: var(--bg2); color: var(--fg); cursor: pointer; }
+button:hover:not(:disabled) { background: var(--bg3); }
+button:disabled { opacity: .55; cursor: default; }
+button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+button.primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--accent); }
+button.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
+button.small { padding: 4px 10px; font-size: 12px; font-weight: 500; }
+.note { margin-top: 10px; padding: 8px 10px; border-radius: var(--rs); font-size: 12px; background: var(--bg2); color: var(--fg2); }
+.note.ok { background: var(--okbg); color: var(--ok); }
+.note.err { background: var(--dangerbg); color: var(--danger); }
+.note.warn { background: var(--warnbg); color: var(--warn); }
+.meter { height: 6px; border-radius: 3px; background: var(--bg3); overflow: hidden; margin-top: 4px; }
+.meter > i { display: block; height: 100%; background: var(--accent); }
+pre.fallback { white-space: pre-wrap; word-break: break-word; font-family: var(--mono); font-size: 12px; background: var(--bg2); padding: 10px; border-radius: var(--rs); margin: 0; }
+table { width: 100%; border-collapse: collapse; font-size: 13px; }
+th, td { text-align: left; padding: 6px 4px; border-bottom: 1px solid var(--border); }
+th { color: var(--fg3); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
+td.num { text-align: right; font-variant-numeric: tabular-nums; }
+.chains { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 12px 0; }
+.chain { border: 1px solid var(--border); border-radius: var(--r); padding: 12px; background: var(--bg2); }
+.chain.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
+.chain .name { font-weight: 600; display: flex; justify-content: space-between; align-items: center; }
+.chain .bal { font-size: 22px; font-weight: 600; margin: 6px 0 2px; font-variant-numeric: tabular-nums; }
+.chain .addr { color: var(--fg2); }
+.qr { display: block; margin: 10px auto 0; width: 168px; height: 168px; border-radius: var(--rs); background: #fff; padding: 6px; }
+@media (max-width: 440px) { .grid, .chains { grid-template-columns: 1fr; } }
diff --git a/apps/vite.config.ts b/apps/vite.config.ts
new file mode 100644
index 0000000..b2d2cb4
--- /dev/null
+++ b/apps/vite.config.ts
@@ -0,0 +1,31 @@
+// apps/vite.config.ts — builds ONE app per invocation into ../ui/.html.
+//
+// vite build --config apps/vite.config.ts --mode order-preview
+// vite build --config apps/vite.config.ts --mode wallet
+//
+// `--mode` picks the entry so each bundle is a self-contained single HTML
+// file (vite-plugin-singlefile inlines JS + CSS). MCP hosts render the
+// resource in a deny-by-default CSP sandbox, so nothing may be loaded from a
+// URL — everything must be inline.
+import { defineConfig } from "vite";
+import { viteSingleFile } from "vite-plugin-singlefile";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
+
+const here = dirname(fileURLToPath(import.meta.url));
+
+export default defineConfig(({ mode }) => {
+ const entry = mode === "wallet" ? "wallet" : "order-preview";
+ return {
+ root: here,
+ plugins: [viteSingleFile()],
+ build: {
+ outDir: resolve(here, "..", "ui"),
+ emptyOutDir: false,
+ rollupOptions: { input: resolve(here, `${entry}.html`) },
+ target: "es2022",
+ minify: true,
+ },
+ logLevel: "warn",
+ };
+});
diff --git a/apps/wallet.html b/apps/wallet.html
new file mode 100644
index 0000000..b401d89
--- /dev/null
+++ b/apps/wallet.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ BlockRun — Wallet
+
+
+
+
+
+
+
BlockRun wallet
+
Loading…
+
+
USDC · x402
+
+
+
+
+
+
diff --git a/apps/wallet.ts b/apps/wallet.ts
new file mode 100644
index 0000000..7c841aa
--- /dev/null
+++ b/apps/wallet.ts
@@ -0,0 +1,125 @@
+// apps/wallet.ts — the wallet panel.
+//
+// Rendered by the host for every blockrun_wallet result. For `status` it
+// shows both chains, lets the user switch the active chain, copy an address,
+// show a funding QR, and open the card on-ramp — each of which is a
+// tools/call on blockrun_wallet through the host.
+import QRCode from "qrcode";
+import { $, autoSize, bootApp, copyText, el, resultText, setBusy, shortAddr, structured, type ToolResult } from "./shared";
+
+interface Status {
+ activeChain: "base" | "solana";
+ address: string;
+ balance: number | null;
+ explorerUrl: string;
+ explorerLabel: string;
+ isNew?: boolean;
+ wallets: { base: { address: string; balance: number | null }; solana: { address: string; balance: number | null } };
+}
+
+// Payment-request URIs, same encoding as src/utils/qr.ts (EIP-681 on Base,
+// Solana Pay on Solana) so a wallet app that scans the QR pre-fills USDC.
+const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
+const USDC_SOL_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
+const paymentUri = (chain: "base" | "solana", address: string) =>
+ chain === "solana"
+ ? `solana:${address}?spl-token=${USDC_SOL_MINT}&label=BlockRun`
+ : `ethereum:${USDC_BASE}@8453/transfer?address=${address}`;
+
+const app = await bootApp("BlockRun wallet");
+autoSize(app);
+
+const subtitle = $("subtitle");
+const body = $("body");
+
+app.ontoolresult = (r) => render(r as ToolResult);
+
+async function call(args: Record): Promise {
+ return (await app.callServerTool({ name: "blockrun_wallet", arguments: args })) as ToolResult;
+}
+
+function render(r: ToolResult): void {
+ const s = structured>(r);
+ if (!r.isError && s && s.wallets) return renderStatus(s as unknown as Status);
+ if (!r.isError && s && typeof s.onramp_url === "string") return renderOnramp(s.onramp_url as string, resultText(r));
+ renderFallback(resultText(r), Boolean(r.isError));
+}
+
+function renderFallback(text: string, isError: boolean): void {
+ subtitle.textContent = isError ? "Error" : "";
+ const refresh = el("button", { class: "small" }, "Show balances") as HTMLButtonElement;
+ refresh.addEventListener("click", async () => { setBusy(refresh, true, "Loading…"); try { render(await call({ action: "status" })); } finally { setBusy(refresh, false, "Show balances"); } });
+ body.replaceChildren(el("pre", { class: `fallback${isError ? " err" : ""}` }, text || "(empty result)"), el("div", { class: "row end", style: "margin-top:10px" }, refresh));
+}
+
+function renderOnramp(url: string, text: string): void {
+ subtitle.textContent = "Card top-up";
+ const open = el("button", { class: "primary" }, "Open Coinbase Onramp") as HTMLButtonElement;
+ open.addEventListener("click", () => { void app.openLink({ url }); });
+ const back = el("button", { class: "small" }, "Back to balances") as HTMLButtonElement;
+ back.addEventListener("click", async () => { setBusy(back, true); render(await call({ action: "status" })); });
+ body.replaceChildren(
+ el("div", { class: "note" }, text),
+ el("div", { class: "row end", style: "margin-top:10px" }, back, open),
+ );
+}
+
+function renderStatus(s: Status): void {
+ subtitle.textContent = `Paying on ${s.activeChain === "solana" ? "Solana" : "Base"} · self-custody · pay-per-call`;
+ const note = el("div", { class: "note", hidden: "" });
+
+ const chainCard = (chain: "base" | "solana") => {
+ const w = s.wallets[chain];
+ const active = s.activeChain === chain;
+ const bal = w.balance;
+ const low = bal !== null && bal < 1;
+ const useBtn = el("button", { class: "small" }, active ? "Active" : `Use ${chain === "base" ? "Base" : "Solana"}`) as HTMLButtonElement;
+ useBtn.disabled = active;
+ if (active) useBtn.className = "pill active";
+ useBtn.addEventListener("click", async () => {
+ setBusy(useBtn, true, "Switching…");
+ try {
+ const r = await call({ action: "chain", chain });
+ if (r.isError) { note.hidden = false; note.className = "note err"; note.textContent = resultText(r); setBusy(useBtn, false, `Use ${chain}`); return; }
+ render(await call({ action: "status" }));
+ } catch (e) { note.hidden = false; note.className = "note err"; note.textContent = String((e as Error).message ?? e); setBusy(useBtn, false); }
+ });
+
+ const copy = el("button", { class: "small" }, "Copy") as HTMLButtonElement;
+ copy.addEventListener("click", async () => { copy.textContent = (await copyText(w.address)) ? "Copied" : "Copy failed"; setTimeout(() => (copy.textContent = "Copy"), 1500); });
+ const qrBtn = el("button", { class: "small" }, "QR") as HTMLButtonElement;
+ const qrHolder = el("div");
+ qrBtn.addEventListener("click", async () => {
+ if (qrHolder.childElementCount) { qrHolder.replaceChildren(); qrBtn.textContent = "QR"; return; }
+ const dataUrl = await QRCode.toDataURL(paymentUri(chain, w.address), { margin: 1, width: 168 });
+ qrHolder.replaceChildren(el("img", { class: "qr", src: dataUrl, alt: `${chain} funding QR` }), el("div", { class: "sub", style: "text-align:center;margin-top:6px" }, chain === "solana" ? "Send USDC (SPL) on Solana" : "Send USDC on Base"));
+ qrBtn.textContent = "Hide QR";
+ });
+
+ return el("div", { class: `chain${active ? " active" : ""}` },
+ el("div", { class: "name" }, chain === "base" ? "Base" : "Solana", useBtn),
+ el("div", { class: "bal" }, bal === null ? "—" : `$${bal.toFixed(2)}`, el("span", { class: "sub", style: "font-size:12px;font-weight:400" }, " USDC")),
+ low ? el("div", { class: "sub", style: "color:var(--warn)" }, "Low balance") : el("div", { class: "sub" }, " "),
+ el("div", { class: "row", style: "margin-top:8px" }, el("span", { class: "mono addr", title: w.address }, shortAddr(w.address)), copy, qrBtn),
+ qrHolder,
+ );
+ };
+
+ const buy = el("button", { class: "primary" }, "Buy USDC with card") as HTMLButtonElement;
+ buy.addEventListener("click", async () => {
+ setBusy(buy, true, "Minting link…");
+ try { render(await call({ action: "deposit" })); } catch (e) { note.hidden = false; note.className = "note err"; note.textContent = String((e as Error).message ?? e); }
+ finally { setBusy(buy, false, "Buy USDC with card"); }
+ });
+ const explorer = el("button", { class: "small" }, s.explorerLabel || "Explorer") as HTMLButtonElement;
+ explorer.addEventListener("click", () => { void app.openLink({ url: s.explorerUrl }); });
+ const refresh = el("button", { class: "small" }, "Refresh") as HTMLButtonElement;
+ refresh.addEventListener("click", async () => { setBusy(refresh, true, "…"); try { render(await call({ action: "status" })); } finally { setBusy(refresh, false, "Refresh"); } });
+
+ body.replaceChildren(
+ el("div", { class: "chains" }, chainCard("base"), chainCard("solana")),
+ s.isNew ? el("div", { class: "note warn" }, "New wallet on the active chain — fund it before paid calls.") : "",
+ note,
+ el("div", { class: "row end", style: "margin-top:10px" }, refresh, explorer, buy),
+ );
+}
diff --git a/assets/mcp-apps/order-card.png b/assets/mcp-apps/order-card.png
new file mode 100644
index 0000000..e560ab1
Binary files /dev/null and b/assets/mcp-apps/order-card.png differ
diff --git a/assets/mcp-apps/wallet-panel.png b/assets/mcp-apps/wallet-panel.png
new file mode 100644
index 0000000..3c6bf68
Binary files /dev/null and b/assets/mcp-apps/wallet-panel.png differ
diff --git a/docs/mcp-apps.md b/docs/mcp-apps.md
new file mode 100644
index 0000000..2925c1a
--- /dev/null
+++ b/docs/mcp-apps.md
@@ -0,0 +1,98 @@
+# MCP Apps — the order card and the wallet panel
+
+BlockRun MCP ships two **MCP Apps**: interactive cards that a capable host renders inline
+in the conversation instead of a wall of text. They use the official
+[MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview)
+(`io.modelcontextprotocol/ui`). On a host without the extension nothing changes — the tool
+returns the same text it always has.
+
+| App | Attached to | What it does |
+|---|---|---|
+| **Order card** `ui://blockrun/order-preview.html` | `blockrun_polymarket_read` | Live Polymarket order preview: question, outcome, side, best quote, est. shares, notional, per-order cap meter, session ledger. Edit the amount and re-quote. **Place order** signs and submits through the host. |
+| **Wallet panel** `ui://blockrun/wallet.html` | `blockrun_wallet` | Both chains' USDC balances, switch the active chain, copy an address, show a funding QR (EIP-681 / Solana Pay), open the explorer, buy USDC with a card. |
+
+
+
+
+
+
+
+*Rendered by the MCPJam inspector against the published server, 2026-08-30.*
+
+## Where they render
+
+| Host | Renders MCP Apps | Notes |
+|---|---|---|
+| Claude Desktop | ✅ | Local stdio server, no extra setup. |
+| claude.ai | ✅ | Via a remote connector; a local stdio server needs a tunnel (see [testing](#testing-locally)). |
+| VS Code (Copilot) | ✅ | |
+| Cursor | ✅ | |
+| ChatGPT | ✅ | Remote connectors only. |
+| MCPJam inspector | ✅ | Simulates Claude / ChatGPT / Cursor side by side — the screenshots above. |
+| Claude Code, Codex CLI, Gemini CLI, Windsurf | ❌ | Terminal / no extension — the text result is shown, unchanged. |
+
+Sources: the MCP [client support matrix](https://modelcontextprotocol.io/extensions/client-matrix).
+
+## How placing an order works — and what still guards it
+
+The card is a UI over the same two tools the model already uses. Nothing is signed by the card
+itself.
+
+1. `blockrun_polymarket_read action:"preview"` returns the quote; the host renders the card
+ with it.
+2. **Re-quote** calls `blockrun_polymarket_read` again with the new amount — read-only.
+3. **Place buy / sell** is two clicks (arm, then *Confirm — sign & submit $X*). The card then
+ asks the **host** to call `blockrun_polymarket action:"buy"|"sell" … confirm:true`.
+4. The host applies its own tool-consent prompt (it is a real `tools/call` from an app), then
+ the server applies every existing rail: `POLYMARKET_MAX_BET_USD` per order (default $25),
+ `POLYMARKET_MAX_SESSION_USD`, minimum size, FOK fillability, the closed-market guard.
+5. The card shows the order id, status and any transaction hashes, and pushes the result
+ into the model's context so the conversation knows what happened.
+
+A declined host prompt or a server-side cap comes back as an error *inside the card*; nothing
+was placed.
+
+## Wallet panel actions
+
+| Button | Tool call |
+|---|---|
+| Use Base / Use Solana | `blockrun_wallet action:"chain" chain:"…"` then `action:"status"` |
+| Refresh | `blockrun_wallet action:"status"` |
+| Buy USDC with card | `blockrun_wallet action:"deposit"` → opens the one-time Coinbase Onramp link via the host (`ui/open-link`) |
+| Basescan / Solscan | `ui/open-link` to the explorer |
+| Copy | clipboard (`permissions.clipboardWrite` is requested on the resource) |
+| QR | generated in the panel; same EIP-681 / Solana Pay encoding as `action:"qr"` |
+
+## Profiles
+
+Apps are registered only when their tool is in the active profile: the wallet panel in every
+profile, the order card in `full` and `trading`. A trimmed profile never advertises a
+`ui://` resource for a tool it excludes.
+
+## Testing locally
+
+- **MCPJam** (stdio, no tunnel): `npx @mcpjam/inspector@latest`, add a STDIO server with
+ command `npx -y @blockrun/mcp@latest --profile trading`, open **Playground**, pick a tool,
+ fill the parameters, **Run**. The Chat pane renders the app in three simulated hosts.
+- **Claude Desktop**: install as usual (`claude_desktop_config.json`) and ask for your wallet
+ status or a Polymarket preview.
+- **claude.ai**: a local stdio server is not reachable; expose an HTTP transport and tunnel it
+ (`npx cloudflared tunnel --url …`), then add it as a custom connector.
+
+## Building
+
+The apps live in `apps/` (vanilla TypeScript, no framework) and are bundled by
+`vite-plugin-singlefile` into `ui/*.html` — one self-contained file each, because the host
+renders them in a deny-by-default CSP sandbox where nothing may load from a URL.
+`npm run build:apps` runs as part of `npm run build` and before `npm test`; the bundles are
+gitignored and shipped in the npm tarball. `test/apps.test.ts` pins the `_meta.ui` wiring,
+per-profile registration, and that each bundle really is self-contained.
+
+## Limitations
+
+- Bundles are ~350 KB each: the `@modelcontextprotocol/ext-apps` `App` class pulls in the
+ SDK protocol layer. Fine over stdio; a hand-rolled postMessage client would be ~10× smaller.
+- The card re-quotes on demand, not on a timer. A quote is as fresh as the last click.
+- Sell previews need `size` (shares); the card edits that field, not USD.
+- The two-column layouts collapse under ~440 px; MCPJam's side-by-side preview is narrower
+ than any real host, so it shows the stacked variant.
diff --git a/package-lock.json b/package-lock.json
index 6bac37f..17ef54e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@blockrun/mcp",
- "version": "0.43.0",
+ "version": "0.44.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@blockrun/mcp",
- "version": "0.43.0",
+ "version": "0.44.0",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
@@ -29,11 +29,14 @@
"blockrun-mcp": "dist/index.js"
},
"devDependencies": {
+ "@modelcontextprotocol/ext-apps": "^1.7.5",
"@types/node": "^20.0.0",
"@types/qrcode": "^1.5.6",
"tsup": "^8.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0",
+ "vite": "^8.2.2",
+ "vite-plugin-singlefile": "^2.3.3",
"yaml": "^2.9.0"
},
"engines": {
@@ -1805,6 +1808,36 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@modelcontextprotocol/ext-apps": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz",
+ "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "examples/*"
+ ],
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@modelcontextprotocol/sdk": "^1.29.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -1857,6 +1890,16 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@oxc-project/types": {
+ "version": "0.147.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
+ "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
"node_modules/@polymarket/builder-abstract-signer": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@polymarket/builder-abstract-signer/-/builder-abstract-signer-0.0.1.tgz",
@@ -1921,6 +1964,268 @@
"node": ">=20.10"
}
},
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
+ "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
+ "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
+ "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
+ "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
+ "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
+ "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
+ "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
+ "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
+ "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
+ "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
+ "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
+ "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
+ "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
+ "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz",
+ "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
@@ -2721,6 +3026,13 @@
"base-x": "^3.0.2"
}
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
@@ -3130,6 +3442,19 @@
"base-x": "^3.0.2"
}
},
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/brorand": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
@@ -3555,8 +3880,8 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "devOptional": true,
"license": "Apache-2.0",
- "optional": true,
"engines": {
"node": ">=8"
}
@@ -3961,6 +4286,19 @@
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -4408,6 +4746,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -4561,17 +4909,278 @@
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC"
},
- "node_modules/lilconfig": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
- "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
- "license": "MIT",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
"engines": {
- "node": ">=14"
+ "node": ">= 12.0.0"
},
"funding": {
- "url": "https://github.com/sponsors/antonk52"
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/lines-and-columns": {
@@ -4649,6 +5258,33 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/micromatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -4717,6 +5353,25 @@
"thenify-all": "^1.0.0"
}
},
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -5014,9 +5669,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5066,6 +5721,35 @@
"node": ">=10.13.0"
}
},
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
"node_modules/postcss-load-config": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
@@ -5269,6 +5953,40 @@
"node": ">=8"
}
},
+ "node_modules/rolldown": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
+ "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.147.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.6",
+ "@rolldown/binding-android-arm64": "1.2.6",
+ "@rolldown/binding-darwin-arm64": "1.2.6",
+ "@rolldown/binding-darwin-x64": "1.2.6",
+ "@rolldown/binding-freebsd-x64": "1.2.6",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.6",
+ "@rolldown/binding-linux-arm64-musl": "1.2.6",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.6",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-musl": "1.2.6",
+ "@rolldown/binding-openharmony-arm64": "1.2.6",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.6",
+ "@rolldown/binding-win32-x64-msvc": "1.2.6"
+ }
+ },
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -5624,6 +6342,16 @@
"node": ">= 12"
}
},
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
@@ -5787,6 +6515,19 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -6552,6 +7293,106 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/vite": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-plugin-singlefile": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz",
+ "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">18.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^4.59.0",
+ "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
diff --git a/package.json b/package.json
index b25eec0..b8809d0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@blockrun/mcp",
- "version": "0.43.0",
+ "version": "0.44.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",
@@ -12,10 +12,11 @@
"files": [
"dist",
"skills",
+ "ui",
"README.md"
],
"scripts": {
- "build": "tsup src/index.ts --format esm --dts --clean --external sharp",
+ "build": "npm run build:apps && tsup src/index.ts --format esm --dts --clean --external sharp",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
@@ -26,7 +27,9 @@
"e2e:polymarket:approvals": "tsx scripts/polymarket-e2e-verify-approvals.ts",
"e2e:polymarket:approve": "tsx scripts/polymarket-e2e-approve.ts",
"e2e:polymarket:withdraw": "tsx scripts/polymarket-e2e-withdraw.ts",
- "e2e:polymarket:live": "tsx scripts/polymarket-e2e-live.ts"
+ "e2e:polymarket:live": "tsx scripts/polymarket-e2e-live.ts",
+ "build:apps": "vite build --config apps/vite.config.ts --mode order-preview && vite build --config apps/vite.config.ts --mode wallet",
+ "pretest": "npm run build:apps"
},
"keywords": [
"mcp",
@@ -80,11 +83,14 @@
"ip-address": "10.5.0"
},
"devDependencies": {
+ "@modelcontextprotocol/ext-apps": "^1.7.5",
"@types/node": "^20.0.0",
"@types/qrcode": "^1.5.6",
"tsup": "^8.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0",
+ "vite": "^8.2.2",
+ "vite-plugin-singlefile": "^2.3.3",
"yaml": "^2.9.0"
},
"engines": {
diff --git a/src/apps.ts b/src/apps.ts
new file mode 100644
index 0000000..c6bafe3
--- /dev/null
+++ b/src/apps.ts
@@ -0,0 +1,111 @@
+// src/apps.ts
+//
+// MCP Apps (extension io.modelcontextprotocol/ui): interactive HTML that a
+// capable host — Claude Desktop, claude.ai, VS Code, Cursor — renders inline
+// in place of a tool's text result. Two apps ship:
+//
+// ui://blockrun/order-preview.html attached to blockrun_polymarket_read
+// A live order card for the `preview` action: quote, est. shares,
+// notional, caps, an editable amount that re-quotes, and a Place-order
+// button that calls blockrun_polymarket with confirm:true THROUGH THE
+// HOST — so the host's own tool-consent prompt and every server-side cap
+// (POLYMARKET_MAX_BET_USD, session cap) still apply.
+//
+// ui://blockrun/wallet.html attached to blockrun_wallet
+// Both chains' balances, active-chain switch, address + QR, card top-up.
+//
+// Hosts without the extension ignore `_meta.ui` and get the text result
+// exactly as before (the spec's mandated fallback) — Claude Code, Codex CLI
+// and every other terminal client see no difference.
+//
+// The bundles are single-file HTML (vite-plugin-singlefile) built from apps/
+// into ui/ at build time and shipped in the npm tarball. They are resolved
+// from the package root the same way skills/ is, so this works from src/
+// under tsx and from the tsup bundle in dist/.
+
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+
+/** MIME type the host uses to recognise an MCP App resource. */
+export const APP_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
+
+export const APP_URIS = {
+ orderPreview: "ui://blockrun/order-preview.html",
+ wallet: "ui://blockrun/wallet.html",
+} as const;
+
+export type AppName = keyof typeof APP_URIS;
+
+const APP_FILES: Record = {
+ orderPreview: "order-preview.html",
+ wallet: "wallet.html",
+};
+
+/** `_meta` to put on a tool so a capable host renders the app for its results. */
+export function appToolMeta(app: AppName): { ui: { resourceUri: string } } {
+ return { ui: { resourceUri: APP_URIS[app] } };
+}
+
+export const UI_DIR = locateUiDir(fileURLToPath(import.meta.url));
+
+function locateUiDir(start: string): string {
+ let dir = dirname(start);
+ for (let i = 0; i < 5; i++) {
+ if (existsSync(join(dir, "package.json")) && existsSync(join(dir, "ui"))) return join(dir, "ui");
+ const parent = dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ return join(dirname(dirname(start)), "ui");
+}
+
+/** Read a built app bundle. Throws a clear error if `npm run build:apps` never ran. */
+export function readAppHtml(app: AppName): string {
+ const file = join(UI_DIR, APP_FILES[app]);
+ if (!existsSync(file)) {
+ throw new Error(`MCP App bundle missing: ${file}. Run \`npm run build:apps\` (it is part of \`npm run build\`).`);
+ }
+ return readFileSync(file, "utf8");
+}
+
+/**
+ * Register the UI resources for whichever apps' tools are active. Gated on
+ * the tool set exactly like the wallet/models resources, so a trimmed profile
+ * never advertises an app for a tool it excluded.
+ */
+export function registerAppResources(server: McpServer, tools: Set): AppName[] {
+ const wanted: Array<[AppName, string]> = [
+ ["orderPreview", "polymarket_read"],
+ ["wallet", "wallet"],
+ ];
+ const registered: AppName[] = [];
+ for (const [app, tool] of wanted) {
+ if (!tools.has(tool)) continue;
+ const uri = APP_URIS[app];
+ server.registerResource(
+ `app-${APP_FILES[app].replace(/\.html$/, "")}`,
+ uri,
+ {
+ description: app === "orderPreview"
+ ? "Polymarket order-preview card (MCP App) for blockrun_polymarket_read"
+ : "Wallet balance / top-up panel (MCP App) for blockrun_wallet",
+ mimeType: APP_RESOURCE_MIME_TYPE,
+ // Resource-level UI metadata: the app copies addresses, so ask for
+ // clipboard; a card wants the host's border/background chrome.
+ _meta: { ui: { prefersBorder: true, permissions: { clipboardWrite: {} } } },
+ },
+ async () => ({
+ contents: [{
+ uri,
+ mimeType: APP_RESOURCE_MIME_TYPE,
+ text: readAppHtml(app),
+ _meta: { ui: { prefersBorder: true, permissions: { clipboardWrite: {} } } },
+ }],
+ }),
+ );
+ registered.push(app);
+ }
+ return registered;
+}
diff --git a/src/mcp-handler.ts b/src/mcp-handler.ts
index a9d890d..833a519 100644
--- a/src/mcp-handler.ts
+++ b/src/mcp-handler.ts
@@ -25,6 +25,7 @@ import { registerRpcTool } from "./tools/rpc.js";
import { registerDefiTool } from "./tools/defi.js";
import { registerPolymarketReadTool, registerPolymarketTool } from "./tools/polymarket.js";
import { resolveTools, type ToolName } from "./profiles.js";
+import { registerAppResources } from "./apps.js";
/**
* Initialize the MCP server. The active tool `profile` (resolved from
@@ -119,5 +120,8 @@ export function initializeMcpServer(
);
}
+ // MCP App bundles (ui://) for the tools that carry _meta.ui — same gating.
+ registerAppResources(server, tools);
+
return { profile, tools: [...tools] };
}
diff --git a/src/tools/polymarket.ts b/src/tools/polymarket.ts
index 9fccbc4..96433e5 100644
--- a/src/tools/polymarket.ts
+++ b/src/tools/polymarket.ts
@@ -10,6 +10,7 @@ import { runSetup } from "../utils/polymarket/setup.js";
import { withdrawFunds } from "../utils/polymarket/withdraw.js";
import { fundVault } from "../utils/polymarket/fund.js";
import { TOOL_ANNOTATIONS } from "../tool-annotations.js";
+import { appToolMeta } from "../apps.js";
/**
* Trading is intentionally NOT gated on the x402 budget ledger: that ledger
@@ -138,6 +139,9 @@ Actions:
- preview — build a live buy/sell order preview from the CLOB book. side plus token_id (or condition_id+outcome) are required. Market buys use amount_usd; limit orders use price+size. This action never accepts confirm and never signs or submits an order.
Use blockrun_polymarket only for setup and funds-affecting operations: confirmed buy/sell, cancel, redeem, fund, or withdraw.`,
+ // MCP App: a capable host renders the order card for this tool's
+ // results (docs/mcp-apps.md); others ignore _meta and show the text.
+ _meta: appToolMeta("orderPreview"),
annotations: TOOL_ANNOTATIONS.readOnlyOpenWorld,
inputSchema: {
action: z.enum(["positions", "orders", "preview"]).describe("Read-only operation"),
diff --git a/src/tools/wallet.ts b/src/tools/wallet.ts
index 9b2c648..4aaad61 100644
--- a/src/tools/wallet.ts
+++ b/src/tools/wallet.ts
@@ -7,6 +7,7 @@ import { generateQrPng, openQrInViewer } from "../utils/qr.js";
import { launchTopUp } from "../utils/onramp.js";
import { formatError } from "../utils/errors.js";
import { TOOL_ANNOTATIONS } from "../tool-annotations.js";
+import { appToolMeta } from "../apps.js";
export function registerWalletTool(server: McpServer, budget: BudgetState): void {
server.registerTool(
@@ -52,6 +53,8 @@ Usage pattern for multi-agent systems:
3. blockrun_wallet action:"report" to audit spending
Do NOT call this for actual AI queries — use blockrun_chat for that.`,
+ // MCP App: the wallet panel (docs/mcp-apps.md) on hosts that support it.
+ _meta: appToolMeta("wallet"),
annotations: TOOL_ANNOTATIONS.walletManagement,
inputSchema: {
action: z.enum(["status", "deposit", "setup", "qr", "chain", "budget", "delegate", "revoke", "report"]).optional().default("status").describe("What to do"),
diff --git a/src/utils/polymarket/orders.ts b/src/utils/polymarket/orders.ts
index 3d3723e..18f71a3 100644
--- a/src/utils/polymarket/orders.ts
+++ b/src/utils/polymarket/orders.ts
@@ -12,6 +12,7 @@ import { AssetType, OrderType, Side, type ClobClient, type OrderBookSummary } fr
import { checkGeoblock, getClobClient, getPolymarketAccount, resetClobClient } from "./client.js";
import { getMaxBetUsd, getMaxSessionUsd, getSigType } from "./constants.js";
import { invalidateL2Creds, loadState } from "./creds.js";
+import { fetchWithTimeout } from "../http.js";
// --- Session bet ledger (in-memory; resets with the process) ---
const ledger = {
@@ -91,7 +92,16 @@ async function resolveToken(
clob: ClobClient,
input: { token_id?: string; condition_id?: string; outcome?: string },
): Promise {
- if (input.token_id) return { tokenId: input.token_id };
+ if (input.token_id) {
+ // A bare token_id resolves through the CLOB without a question or outcome,
+ // which is all the order card (apps/order-preview.ts) can show in its
+ // header, and it skipped the closed/acceptingOrders guard entirely. Gamma
+ // indexes markets by CLOB token id; ask it, best-effort, and keep going
+ // with the bare id if it is slow or down — the CLOB book is still the
+ // authority on whether the order can be placed.
+ const meta = await lookupMarketByToken(input.token_id);
+ return { tokenId: input.token_id, ...meta };
+ }
if (!input.condition_id) {
throw new Error(`Provide token_id, or condition_id + outcome (find them via blockrun_markets).`);
}
@@ -117,6 +127,62 @@ async function resolveToken(
};
}
+const GAMMA_API = "https://gamma-api.polymarket.com";
+
+interface GammaMarket {
+ question?: string;
+ conditionId?: string;
+ closed?: boolean;
+ acceptingOrders?: boolean;
+ outcomes?: string | string[];
+ clobTokenIds?: string | string[];
+}
+
+/**
+ * Pick the market that lists `tokenId` and map its metadata onto the resolved
+ * token — including which outcome the token is. Gamma serialises `outcomes`
+ * and `clobTokenIds` as JSON *strings*; tolerate both that and real arrays.
+ * Returns {} when nothing matches so a caller can spread it safely.
+ */
+export function parseGammaMarket(payload: unknown, tokenId: string): Partial {
+ const list = Array.isArray(payload) ? (payload as GammaMarket[]) : [];
+ const asArray = (v: string | string[] | undefined): string[] => {
+ if (Array.isArray(v)) return v.map(String);
+ if (typeof v === "string") {
+ try { const parsed = JSON.parse(v); return Array.isArray(parsed) ? parsed.map(String) : []; } catch { return []; }
+ }
+ return [];
+ };
+ for (const m of list) {
+ const ids = asArray(m.clobTokenIds);
+ const idx = ids.indexOf(tokenId);
+ if (idx < 0) continue;
+ const outcomes = asArray(m.outcomes);
+ return {
+ question: typeof m.question === "string" ? m.question : undefined,
+ outcome: outcomes[idx],
+ conditionId: typeof m.conditionId === "string" ? m.conditionId : undefined,
+ closed: typeof m.closed === "boolean" ? m.closed : undefined,
+ acceptingOrders: typeof m.acceptingOrders === "boolean" ? m.acceptingOrders : undefined,
+ };
+ }
+ return {};
+}
+
+async function lookupMarketByToken(tokenId: string): Promise> {
+ try {
+ const resp = await fetchWithTimeout(
+ `${GAMMA_API}/markets?clob_token_ids=${encodeURIComponent(tokenId)}`,
+ { headers: { accept: "application/json" } },
+ 4_000,
+ );
+ if (!resp.ok) return {};
+ return parseGammaMarket(await resp.json(), tokenId);
+ } catch {
+ return {};
+ }
+}
+
function bestQuote(book: OrderBookSummary, side: "buy" | "sell"): number | null {
// Asks fill buys, bids fill sells. Books are not guaranteed sorted; scan.
const levels = side === "buy" ? book.asks : book.bids;
@@ -417,6 +483,19 @@ export async function executeTrade(input: TradeInput): Promise {
notionalUsd: notional,
tickSize,
negRisk,
+ // Everything the order-preview MCP App renders that the summary
+ // text above already carries in prose — so the card never has to
+ // parse text, and hosts without the app still get the same facts.
+ question: token.question,
+ outcome: token.outcome,
+ conditionId: token.conditionId,
+ bestQuote: quote,
+ minSize,
+ maxBetUsd: maxBet,
+ sessionSpentUsd: ledger.totalUsd,
+ sessionCapUsd: sessionCap,
+ expiresAt: input.expires_at,
+ postOnly: input.post_only ?? false,
},
};
}
diff --git a/test/apps.test.ts b/test/apps.test.ts
new file mode 100644
index 0000000..a40efde
--- /dev/null
+++ b/test/apps.test.ts
@@ -0,0 +1,88 @@
+// Run with: npm test (tsx --test)
+//
+// MCP Apps wiring. Three things must hold together or the app silently never
+// renders — the host shows plain text and nobody notices:
+//
+// 1. The tool carries _meta.ui.resourceUri (and ONLY the two app tools do —
+// a stray _meta.ui on a text tool makes hosts fetch a resource that
+// does not exist).
+// 2. The ui:// resource is registered for exactly the profiles that expose
+// the tool, with the mcp-app MIME type.
+// 3. The bundle it serves is a real single-file HTML: inline script, no
+// external script/stylesheet URLs (the host sandbox blocks them).
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { initializeMcpServer } from "../src/mcp-handler.js";
+import { APP_RESOURCE_MIME_TYPE, APP_URIS, appToolMeta, readAppHtml } from "../src/apps.js";
+
+type ToolCfg = { _meta?: { ui?: { resourceUri?: string } } };
+type ResCfg = { mimeType?: string; _meta?: Record };
+type Reader = () => Promise<{ contents: Array<{ uri: string; mimeType?: string; text?: string }> }>;
+
+function collect(argv: string[]) {
+ const tools = new Map();
+ const resources = new Map();
+ const fake = {
+ registerTool(name: string, cfg: ToolCfg) { tools.set(name, cfg); },
+ registerResource(_name: string, uri: string, cfg: ResCfg, read: Reader) { resources.set(uri, { cfg, read }); },
+ } as unknown as McpServer;
+ initializeMcpServer(fake, { argv, env: {} });
+ return { tools, resources };
+}
+
+test("exactly two tools carry _meta.ui, pointing at the two app URIs", () => {
+ const { tools } = collect([]);
+ const withUi = [...tools.entries()].filter(([, c]) => c._meta?.ui?.resourceUri).map(([n, c]) => [n, c._meta!.ui!.resourceUri]);
+ assert.deepEqual(
+ withUi.sort(),
+ [["blockrun_polymarket_read", APP_URIS.orderPreview], ["blockrun_wallet", APP_URIS.wallet]].sort(),
+ );
+ assert.deepEqual(appToolMeta("wallet"), { ui: { resourceUri: "ui://blockrun/wallet.html" } });
+});
+
+test("app resources are registered only for profiles that expose their tool", () => {
+ const cases: Array<[string[], string[]]> = [
+ [[], [APP_URIS.orderPreview, APP_URIS.wallet]],
+ [["--profile", "trading"], [APP_URIS.orderPreview, APP_URIS.wallet]],
+ [["--profile", "chat"], [APP_URIS.wallet]],
+ [["--profile", "media"], [APP_URIS.wallet]],
+ [["--profile", "research"], [APP_URIS.wallet]],
+ ];
+ for (const [argv, expected] of cases) {
+ const { resources } = collect(argv);
+ const uis = [...resources.keys()].filter((u) => u.startsWith("ui://")).sort();
+ assert.deepEqual(uis, [...expected].sort(), `argv=${argv.join(" ") || "(full)"}`);
+ for (const u of uis) assert.equal(resources.get(u)!.cfg.mimeType, APP_RESOURCE_MIME_TYPE, u);
+ }
+});
+
+test("each app resource serves a self-contained single-file HTML bundle", async () => {
+ const { resources } = collect([]);
+ for (const uri of [APP_URIS.orderPreview, APP_URIS.wallet]) {
+ const r = await resources.get(uri)!.read();
+ assert.equal(r.contents.length, 1);
+ const c = r.contents[0];
+ assert.equal(c.uri, uri);
+ assert.equal(c.mimeType, APP_RESOURCE_MIME_TYPE);
+ const html = c.text ?? "";
+ // assert.ok, not assert.match: a failure must not dump 350 KB of bundle
+ // into the test log.
+ assert.ok(/^/i.test(html), `${uri}: not an HTML document`);
+ assert.ok(/