From c061fe721e4ac949d1c08841e73b4f7a3e82c70e Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:38:22 -0500 Subject: [PATCH 1/4] =?UTF-8?q?feat(apps):=20MCP=20Apps=20=E2=80=94=20Poly?= =?UTF-8?q?market=20order=20card=20and=20wallet=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two generative-UI apps (extension io.modelcontextprotocol/ui) that capable hosts — Claude Desktop, claude.ai, VS Code, Cursor — render inline instead of the tool's text. Hosts without the extension ignore _meta.ui and see today's text unchanged, so Claude Code and Codex are unaffected. ui://blockrun/order-preview.html, on blockrun_polymarket_read: the preview action becomes a live card — question, outcome, side, best quote in cents, est. shares, notional, per-order cap meter, session ledger. The amount is editable and re-quotes through the read-only tool. Place order is a two-click arm/confirm that calls blockrun_polymarket with confirm:true THROUGH THE HOST, so the host's own tool-consent prompt and every server cap still apply; the card then shows orderID/status/tx links and updates the model context. positions/orders results render as tables; anything else falls back to text. ui://blockrun/wallet.html, on blockrun_wallet: both chains' balances, active chain switch, copy address, EIP-681 / Solana Pay QR (same encoding as utils/qr.ts), explorer link, card on-ramp via ui/open-link. The preview payload now carries question/outcome/conditionId/bestQuote/ minSize/maxBetUsd/session so the card never parses prose. Bundles are single-file HTML built by vite-plugin-singlefile from apps/ into ui/ (pretest + build), shipped in the tarball, located from the package root like skills/. Resources are registered only for profiles exposing the tool. test/apps.test.ts pins the _meta wiring, per-profile registration, and that each bundle is self-contained (no external script/stylesheet). --- .gitignore | 1 + apps/order-preview.html | 22 + apps/order-preview.ts | 220 +++++++++ apps/shared.ts | 98 ++++ apps/styles.css | 85 ++++ apps/vite.config.ts | 31 ++ apps/wallet.html | 22 + apps/wallet.ts | 125 +++++ package-lock.json | 863 ++++++++++++++++++++++++++++++++- package.json | 10 +- src/apps.ts | 111 +++++ src/mcp-handler.ts | 4 + src/tools/polymarket.ts | 4 + src/tools/wallet.ts | 3 + src/utils/polymarket/orders.ts | 13 + test/apps.test.ts | 88 ++++ tsconfig.json | 11 +- 17 files changed, 1696 insertions(+), 15 deletions(-) create mode 100644 apps/order-preview.html create mode 100644 apps/order-preview.ts create mode 100644 apps/shared.ts create mode 100644 apps/styles.css create mode 100644 apps/vite.config.ts create mode 100644 apps/wallet.html create mode 100644 apps/wallet.ts create mode 100644 src/apps.ts create mode 100644 test/apps.test.ts 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/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/package-lock.json b/package-lock.json index 6bac37f..9810257 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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..fa3bf9e 100644 --- a/package.json +++ b/package.json @@ -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..06020f7 100644 --- a/src/utils/polymarket/orders.ts +++ b/src/utils/polymarket/orders.ts @@ -417,6 +417,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(/