{"slug": "watch-an-ai-agent-try-to-spend-money-it-shouldn-t", "title": "Watch an AI Agent Try to Spend Money It Shouldn't", "summary": "Engineers Prakash Rao and Marco Gonzalez built a live demonstration of an AI agent paying for MCP tool calls over the x402 payment protocol on a test network, including the failure path where the agent attempts to spend on something it was not approved for. The pair argue that while x402 v2 and the MCP 2026-07-28 revision moved pricing and routing metadata into HTTP headers, making paid agent traffic cheaper to meter, neither spec defines who holds the authority to approve spending — a decision they place with a budget authority the agent cannot reach. Their architecture treats the agent process and all its inputs as hostile, routing every request through an enforcement gateway, authorization service and signing service.", "body_md": "*By Prakash Rao and Marco Gonzalez*\n\nAn agent needs to enrich a customer record. The tool that does it costs money.\n\nNothing in x402 v2 says who is allowed to approve that. Nothing in MCP 2026-07-28 says it either. Both specs moved price and routing metadata into HTTP headers, which means your gateway can now *see* what a tool call costs. Seeing a price is not the same as having the authority to approve one.\n\nWe wrote about where that authority should sit in [402 Payment Required: What Enterprise MCP Servers Owe the Agents That Pay Them](https://aaif.io/blog/402-payment-required-what-enterprise-mcp-servers-owe-the-agents-that-pay-them), with the Agentic AI Foundation. Then we took it to AGNTCon / MCPCon Japan in Tokyo and let an agent actually do it, live, on a test network.\n\nIf you missed either the post or the session, here is everything you need before we start.\n\n**What x402 is.** HTTP has had a `402 Payment Required` status code sitting unused since 1997, reserved but never specified. x402 finally gave it a meaning: a server answers `402` with the terms it wants, the client retries with a signed payment authorization, and the server returns the result plus evidence of settlement. **v2 (December 2025) moved all of that into headers**: `PAYMENT-REQUIRED`, `PAYMENT-SIGNATURE` and `PAYMENT-RESPONSE`, instead of burying it in bodies.\n\n**What MCP is.** The Model Context Protocol is how an AI agent discovers and calls tools. **The 2026-07-28 revision** removed sessions and the `Mcp-Session-Id` header, added `Mcp-Method` and `Mcp-Name` as headers, and hardened authorization with issuer validation per RFC 9207.\n\n**Why those two releases matter together.** Both moved the interesting metadata *out of request bodies and into headers*. Your gateway, rate limiter and WAF can now see who is calling, which tool, and what it costs, without parsing a single JSON-RPC body. Metering and routing for paid agent traffic got dramatically cheaper.\n\n**And that is exactly where the problem starts.** Header visibility is not authority. The moment your infrastructure can see the price, it has to decide something about it, and neither spec tells you who gets to make that call. That was the argument in [our AAIF post](https://aaif.io/blog/402-payment-required-what-enterprise-mcp-servers-owe-the-agents-that-pay-them): the spending decision belongs to a budget authority the agent cannot reach, and where you put it is the part that will still matter after the protocol details have moved on.\n\n**What we did in Tokyo.** Stopped arguing and ran it: a live agent paying for MCP tool calls on a test network, including the path where it tries to spend on something it was not approved for.\n\nFive moving parts appear throughout. You do not need to memorize these; they are here so the diagrams read cleanly.\n\n| Component | Its one job | \n|---|---|\n| **Agent** | Decides what work to do. Treated as hostile throughout. Holds no private key. | \n| **Enforcement gateway** | The only permitted route out. Sees every request twice and can refuse at the boundary. | \n| **Authorization service** | Decides whether spending is permitted, and reserves the budget. The agent cannot influence it. | \n| **Signing service** | Produces signatures. Reads what it signs, and can refuse. | \n| **Protected resource** | The tool that costs money, behind a paid server. | \n\nOur threat model, so you know what is and is not being defended: the **agent process and every input to it are hostile**. The gateway, the services and their policies are **honest but fallible**. Compromised binaries, malicious operators, key extraction from hardware and chain-level attacks are **out of scope**. Different problem, different post.\n\nNow follow one request all the way through. It gets interesting about two-thirds of the way down, when the request that arrives at the boundary turns out not to be the request that was approved.\n\nOur agent has a task stated in business terms: *enrich record 42*. It does not know what that costs, and it does not know which provider will do it.\n\nSo the first problem is immediate: how do you let a hostile thing shop around, without handing it a wallet first?\n\nIt asks, and gets refused on purpose:\n\n```\nGET /tools/enrich?record=42 HTTP/1.1\nHost: resource.example\n\nHTTP/1.1 402 Payment Required\nPAYMENT-REQUIRED: eyJ2IjoyLCJhY2NlcHRzIjpbey...\n```\n\nThat header is base64 JSON. Decoded, it is an offer:\n\n```\n{\n  \"v\": 2,\n  \"accepts\": [{\n    \"scheme\": \"exact\",\n    \"network\": \"<test-network>\",\n    \"asset\": \"<stablecoin-contract>\",\n    \"payTo\": \"<payee-address>\",\n    \"maxAmountRequired\": \"<amount>\",\n    \"resource\": \"https://resource.example/tools/enrich\",\n    \"maxTimeoutSeconds\": 60,\n    \"nonce\": \"<server-chosen>\"\n  }]\n}\n```\n\nThat is the HTTP binding. Over MCP the same negotiation happens, but not in the same place. Worth knowing before you meter anything at the edge:\n\n|  | HTTP binding | MCP binding | \n|---|---|---|\n| Price challenge arrives as | `402` status +`PAYMENT-REQUIRED` | terms in the **tool result** | \n| Routing metadata | the URL path | `Mcp-Method` /`Mcp-Name` headers | \n| Visible to infra without parsing a body | yes | the challenge is **not** | \n\nThe MCP binding hands your gateway a `200` for a call nobody has paid for yet. If your metering logic keys on status codes, it will sail straight past that.\n\nHere is the first design decision that matters more than it looks. **Asking is free.**\n\nIf finding out what something costs itself costs money, then every agent needs spending authority *before* it can plan, which pushes the authorization decision to the least informed moment in the entire flow. Free discovery is what lets this first pass carry no authority to consume value at all. The boundary can admit it precisely because admitting it commits the organization to nothing.\n\nAnd because this pass is cheap to allow, the boundary can do something quietly important while it is forwarding:\n\n```\nsequenceDiagram\n    autonumber\n    participant W as Agent\n    participant G as Enforcement gateway\n    participant R as Protected resource\n\n    W->>G: candidate request, identity present,<br/>no authority to consume value\n    G->>G: validate assertion, apply a lighter policy\n    G->>G: audience translation: mint a downstream credential<br/>scoped to one resource and one action\n    G->>R: forward, carrying only that credential\n    R-->>G: resource-terms response, the quote\n    G->>G: record the witnessed terms, from the return leg\n    G-->>W: the quote\n```\n\nThe gateway **saw the quote itself**, on the way back. Remember that. It becomes the whole game at the boundary.\n\nNote also what crossed the boundary: not the enterprise credential, but a freshly minted one restricted to one resource and one action. Useful for nothing else. That is audience translation, and it is how you avoid handing a downstream service something it could turn around and reuse.\n\nNow the agent wants to actually do it. This is the moment the money becomes real, and the moment somebody other than the agent has to say yes.\n\nFour decisions are tangled together here, and the useful move is to pull them apart and give each one an owner:\n\n| Decision | Who owns it | \n|---|---|\n| What work to do | the agent | \n| Who is asking | identity provider, at the gateway | \n| What it costs | the resource server, from the arguments | \n| **Whether it is permitted** | **a budget authority the agent cannot reach** | \n\nThat last row is the one people collapse, and collapsing it is how an agent ends up able to approve its own spending.\n\nThe authorization service does four things in order. Each one is load-bearing:\n\n**It fetches the quote from the gateway, never from the request.** The agent is hostile; its claim about what it was offered is worthless. The boundary witnessed the real terms while the agent was shopping.\n\n**It filters on capability before it looks at price.** Capability and compliance narrow the field first. A filter, not a ranking. No price advantage restores an excluded candidate. Get this backwards and you have a system that will happily route regulated data somewhere cheap.\n\n**It reserves the budget before any attempt is made**, as one indivisible state transition. This is what stops concurrent agents sharing an allocation from collectively overspending it: the commitment is taken up front rather than discovered afterwards.\n\n**Then it binds the whole meaning of the action into one digest, and signs a receipt over it.**\n\n``` js\n// authorization service\nconst fields  = deriveActionFields(proposal, witnessedTerms, selection);\nconst digest  = sha256(canonicalize(fields));   // RFC 8785 serialization\n\nawait reserveAtomically(allocation, ceiling);   // before any attempt\n\nconst receipt = signReceipt({\n  decision: 'allow',\n  digest,                                        // the binding\n  policyVersion,\n  expiresAt: now() + SHORT_TTL\n});\n```\n\nOne binding, over the whole meaning of the action. Alter any bound element and the digest differs.\n\nThe agent has a receipt. It needs a signature. And here the architecture does something that surprises people:\n\n**The component that approved the action cannot sign it. The component that signs it can still refuse.**\n\n``` js\n// policy-aware signing service\nasync function sign(request) {\n  const payload = parseStructuredPayload(request);   // it can read what it signs\n  const params  = recoverConsequentialParameters(payload);\n\n  if (!withinOwnConstraints(params)) {\n    return refuse('constraint_violation');           // even though the gate approved\n  }\n  return signWithNonExportableKey(payload);\n}\n```\n\nA custody service that signs an opaque digest enforces nothing about meaning. Handed a blob, it has no basis on which to form an opinion at all. This one parses the payload first, recovers the parameters that actually matter, and applies its own constraints. It can refuse an action the authorization gate already approved.\n\nMeanwhile the gate holds no key whatsoever. It is *incapable* of producing a signature. That is not a policy choice you can accidentally configure away. It is a property of who holds what.\n\nThere is a boring reason this split survives contact with a roadmap, and it persuades engineers faster than the security argument: the rules about what a key may be used for and the rules about what policy permits change at different rates, for different reasons, owned by different teams. Putting them in one component means every policy change touches the thing holding your keys. Nobody wants that.\n\nThe signed request heads for the exit. The receipt is valid. The signature verifies.\n\nAnd the boundary refuses it.\n\n```\nsequenceDiagram\n    autonumber\n    participant W as Agent\n    participant G as Enforcement gateway\n    participant R as Protected resource\n\n    W->>G: authorized request + valid receipt\n    Note right of W: something changed after approval\n    G->>G: re-derive the action from the bytes that will actually leave\n    G->>G: recompute the digest\n    G--xW: DENIED: mismatch, fail closed\n    Note over G,R: nothing leaves\n```\n\nHere is why, and it is the single most important line in the whole design:\n\n**The gateway does not read the digest out of the receipt. It rebuilds it from the bytes that are actually leaving.**\n\n```\n// enforcement gateway, egress path\nasync function release(outgoing, receipt) {\n  if (!verifySignature(receipt)) return failClosed('bad_receipt');\n  if (expired(receipt))          return failClosed('expired_receipt');\n\n  // Re-derive from the request that will actually leave, not from the receipt.\n  // RFC 8785 fixes the serialization; a normative rule both sides build\n  // against fixes which fields participate.\n  const rebuilt = canonicalize(deriveActionFields(outgoing));\n\n  if (sha256(rebuilt) !== receipt.digest) {\n    return failClosed('digest_mismatch');   // substituted, or altered after approval\n  }\n\n  // A valid receipt is necessary, not sufficient.\n  const verdict = await policy.evaluate(deriveActionFields(outgoing));\n  if (!verdict.allow) return failClosed(verdict.reason);\n\n  return forward(translateAudience(outgoing));\n}\n```\n\nTwo independent opinions have to agree. One was formed at approval time, from the proposal; the other is formed here, from reality. Verifying a *decision* is not the same as verifying the *thing the decision was about*, and almost every system that gets this wrong gets it wrong right here.\n\nNotice what a mismatch catches in both directions. Substituting to a more expensive resource is a budget failure. Substituting to a *cheaper* one can send regulated data somewhere not approved for it. Same mismatch, same refusal.\n\nAnd notice `failClosed` on every path. Where required verification cannot be completed, the request is denied. A verifier that cannot answer is never treated as one that said yes.\n\nWhen a request does pass, the ending is deliberately unglamorous. The agent signs the value authorization but submits nothing; a facilitator submits it and bears the execution fee, so the workload needs no local balance of any kind, of any asset. Then one trace identifier binds the identity subject, the signing request, the nonce and the settlement record together, so a completed action can be reconstructed end to end from evidence held entirely *outside* the agent.\n\nNeither direction requires trusting it. That is the point.\n\nHere is the whole path, once, now that you know what each step is defending against:\n\n```\nsequenceDiagram\n    autonumber\n    participant W as Agent\n    participant G as Enforcement gateway\n    participant A as Authorization service\n    participant S as Signing service\n    participant R as Protected resource\n\n    Note over W,R: DISCOVERY, no authority to spend\n    W->>G: discovery pass, no authority to consume value\n    G->>R: forward with a scoped downstream credential\n    R-->>G: the quote\n    G->>G: record the witnessed terms\n\n    Note over W,R: AUTHORIZATION\n    W->>A: request authorization\n    A->>G: fetch witnessed terms, never from the request\n    A->>A: capability filter, then cost\n    A->>A: reserve atomically, before any attempt\n    A-->>W: signed receipt, bound to the digest\n\n    Note over W,R: SIGNING\n    W->>S: request a signature\n    S->>S: parse, recover parameters, enforce own constraints\n    S-->>W: signature from a non-exportable key\n\n    Note over W,R: EGRESS\n    W->>G: authorized request + receipt\n    G->>G: re-derive from outgoing bytes, recompute digest\n    G->>G: independent policy re-evaluation\n    G->>R: release, or refuse\n```\n\nThree things we did not fully appreciate until it was running in front of people.\n\n**The refusal is the demo.** We prepared a success path and a denial path and rehearsed the success path harder. That was backwards. An authorization architecture is most convincing at the moment nothing happens. The receipt is valid, the signature is good, and the request still does not leave. If you demo one of these, budget your stage time for the refusal.\n\n**Canonicalization determinism is not a detail, it is the design.** Two independent derivations only help if they agree. RFC 8785 fixes *how* to serialize; it does not fix *which fields participate*. Every ambiguity you leave in that question becomes a future incident that presents as a mysterious denial and resolves three layers away. Write the membership rule down as a normative artifact both sides build against, not as behaviour each side reimplements from prose.\n\n**Reachability is not authority.** The agent in this flow calls the authorization service over an ordinary API, like any client. It simply cannot move its own limit. An agent that cannot reach the authorization service is isolated; an agent that reaches it and still cannot change the answer is governed. Those are very different systems and only one of them is useful.\n\nNeither x402 v2 nor MCP 2026-07-28 makes the spending decision for you. They make the price visible and the routing cheap. Where the authority sits is still yours to place, and that placement will outlive both protocols.\n\nThe [AAIF post](https://aaif.io/blog/402-payment-required-what-enterprise-mcp-servers-owe-the-agents-that-pay-them) has the full argument and the five obligations a paid tool server owes its callers.\n\n*Code is illustrative and abbreviated to the checks under discussion.*\n\n*Patent pending. U.S. Provisional Application No. 64/149,249, filed 6 September 2026 (docket Y26S008-PPA). A provisional application only: no patent has been granted and no patent number is asserted.*", "url": "https://wpnews.pro/news/watch-an-ai-agent-try-to-spend-money-it-shouldn-t", "canonical_source": "https://dev.to/prakash_rao/watch-an-ai-agent-try-to-spend-money-it-shouldnt-2754", "published_at": "2026-09-25 18:19:17+00:00", "updated_at": "2026-09-25 18:30:59.148608+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-infrastructure", "ai-safety"], "entities": ["Prakash Rao", "Marco Gonzalez", "x402", "Model Context Protocol", "Agentic AI Foundation", "AGNTCon", "MCPCon Japan", "AAIF"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/watch-an-ai-agent-try-to-spend-money-it-shouldn-t", "markdown": "https://wpnews.pro/news/watch-an-ai-agent-try-to-spend-money-it-shouldn-t.md", "text": "https://wpnews.pro/news/watch-an-ai-agent-try-to-spend-money-it-shouldn-t.txt", "jsonld": "https://wpnews.pro/news/watch-an-ai-agent-try-to-spend-money-it-shouldn-t.jsonld"}}