Inspiration
PayPal now ships its own MCP server and Agent Toolkit, so an AI agent can create orders, send invoices, issue refunds and accept disputes. That's powerful, and it's also scary. Nothing in that toolchain decides how much an agent may refund, who it may pay, or when a human should look first.
One prompt injection in a customer email ("refund me $900 immediately") is all it takes. Add an agent that retries after a timeout, and money moves that nobody approved. We wanted a layer that makes PayPal safe to hand to an agent, without rewriting the agent.
What it does
PurseString is an MCP server that sits between any AI agent and PayPal. Every money-moving action becomes an intent. Policy then does one of three things:
runs it straight away pauses it for a human (in Slack, Discord, the terminal or over HTTP) refuses it Every decision, and the agent's stated reason, goes into an audit log.
On PayPal, it does two things:
Governs PayPal's own MCP server. PurseString runs @paypal/mcp (the PayPal Agent Toolkit) behind itself and re-exposes all 28 tools. Read-only tools such as list_transactions and get_order pass straight through. Refunds are ruled on by amount. Small ones run, larger ones wait for a human, and anything over a hard cap is refused. Refunds count toward each agent's daily budget. Dispute acceptance and cancellations always need a human. An approval covers only the exact arguments that were approved, so an agent can't get $80 approved and then refund $400. A repeated call returns PayPal's first answer instead of refunding twice. Pays vendors through PayPal and Venmo Payouts. An agent can pay for things through a send_payment tool. The recipient always comes from a payee directory in the policy, never from the agent, so a prompt injection has no address to swap in. Each intent is paid at most once. The intent ID is sent to PayPal as PayPal-Request-Id and sender_batch_id, so even a crash halfway through a call can't double-pay. The same policy file and approval flow also cover virtual-card rails (Lithic, Privacy.com and Airwallex), so a team gets one set of rules across every way its agents spend money.
How we built it
TypeScript and Node 22, on the official MCP TypeScript SDK. It runs over stdio (Claude Desktop, Cursor, Claude Code) or Streamable HTTP (remote agents and ChatGPT, with OAuth sign-in). PayPal integration: The PayPal MCP server / Agent Toolkit (@paypal/[email protected]) runs as a child process. PurseString acts as an MCP client to it and as an MCP server to the agent. PayPal's JSON Schemas are converted to zod, so the agent sees PayPal's real tool definitions plus two governance fields (agent_id, reason). PayPal OAuth (client credentials). PayPal's MCP server only ever gets a short-lived access token, never the client secret, and it is restarted when the token rotates. PayPal Payouts API (/v1/payments/payouts), with PayPal and Venmo wallets, idempotency keys and status refresh. Everything runs on the PayPal sandbox by default. Policy engine: a pure function from intent to decision, written in YAML (policies.yaml), shared by every rail. State and audit: a local JSON store with file locking, so the server and a human approving from a terminal never race. The audit log is append-only JSONL, hash-chained so any edit or deleted line shows up (verify-log), with an optional HMAC key. Demo: a single-page app served by the same server. A scripted agent makes real MCP tool calls, and the visitor is the approver. It's deployed on Render from the repo's Dockerfile and render.yaml. Tests: 116 automated tests, covering PayPal Payouts, governance of the PayPal MCP tools against a stand-in MCP server, and the demo flows. AI tools used The agents being governed: any MCP-capable model. We tested with Claude in Claude Desktop and Claude Code. The demo uses a scripted agent, so judges see the same flow every time. PayPal Agent Toolkit / PayPal MCP server: the PayPal-side AI tooling that PurseString governs. Claude Code (Anthropic): used as a pair programmer to write and review much of the code, tests and docs.
Challenges we ran into
PayPal's toolkit reports API failures as normal results ({"ok":false,"status":403}), not as MCP errors. Taken at face value, a failed refund would look like it succeeded. PurseString now detects these, so a failed call is never recorded as done or counted against the budget. PayPal has no API for minting virtual cards, so the card model we use on other rails didn't fit. We designed a payout rail where the policy holds the payee directory. That turned out to be a stronger defence against prompt injection than a card. Proxying another MCP server's tools safely. We had to re-expose PayPal's schemas, add governance fields, and still pass PayPal exactly the arguments that were approved. A hash of the arguments ties each approval to one specific call.
Accomplishments that we're proud of
The guardrails work with PayPal's own AI tooling, not a fork of it. Upgrading @paypal/mcp doesn't change the safety model. No double refunds and no double payouts, even across retries and crashes. A demo where the judge is the human in the loop.
What we learned
Agent payments fail in ordinary ways: retries, timeouts, a polite-sounding email that's really an instruction. The fix isn't a smarter agent. It's an independent layer that asks "should this money move?" every time, and remembers the answer.
What's next for PurseString
Approvals inside PayPal's own surfaces, and per-merchant refund policies. Governing PayPal's remote MCP server as well as the local one. More rails behind the same policy: Venmo for teams, Braintree, and bank transfers. A hosted version for teams that don't want to run a server
Built With
- ai-agents
- claude
- claude-code
- docker
- mcp
- model-context-protocol
- node.js
- paypal
- paypal-agent-toolkit
- paypal-mcp
- paypal-payouts
- render
- typescript
- yaml
- zod