Skip to main content
Glama
README.md
# Agent Wallet MCP

[![tests](https://github.com/GitLoopDesign/agent-wallet/actions/workflows/tests.yml/badge.svg)](https://github.com/GitLoopDesign/agent-wallet/actions/workflows/tests.yml)

A **prepaid, capped payment method for an AI agent**, exposed over MCP. The agent
never touches your real bank accounts — it gets an isolated float and a server that
lets it *request* charges but never unilaterally move money.

Safety rests on five properties: **isolation, capping, revocability, out-of-band
confirmation, and auditability.**

> **▶️ Try it in your browser** (no signup, mock money): **https://agentwallet.loopdesign.com.au/demo**
> — make a mock agent request charges and watch the real policy engine auto-clear, hold for
> approval, or deny (including a prompt-injected "$5,000 to a scammer" that gets refused).
> Landing page: **https://agentwallet.loopdesign.com.au**

## The one rule that matters

The agent can only call `request_charge`, which creates a **pending intent**. The
**server** (policy engine) decides whether it clears. Charges at/above your threshold
wait for **you** to approve on a channel the agent cannot reach (`cli_approve.py` or
Telegram). A prompt-injected agent can *ask* to send $5,000 to a scammer; the server
just says no.

## Layers of protection

| Layer | Setting |
|---|---|
| Hard float ceiling (blast radius) | `WALLET_FLOAT_CENTS` (default $200) |
| Per-transaction cap | `WALLET_PER_TXN_CAP_CENTS` (default $50) |
| Rolling 24h spend cap | `WALLET_DAILY_CAP_CENTS` (default $100) |
| Rolling 1h transaction count | `WALLET_HOURLY_TXN_LIMIT` (default 5) |
| Auto-approve threshold | `WALLET_AUTO_APPROVE_UNDER_CENTS` (default 0 = confirm everything) |
| Merchant allow / deny lists | `WALLET_MERCHANT_ALLOWLIST` / `_DENYLIST` |
| Out-of-band approval | `cli_approve.py` (agent cannot call it) |
| Audit log | every request + capture in the `audit` table |

## How a charge flows

```mermaid
flowchart TD
    A["AI agent<br/>(can only request)"] -->|request_charge| P{Policy engine<br/>caps · velocity · merchant}
    P -->|over cap / denylist / no float| D["DENY<br/>agent cannot override"]
    P -->|under auto-approve threshold| C["Capture against float"]
    P -->|at / above threshold| H["Pending intent<br/>held for a human"]
    H -.->|out-of-band, agent can't reach| U["You: Telegram · dashboard · CLI"]
    U -->|approve| RC{Re-check at capture}
    U -->|deny| D
    RC -->|still in policy| C
    RC -->|now out of policy| D
    C --> L[("Append-only ledger<br/>balance derived from captures")]
    D --> L
```

Everything the agent can touch is on the left; the approval channel on the right is
deliberately out of its reach. [Play with this exact flow in your browser.](https://agentwallet.loopdesign.com.au/demo)

## Quick start (no secrets, mock money)

```bash
pip install -r requirements.txt
python test_policy.py          # 8/8 — the policy engine
python test_oauth.py           # 8/8 — the OAuth 2.1 core (16 tests total)
python server.py               # starts the MCP server (stdio)
```

With `WALLET_AUTO_APPROVE_UNDER_CENTS=0`, every charge the agent requests pauses as
`pending_approval` and prints an approval line to the console:

```bash
python cli_approve.py                    # list what's waiting
python cli_approve.py <intent_id>        # approve + capture
python cli_approve.py <intent_id> --deny
```

## Local dashboard

A single-page web UI to watch the balance, approve/deny pending charges, and read
history — dependency-free (stdlib), bound to `127.0.0.1`, and token-protected. Like the
CLI and Telegram, it's an out-of-band approver the agent cannot reach.

```bash
python dashboard.py     # prints http://127.0.0.1:8787/?token=... — open it
```

Set `DASHBOARD_TOKEN` to pin a token, or leave it blank to auto-generate one each run.

## Tools the agent sees

- `get_balance()` — float / spent / remaining
- `get_policy()` — the active limits
- `request_charge(amount, merchant, reason)` — creates a pending intent only
- `get_intent(intent_id)` — poll the outcome
- `list_transactions(limit)` — recent history

## Connecting an agent

- **Claude Desktop / MCP clients (stdio):** add this server (command `python`, arg
  `server.py`, cwd = this folder) to the client's MCP config.
- **ChatGPT (HTTP + OAuth):** run `server_http.py`, which wraps the same tools in an
  HTTP endpoint fronted by a real OAuth 2.1 flow (see below).

### ChatGPT connector (OAuth)

`server_http.py` (Starlette) serves the MCP endpoint at `/mcp` plus a full OAuth 2.1
authorization server (`oauth.py`): Dynamic Client Registration, Authorization Code +
PKCE, refresh tokens, discovery metadata, and bearer verification scoped to
`wallet.read` + `wallet.request_charge`. The `/authorize` step is gated by your
`OWNER_PASSCODE`, so only you can approve a connector.

```bash
pip install -r requirements.txt
export OAUTH_ISSUER=https://your-public-host   # ChatGPT requires HTTPS
export OWNER_PASSCODE=some-strong-secret
uvicorn server_http:app --port 8000            # put an HTTPS tunnel in front
```

In ChatGPT, add a custom connector pointing at `<OAUTH_ISSUER>/mcp`; it discovers the
OAuth endpoints, registers itself, and sends you to `/authorize` to sign in with the
passcode. The OAuth core is unit-tested (`test_oauth.py`), but the end-to-end ChatGPT
flow is yours to deploy behind HTTPS and verify. Never expose `cli_approve.py`,
`dashboard.py`, or the Telegram/Stripe processes to the agent.

## Approve from your phone (Telegram)

1. Create a bot with **@BotFather** → `TELEGRAM_BOT_TOKEN`.
2. Message the bot once, open `https://api.telegram.org/bot<token>/getUpdates`, read
   your numeric chat id → `TELEGRAM_CHAT_ID`.
3. `WALLET_APPROVER=telegram`, then run the poller alongside the server:

```bash
python telegram_poller.py
```

Now every over-threshold charge pings your phone with **Approve / Deny** buttons; one
tap finalizes it. Only messages from your own `TELEGRAM_CHAT_ID` are honoured, and the
agent has no access to Telegram — that's what makes it safe. You can also send
`/pending`, `/approve <id>`, `/deny <id>`.

## Going live with Stripe (recommended, economical)

Stripe Issuing is ~$0.10 per virtual card with no forced monthly minimum — you fund a
Stripe Issuing balance and spend against it. First confirm Issuing is **enabled on your
Stripe account** (Dashboard → Issuing; it may need activation, and this is where you
verify current availability in your country).

Stripe cards are **pull-based**: the merchant charges the card, so the hard ceiling is
the card's own `spending_controls`, enforced by Stripe independently of this server.

1. **Create the card once** (test mode first). In a Python REPL:
   ```python
   from issuer import StripeIssuer   # needs STRIPE_API_KEY set
   print(StripeIssuer().provision_card("Agent Wallet", "you@example.com",
                                        per_auth_cap_cents=5000, monthly_cap_cents=20000))
   ```
   Paste the printed id into `STRIPE_CARD_ID`, set `WALLET_ISSUER=stripe`.
2. **Fund the Stripe Issuing balance** with your set float — that's the max you can lose.
3. **Turn on real-time authorizations** and run the webhook so the policy engine gates
   each live charge (auto-declines out-of-policy amounts/merchants):
   ```bash
   stripe listen --forward-to localhost:4242/webhook   # Stripe CLI, in one terminal
   python stripe_webhook.py                             # in another
   ```
   For this path set `WALLET_AUTO_APPROVE_UNDER_CENTS` to your per-transaction cap so the
   card works up to the cap while float/24h/velocity/merchant limits still bind.
4. **Set `WALLET_APPROVER=telegram`** and run the poller so large buys the agent flags via
   `request_charge` hit your phone for a tap before it uses the card.

Alternative issuer (**Airwallex**, `WALLET_ISSUER=airwallex`): auth, a Payouts/Transfers
`execute_payment`, and a reconciliation read are implemented, but untested against the
live API and pricier — test in their demo host and verify fields first.

## Kill switch

Freeze/close the card at the issuer, revoke the agent's OAuth token, or just stop
`server.py`. The float is the maximum you can ever lose.

## Files

| File | Role |
|---|---|
| `config.py` | policy config from env |
| `policy.py` | **the safety boundary** — pure decision function |
| `store.py` | SQLite: intents, captures, audit; balance is derived |
| `issuer.py` | payment execution (mock / **Stripe** / Airwallex) |
| `approvals.py` | out-of-band notify (console / Telegram buttons) |
| `wallet.py` | orchestration shared by server + CLI + poller |
| `server.py` | MCP tools the agent can call |
| `cli_approve.py` | your approval channel — **not** an agent tool |
| `telegram_poller.py` | phone approvals via bot buttons — **not** an agent tool |
| `dashboard.py` | local web approval dashboard — **not** an agent tool |
| `stripe_webhook.py` | real-time Stripe authorization gate — **not** an agent tool |
| `oauth.py` | OAuth 2.1 authorization server for the connector |
| `server_http.py` | HTTP MCP transport + OAuth (ChatGPT connector) |
| `test_policy.py` / `test_oauth.py` | tests for the policy engine and OAuth core |

## Not implemented on purpose

OAuth for the MCP endpoint is left to you. The Stripe calls use the official SDK but
should be run in **test mode** first; the Airwallex calls are untested against the live
service. Anything that touches real money or credentials is yours to verify and
authorise.