diff --git a/DEMO.md b/DEMO.md index d4ce26c..43970c5 100644 --- a/DEMO.md +++ b/DEMO.md @@ -93,24 +93,26 @@ Times are markers for the final edit; live run is closer to 6 minutes including > Voiceover: *"Vault is now funded with 5 USDC. The vault PDA is just an address — there's no private key to steal."* -### 1:20–1:50 — issue MCP URL + paste into Claude +### 1:20–1:50 — issue MCP URL + connect a client 1. **MCP sessions** card → enter label `claude-desktop` → **+ New MCP URL**. 2. Click **Copy** on the new row. 3. Cut to terminal: ```bash - cat ~/.claude/claude_desktop_config.json - # paste the URL into mcpServers.aceguard.url, save, relaunch Claude. + cat ~/Library/Application\ Support/Claude/claude_desktop_config.json + # paste the bridge config below, save, fully quit + relaunch Claude. ``` - Example config: + Claude Desktop only speaks stdio MCP, so we wrap the HTTP endpoint with + `mcp-remote` (community standard bridge, fetched on demand by `npx`): ```json { "mcpServers": { "aceguard": { - "url": "https://x402guard.acedata.cloud/mcp/" + "command": "npx", + "args": ["-y", "mcp-remote", "https://x402guard.acedata.cloud/mcp/"] } } } @@ -118,7 +120,12 @@ Times are markers for the final edit; live run is closer to 6 minutes including 4. Cut back to Claude Desktop — `aceguard_balance`, `aceguard_history`, `aceguard_spend`, `aceguard_pay_for_api` light up in the tools panel. -> Voiceover: *"Any MCP-compatible client works — Claude, Cursor, Cline, custom agents."* +> Voiceover: *"Any MCP-compatible client works. Cursor and Cline take the URL directly; Claude Desktop wraps it with a local stdio bridge. The endpoint itself is plain JSON-RPC — you can drive it from `curl` if you want."* + +> 🎬 **Optional B-roll for the demo video**: split-screen the Claude +> Desktop call against a parallel `python scripts/demo.py ` run in a +> terminal. Both produce the same on-chain `agent_vault.spend()` ix — +> proving the boundary lives in the program, not the client. ### 1:50–2:50 — happy-path spend @@ -175,7 +182,9 @@ ffmpeg -f avfoundation -framerate 30 -i "1:0" \ | Phantom shows "transaction failed" | Page refresh, click Create vault again — backend rebuilds the same tx because the row is keyed off `vault_pda`. | | Solana RPC times out (mainnet during peak) | Switch `VITE_SOLANA_RPC_URL` env in `web/.env` to a paid RPC (Helius / QuickNode) and rebuild. | | `aceguard_pay_for_api` returns "on-chain spend rejected: confirm_timeout" | Retry the same prompt; nonce is freshly issued, no replay risk. | -| Claude Desktop doesn't see the tools after relaunch | Close all Claude windows + relaunch. Tool list refresh happens on app boot, not per-conversation. | +| Claude Desktop shows "MCP could not be loaded" with `{"url": "..."}` config | Switch the config to the `mcp-remote` bridge form (see Step 1:20–1:50). The `{"url": ...}` schema is for Claude.ai web Custom Connectors, not for Claude Desktop, which only speaks stdio. | +| Claude Desktop doesn't see the tools after relaunch | Fully **Cmd+Q** quit Claude (closing the window doesn't reload the MCP config), then relaunch. Tool list refresh happens on app boot, not per-conversation. | +| Suspect the endpoint itself is broken | Run `python scripts/demo.py ` (or `./scripts/mcp-curl.sh`) — drives the JSON-RPC directly. If that works the endpoint is fine and the issue is client-side. | ## Post-demo cleanup diff --git a/README.md b/README.md index cf449aa..f831f90 100644 --- a/README.md +++ b/README.md @@ -181,21 +181,58 @@ In the **MCP sessions** card on the vault detail page: This URL is the **only** thing the agent ever sees. It's bound to one vault; the user can revoke it any time. -### Step 4 — Wire the URL into Claude Desktop (1 min) +### Step 4 — Connect a client to your MCP URL (1 min) -Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows): +The MCP endpoint is plain JSON-RPC over HTTP POST. Three ways to use it, +in increasing order of "needs an LLM in the loop": + +#### 4a. Verify it works at all (no client required) + +Run the bundled CLI demo against your URL — it speaks JSON-RPC directly, +no MCP client / Claude / Cursor / SDK needed: + +```bash +# Python (httpx + stdlib only) +python scripts/demo.py https://x402guard.acedata.cloud/mcp/ + +# or with bash + curl + jq +./scripts/mcp-curl.sh https://x402guard.acedata.cloud/mcp/ +``` + +The script lists tools, reads on-chain balance, calls +`aceguard_pay_for_api` (full x402 dance — upstream 402 → on-chain +`agent_vault.spend()` → X-Payment retry → 200), and re-reads the balance +to confirm the spend landed. **If this passes, your endpoint is healthy +and any "MCP could not be loaded" error is a client-side problem.** + +#### 4b. Claude Desktop (via the `mcp-remote` bridge) + +Claude Desktop only speaks **stdio** MCP — it does not load HTTP MCP +endpoints from a `{"url": "..."}` config (that schema is for Claude.ai +web "Custom Connectors", a different product). The community-standard +bridge is `mcp-remote`: ```json { "mcpServers": { "aceguard": { - "url": "https://x402guard.acedata.cloud/mcp/" + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://x402guard.acedata.cloud/mcp/" + ] } } } ``` -Quit + relaunch Claude. The four tools light up: +Save to `~/Library/Application Support/Claude/claude_desktop_config.json` +(macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows). Then +**fully quit Claude (Cmd+Q, not just close the window)** and relaunch. +Requires Node ≥ 18 on `PATH` so `npx` can fetch `mcp-remote`. + +The four tools light up: | Tool | Use | |---|---| @@ -204,6 +241,31 @@ Quit + relaunch Claude. The four tools light up: | `aceguard_spend` | Direct USDC transfer (low-level) | | `aceguard_pay_for_api` | **The high-level wrapper** — give it a paid URL, it does the full x402 dance | +#### 4c. Cursor / Cline / any other HTTP-MCP-aware client + +Cursor and Cline natively understand HTTP / Streamable HTTP MCP, so they +take the URL directly without a bridge. Consult that client's MCP docs +for the exact config field — it is usually `mcpServers..url` (same +shape Claude Desktop *almost* supports). + +#### Or skip MCP entirely — talk to `api.acedata.cloud` from the SDK + +If your goal is just to **make a paid AceDataCloud API call from your +own wallet** (without the on-chain policy enforcement that x402guard +adds), the [`@acedatacloud/x402-client`][x402client] SDK is the simpler +path: it plugs into [`@acedatacloud/sdk`][sdk] as a `payment_handler` +that signs the X-Payment header from your local key. See its +[`scripts/test-solana-e2e.ts`][solana-e2e] for a 70-line live demo. + +x402guard adds value on top of that path — daily / per-call caps and +endpoint allowlists enforced **on-chain** by an Anchor program — but you +can use the SDK first to confirm the wire-format works, then graduate +to x402guard when you want the spending guardrails. + +[x402client]: https://github.com/AceDataCloud/X402Client +[sdk]: https://github.com/AceDataCloud/SDK +[solana-e2e]: https://github.com/AceDataCloud/X402Client/blob/main/typescript/scripts/test-solana-e2e.ts + ### Step 5 — Use it (1 min) In Claude Desktop, ask: diff --git a/scripts/demo.py b/scripts/demo.py new file mode 100755 index 0000000..c438408 --- /dev/null +++ b/scripts/demo.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Drive an x402guard MCP endpoint from the command line. + +Why this exists +--------------- +The MCP endpoint at ``/mcp/`` is plain JSON-RPC over HTTP POST. +Claude Desktop is one client; it is not the only one. If your client +is misconfigured (Claude Desktop only speaks stdio MCP, not HTTP MCP — +you need the `mcp-remote` bridge for that) you cannot tell whether the +fault is on the client or the server. + +This script answers that question by speaking JSON-RPC to the MCP +endpoint directly. No Claude, no Cursor, no client-side config. + +It will, in order: + +1. ``tools/list`` — confirm the four tools are registered +2. ``aceguard_balance`` — read on-chain USDC + remaining caps +3. ``aceguard_pay_for_api`` — full x402 dance against an acedata API: + * upstream returns 402 + * x402guard backend invokes ``agent_vault.spend()`` on Solana + * Anchor program checks daily / per-call / allowlist / paused / nonce + * PDA-signed SPL transfer goes on chain + * x402guard rebuilds the X-Payment header from the tx signature + * upstream is retried, returns 200 +4. ``aceguard_balance`` — confirm the vault balance ticked down +5. ``aceguard_history`` — show the new spend with Solscan link + +Usage +----- +:: + + python scripts/demo.py \\ + https://x402guard.acedata.cloud/mcp/ + + # or against a local stack + python scripts/demo.py http://localhost:8000/mcp/ + + # use a non-default API endpoint (must be on the vault allowlist) + python scripts/demo.py \\ + --url https://api.acedata.cloud/openai/chat/completions \\ + --body '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}],"max_tokens":10}' + +Requires only ``httpx`` (already in the api package's pyproject) and the +Python stdlib. No SDK, no MCP libraries. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any + +try: + import httpx +except ImportError: + sys.exit( + "httpx is required. install with: pip install httpx\n" + "(or run from the api venv: cd api && poetry shell && python ../scripts/demo.py ...)" + ) + + +DEFAULT_API_URL = "https://api.acedata.cloud/openai/chat/completions" +DEFAULT_BODY: dict[str, Any] = { + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "Say hi in 5 words."}], + "max_tokens": 20, +} + + +def _post(client: httpx.Client, mcp_url: str, payload: dict[str, Any]) -> dict[str, Any]: + """Send one JSON-RPC request, return the parsed response object.""" + resp = client.post(mcp_url, json=payload) + if resp.status_code == 401: + sys.exit( + f"❌ MCP endpoint returned 401 (unknown or revoked token).\n" + f" {mcp_url}\n" + f" Generate a fresh token in the vault detail page → " + f"+ New MCP URL → Copy." + ) + resp.raise_for_status() + return resp.json() + + +def _unwrap_tool_result(rpc_response: dict[str, Any]) -> dict[str, Any]: + """Return the JSON object embedded in the first ``content[0].text``. + + The MCP tools wrap their JSON output in ``{content: [{type: "text", + text: ""}], isError: bool}``; for this demo we always want the + JSON inside. + """ + if "error" in rpc_response: + sys.exit( + f"❌ MCP RPC error: code={rpc_response['error'].get('code')} " + f"message={rpc_response['error'].get('message')!r} " + f"data={rpc_response['error'].get('data')!r}" + ) + result = rpc_response.get("result") or {} + content = result.get("content") or [] + if not content: + return result + text = content[0].get("text") or "" + try: + return json.loads(text) + except json.JSONDecodeError: + return {"_raw_text": text, "_isError": result.get("isError", False)} + + +def _hr(title: str) -> None: + print() + print(f"━━━ {title} " + "━" * max(0, 60 - len(title))) + + +def main() -> int: + p = argparse.ArgumentParser( + description=__doc__.split("\n\n", 1)[0], + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument("mcp_url", help="Full MCP URL e.g. https://x402guard.acedata.cloud/mcp/") + p.add_argument( + "--url", + default=DEFAULT_API_URL, + help=f"API to pay for (must be on vault allowlist). default: {DEFAULT_API_URL}", + ) + p.add_argument( + "--body", + default=json.dumps(DEFAULT_BODY), + help="JSON request body for the API call.", + ) + p.add_argument( + "--method", + default="POST", + choices=["GET", "POST"], + help="HTTP method for the API call. default: POST", + ) + p.add_argument( + "--skip-pay", + action="store_true", + help="Only call balance + history; do not actually spend.", + ) + args = p.parse_args() + + try: + body_json = json.loads(args.body) + except json.JSONDecodeError as exc: + sys.exit(f"--body is not valid JSON: {exc}") + + rpc_id = 0 + + def next_id() -> int: + nonlocal rpc_id + rpc_id += 1 + return rpc_id + + print(f"x402guard CLI demo — driving MCP at {args.mcp_url!r}") + with httpx.Client(timeout=180.0) as client: + _hr("1. tools/list") + resp = _post( + client, + args.mcp_url, + {"jsonrpc": "2.0", "id": next_id(), "method": "tools/list"}, + ) + if "error" in resp: + sys.exit(f"❌ tools/list failed: {resp['error']}") + tools = (resp.get("result") or {}).get("tools") or [] + for t in tools: + print(f" • {t.get('name'):28s} — {t.get('description', '')[:80]}") + if not tools: + sys.exit("❌ tools/list returned no tools — the endpoint is unhealthy.") + + _hr("2. aceguard_balance") + bal_before = _unwrap_tool_result( + _post( + client, + args.mcp_url, + { + "jsonrpc": "2.0", + "id": next_id(), + "method": "tools/call", + "params": {"name": "aceguard_balance", "arguments": {}}, + }, + ) + ) + print(json.dumps(bal_before, indent=2, ensure_ascii=False)) + + if args.skip_pay: + print("\n--skip-pay set; stopping after balance read.") + return 0 + + if bal_before.get("paused"): + sys.exit("❌ vault is paused. Resume it in the Dapp before retrying.") + if (bal_before.get("balance_usdc") or 0) <= 0: + sys.exit( + "❌ vault balance is 0 USDC. Top up via the Dapp first " + "(Step 2 of the README)." + ) + + _hr(f"3. aceguard_pay_for_api → {args.method} {args.url}") + pay_result = _unwrap_tool_result( + _post( + client, + args.mcp_url, + { + "jsonrpc": "2.0", + "id": next_id(), + "method": "tools/call", + "params": { + "name": "aceguard_pay_for_api", + "arguments": { + "url": args.url, + "method": args.method, + "json_body": body_json, + }, + }, + }, + ) + ) + # Only print the metadata block + a short preview of the upstream + # response — the upstream JSON can be large. + meta = {k: v for k, v in pay_result.items() if k != "upstream_response"} + print(json.dumps(meta, indent=2, ensure_ascii=False)) + upstream = pay_result.get("upstream_response") + if upstream is not None: + preview = json.dumps(upstream, ensure_ascii=False) + print(f" upstream_response (preview): {preview[:300]}") + if len(preview) > 300: + print(f" …[{len(preview) - 300} more chars]") + if pay_result.get("_isError"): + print( + "\n⚠️ pay_for_api flagged isError. Common causes:\n" + " - endpoint host not on vault allowlist (EndpointNotAllowed)\n" + " - per_call_cap / daily_cap exceeded\n" + " - vault paused / expired\n" + " - upstream returned non-200 even after payment landed" + ) + return 2 + + _hr("4. aceguard_balance (after spend)") + bal_after = _unwrap_tool_result( + _post( + client, + args.mcp_url, + { + "jsonrpc": "2.0", + "id": next_id(), + "method": "tools/call", + "params": {"name": "aceguard_balance", "arguments": {}}, + }, + ) + ) + bb = bal_before.get("balance_usdc") or 0 + ba = bal_after.get("balance_usdc") or 0 + print( + f" balance: {bb:.6f} → {ba:.6f} USDC (Δ = {ba - bb:+.6f})\n" + f" daily_remaining: {bal_before.get('daily_remaining_usdc')} " + f"→ {bal_after.get('daily_remaining_usdc')} USDC" + ) + + _hr("5. aceguard_history (latest 3)") + hist = _unwrap_tool_result( + _post( + client, + args.mcp_url, + { + "jsonrpc": "2.0", + "id": next_id(), + "method": "tools/call", + "params": { + "name": "aceguard_history", + "arguments": {"limit": 3}, + }, + }, + ) + ) + for sp in (hist.get("spends") or [])[:3]: + print( + f" {sp.get('block_time')} | {sp.get('amount_usdc'):.6f} USDC | " + f"{sp.get('endpoint')}{sp.get('api_path') or ''}" + ) + print(f" → {sp.get('solscan')}") + + print("\n✅ done.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/mcp-curl.sh b/scripts/mcp-curl.sh new file mode 100755 index 0000000..614f9c9 --- /dev/null +++ b/scripts/mcp-curl.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# +# Drive an x402guard MCP endpoint with curl + jq. +# +# Same purpose as scripts/demo.py — proves the MCP endpoint is healthy +# without any MCP client, but using only bash / curl / jq for users +# who don't have a Python toolchain handy. +# +# Usage: +# +# ./scripts/mcp-curl.sh https://x402guard.acedata.cloud/mcp/ +# +# Optional env: +# API_URL upstream API to pay for (default: openai chat completions) +# API_BODY JSON body for that API (default: 5-word "hi" prompt) +# +# What it does: +# 1. tools/list (lists the four aceguard tools) +# 2. aceguard_balance (reads on-chain USDC + caps) +# 3. aceguard_pay_for_api (full x402 + on-chain spend round-trip) +# 4. aceguard_balance (confirms balance ticked down) + +set -euo pipefail + +if [ "${#}" -lt 1 ]; then + echo "usage: $0 " >&2 + echo " e.g. $0 https://x402guard.acedata.cloud/mcp/abc123..." >&2 + exit 64 +fi + +URL="$1" +API_URL="${API_URL:-https://api.acedata.cloud/openai/chat/completions}" +API_BODY="${API_BODY:-{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hi in 5 words.\"}],\"max_tokens\":20}}" + +if ! command -v jq >/dev/null 2>&1; then + echo "❌ this script needs jq. install with: brew install jq" >&2 + exit 127 +fi + +call() { + # call + local id="$1" method="$2" params="${3:-null}" + curl -sS -X POST "$URL" \ + -H 'content-type: application/json' \ + --data "{\"jsonrpc\":\"2.0\",\"id\":${id},\"method\":\"${method}\",\"params\":${params}}" +} + +echo "━━━ 1. tools/list ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +call 1 "tools/list" "null" | jq '.result.tools[] | {name, description: (.description | .[0:80])}' + +echo +echo "━━━ 2. aceguard_balance ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +BAL_BEFORE=$(call 2 "tools/call" '{"name":"aceguard_balance","arguments":{}}' \ + | jq -r '.result.content[0].text') +echo "$BAL_BEFORE" | jq + +PAUSED=$(echo "$BAL_BEFORE" | jq -r '.paused') +BALANCE=$(echo "$BAL_BEFORE" | jq -r '.balance_usdc') +if [ "$PAUSED" = "true" ]; then + echo "❌ vault is paused; resume it in the Dapp first" >&2 + exit 2 +fi +if [ "$(echo "$BALANCE <= 0" | bc -l 2>/dev/null || echo 0)" = "1" ]; then + echo "❌ vault balance is 0 USDC; top up via the Dapp first" >&2 + exit 2 +fi + +echo +echo "━━━ 3. aceguard_pay_for_api → POST $API_URL ━━━━━━━━━━━━━━━━━━━━━━━━━" +PAY_PARAMS=$(jq -n --arg url "$API_URL" --argjson body "$API_BODY" \ + '{name:"aceguard_pay_for_api",arguments:{url:$url,method:"POST",json_body:$body}}') +PAY_RESULT=$(call 3 "tools/call" "$PAY_PARAMS") +echo "$PAY_RESULT" | jq -r '.result.content[0].text' | jq '. | del(.upstream_response) | . + {_upstream_status: .upstream_status}' +IS_ERROR=$(echo "$PAY_RESULT" | jq -r '.result.isError // false') +if [ "$IS_ERROR" = "true" ]; then + echo "⚠️ pay_for_api isError=true. Likely policy rejection (allowlist / cap / paused) or upstream non-200." >&2 + exit 3 +fi + +echo +echo "━━━ 4. aceguard_balance (after spend) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +call 4 "tools/call" '{"name":"aceguard_balance","arguments":{}}' \ + | jq -r '.result.content[0].text' \ + | jq '{balance_usdc, daily_remaining_usdc, paused}' + +echo +echo "✅ done."