branch:
client.tsx
12984 bytesRaw
import { useCallback, useState } from "react";
import { createRoot } from "react-dom/client";
import { ThemeProvider } from "@cloudflare/agents-ui/hooks";
import {
ConnectionIndicator,
ModeToggle,
PoweredByAgents,
type ConnectionStatus
} from "@cloudflare/agents-ui";
import { Button, Badge, Surface, Text, Empty } from "@cloudflare/kumo";
import {
CurrencyDollarIcon,
MegaphoneIcon,
HashIcon,
PaperPlaneRightIcon,
TrashIcon,
XIcon,
CheckIcon,
InfoIcon,
WarningCircleIcon,
CheckCircleIcon
} from "@phosphor-icons/react";
import { useAgent } from "agents/react";
import { nanoid } from "nanoid";
import "./styles.css";
let sessionId = localStorage.getItem("x402-session");
if (!sessionId) {
sessionId = nanoid(8);
localStorage.setItem("x402-session", sessionId);
}
interface ToolResult {
label: string;
text: string;
isError: boolean;
timestamp: number;
}
interface PaymentInfo {
resource: string;
address: string;
network: string;
amount: string;
id: string;
}
function App() {
const [status, setStatus] = useState<ConnectionStatus>("connecting");
const [results, setResults] = useState<ToolResult[]>([]);
const [payment, setPayment] = useState<PaymentInfo | null>(null);
const agent = useAgent({
agent: "pay-agent",
name: sessionId!,
onOpen: useCallback(() => setStatus("connected"), []),
onClose: useCallback(() => setStatus("disconnected"), []),
onMessage: useCallback((message: MessageEvent) => {
try {
const parsed = JSON.parse(message.data);
if (
parsed.type === "payment_required" &&
Array.isArray(parsed.requirements)
) {
const p = parsed.requirements[0] || {};
const amt = (Number(p.maxAmountRequired) / 1e6).toString();
setPayment({
resource: p.resource || "—",
address: p.payTo || "—",
network: p.network || "—",
amount: amt,
id: parsed.confirmationId || "—"
});
}
} catch {
// ignore non-JSON messages
}
}, [])
});
const handleCallTool = async (
toolName: string,
args: Record<string, unknown>
) => {
try {
const res = (await agent.call("callTool", [toolName, args])) as {
text: string;
isError: boolean;
};
setResults((prev) => [
{
label: toolName,
text: res.text,
isError: res.isError,
timestamp: Date.now()
},
...prev
]);
} catch (err) {
setResults((prev) => [
{
label: toolName,
text: err instanceof Error ? err.message : String(err),
isError: true,
timestamp: Date.now()
},
...prev
]);
}
};
const handlePayment = (confirmed: boolean) => {
if (!payment) return;
agent.call("resolvePayment", [payment.id, confirmed]);
setPayment(null);
};
return (
<div className="h-full flex flex-col bg-kumo-base">
<header className="px-5 py-4 border-b border-kumo-line">
<div className="max-w-3xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<CurrencyDollarIcon
size={22}
className="text-kumo-accent"
weight="bold"
/>
<h1 className="text-lg font-semibold text-kumo-default">
x402 MCP
</h1>
</div>
<div className="flex items-center gap-3">
<ConnectionIndicator status={status} />
<ModeToggle />
</div>
</div>
</header>
<main className="flex-1 overflow-auto p-5">
<div className="max-w-3xl mx-auto space-y-6">
<Surface className="p-4 rounded-xl ring ring-kumo-line">
<div className="flex gap-3">
<InfoIcon
size={20}
weight="bold"
className="text-kumo-accent shrink-0 mt-0.5"
/>
<div>
<Text size="sm" bold>
Paid MCP Tools (x402)
</Text>
<span className="mt-1 block">
<Text size="xs" variant="secondary">
The <strong>echo</strong> tool is free. The{" "}
<strong>square</strong> tool costs $0.01 — calling it
triggers a payment confirmation between the client agent and
the MCP server using the x402 protocol.
</Text>
</span>
</div>
</div>
</Surface>
{status === "disconnected" && (
<Empty
icon={<CurrencyDollarIcon size={32} />}
title="Disconnected"
description="Could not connect to the agent. Make sure the server is running."
/>
)}
{status === "connected" && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<EchoForm onCall={handleCallTool} />
<SquareForm onCall={handleCallTool} />
</div>
{payment && (
<PaymentModal
payment={payment}
onConfirm={() => handlePayment(true)}
onCancel={() => handlePayment(false)}
/>
)}
{results.length > 0 && (
<section>
<div className="flex items-center justify-between mb-3">
<Text size="base" bold>
Results
</Text>
<Button
variant="ghost"
size="sm"
icon={<TrashIcon size={14} />}
onClick={() => setResults([])}
>
Clear
</Button>
</div>
<div className="space-y-2">
{results.map((r) => (
<Surface
key={r.timestamp}
className={`p-3 rounded-xl ring ${r.isError ? "ring-red-500/30 bg-red-50 dark:bg-red-950/20" : "ring-kumo-line"}`}
>
<div className="flex items-start gap-2">
{r.isError ? (
<WarningCircleIcon
size={16}
weight="fill"
className="text-red-500 shrink-0 mt-0.5"
/>
) : (
<CheckCircleIcon
size={16}
weight="fill"
className="text-green-600 shrink-0 mt-0.5"
/>
)}
<div className="min-w-0 flex-1">
<Text size="xs" variant="secondary" bold>
{r.label}
</Text>
<p
className={`text-sm mt-0.5 whitespace-pre-wrap break-words ${r.isError ? "text-red-600 dark:text-red-400" : "text-kumo-default"}`}
>
{r.text}
</p>
</div>
<span className="text-[10px] text-kumo-inactive tabular-nums shrink-0">
{new Date(r.timestamp).toLocaleTimeString()}
</span>
</div>
</Surface>
))}
</div>
</section>
)}
</>
)}
</div>
</main>
<footer className="border-t border-kumo-line py-3">
<div className="flex justify-center">
<PoweredByAgents />
</div>
</footer>
</div>
);
}
function EchoForm({
onCall
}: {
onCall: (tool: string, args: Record<string, unknown>) => Promise<void>;
}) {
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
setLoading(true);
await onCall("echo", { message });
setLoading(false);
setMessage("");
};
return (
<Surface className="p-4 rounded-xl ring ring-kumo-line">
<div className="flex items-center gap-2 mb-3">
<MegaphoneIcon size={18} weight="bold" />
<Text size="sm" bold>
Echo
</Text>
<Badge variant="outline">free</Badge>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<label className="block text-xs text-kumo-subtle">
Message
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="hello world"
className="mt-1 w-full px-3 py-1.5 text-sm rounded-lg border border-kumo-line bg-kumo-base text-kumo-default placeholder:text-kumo-inactive focus:outline-none focus:ring-1 focus:ring-kumo-accent"
/>
</label>
<Button
type="submit"
variant="primary"
size="sm"
loading={loading}
icon={<PaperPlaneRightIcon size={14} />}
>
Call echo
</Button>
</form>
</Surface>
);
}
function SquareForm({
onCall
}: {
onCall: (tool: string, args: Record<string, unknown>) => Promise<void>;
}) {
const [number, setNumber] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (number === "") return;
setLoading(true);
await onCall("square", { number: Number(number) });
setLoading(false);
setNumber("");
};
return (
<Surface className="p-4 rounded-xl ring ring-kumo-line">
<div className="flex items-center gap-2 mb-3">
<HashIcon size={18} weight="bold" />
<Text size="sm" bold>
Square
</Text>
<Badge variant="secondary">$0.01</Badge>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<label className="block text-xs text-kumo-subtle">
Number
<input
type="number"
value={number}
onChange={(e) => setNumber(e.target.value)}
placeholder="5"
className="mt-1 w-full px-3 py-1.5 text-sm rounded-lg border border-kumo-line bg-kumo-base text-kumo-default placeholder:text-kumo-inactive focus:outline-none focus:ring-1 focus:ring-kumo-accent"
/>
</label>
<Button
type="submit"
variant="primary"
size="sm"
loading={loading}
icon={<PaperPlaneRightIcon size={14} />}
>
Call square (paid)
</Button>
<span className="block">
<Text size="xs" variant="secondary">
Triggers the x402 payment flow.
</Text>
</span>
</form>
</Surface>
);
}
function PaymentModal({
payment,
onConfirm,
onCancel
}: {
payment: PaymentInfo;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<Surface className="p-5 rounded-xl ring-2 ring-blue-500/30 bg-kumo-elevated">
<div className="mb-4">
<Text size="lg" bold>
Payment Required
</Text>
<Text size="sm" variant="secondary">
A paid tool has been requested. Confirm to continue.
</Text>
</div>
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm mb-5">
<Text size="xs" variant="secondary">
Resource
</Text>
<Text size="sm" bold>
{payment.resource}
</Text>
<Text size="xs" variant="secondary">
Pay to
</Text>
<span className="font-mono text-xs break-all">{payment.address}</span>
<Text size="xs" variant="secondary">
Network
</Text>
<Text size="sm">{payment.network}</Text>
<Text size="xs" variant="secondary">
Amount
</Text>
<Text size="sm" bold>
${payment.amount} USD
</Text>
</div>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
size="sm"
icon={<XIcon size={14} />}
onClick={onCancel}
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
icon={<CheckIcon size={14} />}
onClick={onConfirm}
>
Confirm & Pay
</Button>
</div>
</Surface>
);
}
createRoot(document.getElementById("root")!).render(
<ThemeProvider>
<App />
</ThemeProvider>
);