MCP server: @oid4pay/oid4pay-mcp
The OID4Pay MCP server is a stdio-mode Model Context Protocol server that surfaces OID4AC as agent-callable tools. Drop it into Claude Desktop, Cline, Continue, or any MCP-aware toolchain; the agent can then initiate a payment against any OID4AC-enabled merchant without you implementing the protocol.
Twelve tools ship in oid4pay-mcp 0.1.0. Each tool wraps a
deterministic slice of the protocol wire shapes: registration, payment,
mandate verification, merchant discovery, wallet management, GDPR DSAR, and
the audit chain. The MCP refuses to call any AS endpoint that violates the algorithm whitelist.
Install
npm install -g @oid4pay/oid4pay-mcpClaude Desktop configuration
Add to ~/.config/Claude/claude_desktop_config.json (or the
Windows / macOS equivalent):
{
"mcpServers": {
"oid4pay": {
"command": "oid4pay-mcp",
"args": ["--as-origin", "https://sandbox.oid4pay.com"],
"env": {
"OID4PAY_CLIENT_ID": "client_...",
"OID4PAY_DPOP_PRIVATE_JWK": "{...}",
"OID4PAY_PKJ_PRIVATE_JWK": "{...}",
"OID4PAY_DISCOVERY_URL": "https://sandbox.discover.oid4pay.com"
}
}
}
}The agent's DPoP and private_key_jwt keys are written to ~/.local/share/oid4pay-mcp/keys.json on first registration.
The persisted client_id lands at ~/.local/share/oid4pay-mcp/client.json; rotate by deleting the
file and calling agent_register again with a fresh setup_token.
Token types
Each tool documents which token type the AS accepts on the wire:
- Agent-only JWT-AT: minted via PAR + token, scoped to the
agent's own actions. Carries the agent
client_idassub; does NOT carry a wallet-session principal. - Wallet-session JWT-AT: minted via PAR + token after a
principal-bound consent. Carries the principal_id resolved by the wallet.
Required for DSAR (GDPR Articles 15 and 20) and audit chain queries.
Agent-only tokens are refused with 401
invalid_token. - One-shot setup token: opaque string issued by the Wallet Portal during agent registration. Single-use; bound to the principal who authorised the QR scan or dashboard link.
Tools (table of contents)
| Tool | Purpose | Protocol |
|---|---|---|
agent_register | RFC 7591 Dynamic Client Registration (DCR). | private_key_jwt assertion |
agent_payment_initiate | Full OID4AC chain (PAR : authorize : token : verify-mandate). | PAR response through authorization code single-use |
agent_verify_mandate | SD-JWT VC + KB-JWT presentation at a merchant /verify-mandate. | SD-JWT VC mandate |
discovery_list_merchants | OID4Pay directory query (country, currency, rail filters). | Directory query (see HTTP message signature for catalog signing) |
agent_browse_merchant | Signed catalog preview for a single merchant. | HTTP message signature |
agent_wallet_register | Wallet-side DCR with display metadata (client_name, purpose). | OIDC discovery metadata |
agent_wallet_list_agents | Read the principal's registered agents (wallet:read). | JWT-AT claim set + resource indicators |
agent_wallet_revoke_agent | Cascade-revoke a registered agent (wallet:write). | authorization code single-use + /oauth/revoke |
agent_buy_cheapest_from_store | Sugar: fetch catalog : pick cheapest : pay. | PAR response through authorization code single-use |
agent_dsar_initiate | GDPR Article 15 (access) or Article 20 (portability) DSAR. | GDPR privacy rights + audit chain entry envelope |
agent_audit_chain_query | Forensic query against the principal's audit chain entries. | audit chain entry envelope |
agent_payment_history | UI-friendly payment projection over the audit chain. | audit chain entry envelope |
oid4pay-mcp/src/server/index.ts).
The MCP layer wraps the AS wire shapes verbatim. When a wire shape changes,
the MCP layer follows it; when this page disagrees with the code, the code
wins and we fix the page.agent_register
Generates or loads the agent's DPoP and private_key_jwt keypairs, then calls POST /oauth/register on the AS with the
supplied setup token. Persists the resulting client_id to ~/.local/share/oid4pay-mcp/client.json. RFC 7591 Dynamic
Client Registration; client authentication follows the private_key_jwt assertion.
Subsequent tool calls reuse the persisted registration.
Input schema
{
"type": "object",
"properties": {
"setup_token": { "type": "string", "minLength": 1 },
"as_url": { "type": "string", "format": "uri" },
"redirect_uris": { "type": "array", "minItems": 1,
"items": { "type": "string", "format": "uri" } },
"client_name": { "type": "string" }
},
"required": ["setup_token", "as_url", "redirect_uris"]
}Output
{
"ok": true,
"client_id": "agent_3ff8a1d2b9c4e7f0",
"as_url": "https://sandbox.oid4pay.com"
}Errors
| Operator-facing message | Cause |
|---|---|
agent_register failed: invalid_client_metadata | AS rejected the client_name or other RFC 7591 fields. |
agent_register failed: invalid_redirect_uri | One of the redirect_uris uses a disallowed scheme. |
agent_register failed: invalid_software_statement | The setup_token is expired, already used, or unbound to this AS. |
Auth requirement
One-shot setup token issued by the Wallet Portal. The token binds the agent to a principal at issue time; the AS rejects reuse.
Cross-references
- Protocol: private_key_jwt assertion
Code samples
Python (oid4pay-oid4ac)
from oid4pay_oid4ac import OID4PayClient
client = OID4PayClient(as_url="https://sandbox.oid4pay.com")
reg = client.register(
setup_token="setup_4f6a2c1d8b9e3f70",
redirect_uris=["https://my-agent.example.com/callback"],
client_name="acme-research-agent",
)
print(reg.client_id)Node (@oid4pay/oid4ac-merchant)
import { OID4PayClient } from "@oid4pay/oid4ac-merchant";
const client = new OID4PayClient({ asUrl: "https://sandbox.oid4pay.com" });
const reg = await client.register({
setupToken: "setup_4f6a2c1d8b9e3f70",
redirectUris: ["https://my-agent.example.com/callback"],
clientName: "acme-research-agent",
});
console.log(reg.client_id);Raw JSON-RPC (MCP transport)
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "agent_register",
"arguments": {
"setup_token": "setup_4f6a2c1d8b9e3f70",
"as_url": "https://sandbox.oid4pay.com",
"redirect_uris": ["https://my-agent.example.com/callback"],
"client_name": "acme-research-agent"
}
}
}agent_payment_initiate
Drives the full OID4AC dance against the configured AS: PAR : authorize : token : verify-mandate. Returns the mandate id and (when issued) the signed receipt JWS. Every leg follows its wire shape: PAR response, JWT-AT claim set, SD-JWT VC mandate, KB-JWT, DPoP, resource indicators, algorithm whitelist, and authorization code single-use.
Input schema
{
"type": "object",
"properties": {
"merchant_url": { "type": "string", "format": "uri" },
"merchant_verify_url": { "type": "string", "format": "uri" },
"amount": {
"type": "object",
"properties": {
"currency": { "type": "string", "length": 3 },
"amount_minor": { "type": "integer", "minimum": 1 }
},
"required": ["currency", "amount_minor"]
},
"line_items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"sku": { "type": "string", "minLength": 1 },
"qty": { "type": "integer", "minimum": 1 },
"unit_price_minor": { "type": "integer", "minimum": 0 },
"currency": { "type": "string", "length": 3 }
},
"required": ["sku", "qty"]
}
},
"offer_id": { "type": "string" },
"scope": { "type": "string", "default": "openid payment:initiate" },
"redirect_uri": { "type": "string", "format": "uri" },
"consent_mode_hint": { "enum": ["auto", "dashboard", "scan"] }
},
"required": ["merchant_url", "merchant_verify_url", "amount", "line_items", "redirect_uri"]
}Output
{
"mandate_id": "mandate_7b2c4d6e8f0a1b3c",
"mandate_jwt": "eyJhbGciOiJFZERTQSIsImtpZCI6...",
"presentation": "eyJhbGciOiJFZERTQSIsInR5cCI6InNkLWp3dC12YyJ9...~WyJzYWx0...~eyJhbGciOiJFZERTQSIsInR5cCI6ImtiK2p3dCJ9...",
"payment_intent_id": "pi_3OQ8Z2L8KZ4f5G2c1AbCdE",
"payment_provider_ref": "ch_3OQ8Z2L8KZ4f5G2c0HiJkLmN",
"settled_at": "2026-05-15T05:21:28Z",
"receipt_url": "https://sandbox.oid4pay.com/receipts/r_4f6a2c1d8b9e.json"
}Errors
| Operator-facing message | Cause |
|---|---|
agent_payment_initiate failed: PAR failed: 400 | PAR body rejected: missing authorization_details, bad resource indicator, or stale DPoP nonce. |
agent_payment_initiate failed: token failed: 400 invalid_grant | Authorization code expired or reused. Re-run PAR. |
agent_payment_initiate failed: token failed: 400 invalid_target | The resource indicator does not match the PAR-bound audience (see resource indicators). |
agent_payment_initiate failed: verify-mandate failed: 422 | Merchant rejected the SD-JWT VC presentation (audience mismatch, expired, status-revoked, or KB-JWT nonce wrong). |
agent_payment_initiate failed: verify-mandate failed: 502 | Merchant /verify-mandate upstream error; the mandate is still valid; retry with the same idempotency_key. |
agent_payment_initiate failed: declined: no mandate issued | Principal declined consent at the wallet; no JWT-AT was issued. |
Auth requirement
Agent-registered client_id with a DPoP keypair. The MCP
reads the persisted client.json; agent_register must have run first.
Cross-references
- Protocol: PAR response, JWT-AT claim set, SD-JWT VC mandate, HTTP message signature, DPoP, private_key_jwt assertion, resource indicators, algorithm whitelist, authorization code single-use.
Code samples
Python (oid4pay-oid4ac)
from oid4pay_oid4ac import OID4PayClient, Money, LineItem
client = OID4PayClient.from_persisted()
result = client.pay(
merchant_url="https://shop.alpacanica.com",
merchant_verify_url="https://shop.alpacanica.com/verify-mandate",
amount=Money(currency="EUR", amount_minor=1299),
line_items=[LineItem(sku="alpaca-sock-blue-43", qty=1)],
redirect_uri="https://my-agent.example.com/callback",
)
print(result.payment_intent_id, result.mandate_id)Node (@oid4pay/oid4ac-merchant)
import { OID4PayClient } from "@oid4pay/oid4ac-merchant";
const client = await OID4PayClient.fromPersisted();
const result = await client.pay({
merchantResource: "https://shop.alpacanica.com",
merchantVerifyUrl: "https://shop.alpacanica.com/verify-mandate",
authorizationDetails: [{
type: "oid4ac_mandate",
amount_minor: 1299,
currency: "EUR",
merchant: "https://shop.alpacanica.com",
line_items: [{ sku: "alpaca-sock-blue-43", qty: 1 }],
}],
redirectUri: "https://my-agent.example.com/callback",
scope: "openid payment:initiate",
lineItems: [{ sku: "alpaca-sock-blue-43", qty: 1 }],
});
console.log(result.payment_intent_id, result.mandate_id);Raw JSON-RPC (MCP transport)
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "agent_payment_initiate",
"arguments": {
"merchant_url": "https://shop.alpacanica.com",
"merchant_verify_url": "https://shop.alpacanica.com/verify-mandate",
"amount": { "currency": "EUR", "amount_minor": 1299 },
"line_items": [{ "sku": "alpaca-sock-blue-43", "qty": 1 }],
"redirect_uri": "https://my-agent.example.com/callback",
"scope": "openid payment:initiate"
}
}
}agent_verify_mandate
Posts a compact SD-JWT VC presentation (mandate JWT + selective
disclosures + KB-JWT) to the merchant's /verify-mandate endpoint and returns the verifier's reply. Standalone helper for agents
acting as their own merchant or for re-verification after a network
failure. The presentation envelope follows the SD-JWT VC mandate shape; KB-JWT
key binding enforces the cnf.jkt claim end-to-end.
Input schema
{
"type": "object",
"properties": {
"merchant_verify_url": { "type": "string", "format": "uri" },
"presentation": { "type": "string", "minLength": 1 },
"line_items": { "type": "array",
"items": { "$ref": "#/definitions/LineItem" } },
"offer_id": { "type": "string" },
"idempotency_key": { "type": "string" }
},
"required": ["merchant_verify_url", "presentation", "line_items"]
}Output
{
"mandate_id": "mandate_7b2c4d6e8f0a1b3c",
"verified_at": "2026-05-15T05:21:28Z",
"verifier_principal_id": "principal_3ff8a1d2b9c4e7f0",
"spend_cap_remaining_minor": 4701
}Errors
| Operator-facing message | Cause |
|---|---|
agent_verify_mandate failed: verify-mandate failed: 422 mandate_audience_mismatch | Mandate aud claim does not match merchant_verify_url origin. |
agent_verify_mandate failed: verify-mandate failed: 422 mandate_kb_nonce_mismatch | KB-JWT nonce does not match the merchant's challenge nonce. |
agent_verify_mandate failed: verify-mandate failed: 422 mandate_status_revoked | Mandate revoked at the AS (cascade-revoke or principal-initiated). |
agent_verify_mandate failed: verify-mandate failed: 422 mandate_expired | Mandate exp claim in the past. |
Auth requirement
None at the AS layer; the presentation carries its own cryptographic
proof. The agent must hold the SD-JWT VC + KB-JWT (issued during agent_payment_initiate).
Cross-references
- Protocol: SD-JWT VC mandate
Code samples
Python (oid4pay-oid4ac)
from oid4pay_oid4ac import OID4PayClient, LineItem
client = OID4PayClient.from_persisted()
reply = client.verify_mandate(
merchant_verify_url="https://shop.alpacanica.com/verify-mandate",
presentation=presentation_compact,
line_items=[LineItem(sku="alpaca-sock-blue-43", qty=1)],
idempotency_key="ik_4f6a2c1d8b9e3f70",
)
print(reply.mandate_id, reply.spend_cap_remaining_minor)Node (@oid4pay/oid4ac-merchant)
import { OID4PayClient } from "@oid4pay/oid4ac-merchant";
const client = await OID4PayClient.fromPersisted();
const reply = await client.verifyMandate({
merchantVerifyUrl: "https://shop.alpacanica.com/verify-mandate",
presentation: presentationCompact,
lineItems: [{ sku: "alpaca-sock-blue-43", qty: 1 }],
idempotencyKey: "ik_4f6a2c1d8b9e3f70",
});
console.log(reply.mandate_id, reply.spend_cap_remaining_minor);Raw JSON-RPC (MCP transport)
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "agent_verify_mandate",
"arguments": {
"merchant_verify_url": "https://shop.alpacanica.com/verify-mandate",
"presentation": "eyJhbGciOiJFZERTQSIs...~WyJzYWx0...~eyJhbGciOiJFZERTQSIs...",
"line_items": [{ "sku": "alpaca-sock-blue-43", "qty": 1 }],
"idempotency_key": "ik_4f6a2c1d8b9e3f70"
}
}
}discovery_list_merchants
Calls GET /merchants on the OID4Pay discovery service and
returns the paginated list of verified merchants. Filters: country (ISO
3166-1 alpha-2), currency (ISO 4217), rail (card, ideal, sepa_debit, etc.), free-text search
against business_name. Pagination via page + page_size (server caps page_size at 100).
Returns merchant identities only (id, audience, business name, country,
accepted currencies and rails); no inventory.
Input schema
{
"type": "object",
"properties": {
"country": { "type": "string", "length": 2 },
"currency": { "type": "string", "length": 3 },
"rail": { "type": "string" },
"q": { "type": "string" },
"page": { "type": "integer", "minimum": 1 },
"page_size": { "type": "integer", "minimum": 1, "maximum": 100 }
}
}Output
{
"merchants": [
{
"id": "merch_alpacanica",
"audience": "https://shop.alpacanica.com",
"business_name": "Alpacanica Outfitters",
"country": "NL",
"accepted_currencies": ["EUR", "USD"],
"accepted_rails": ["card", "ideal", "sepa_debit"],
"verified_at": "2026-04-01T00:00:00Z"
},
{
"id": "merch_bristleandslate",
"audience": "https://shop.bristleandslate.com",
"business_name": "Bristle and Slate",
"country": "NL",
"accepted_currencies": ["EUR"],
"accepted_rails": ["card", "ideal"],
"verified_at": "2026-04-12T00:00:00Z"
}
],
"page": 1,
"page_size": 20,
"total": 2
}Errors
| Operator-facing message | Cause |
|---|---|
discovery_list_merchants failed: 400 invalid_filter | Filter value rejected (e.g. country not ISO 3166-1, currency not ISO 4217). |
discovery_list_merchants failed: 500 server_error | Discovery service upstream error. |
discovery_list_merchants failed: fetch failed | Transport-level failure (ECONNREFUSED, DNS, TLS). |
Auth requirement
None. The discovery service is unauthenticated for read; results are filtered to verified, in-good-standing merchants.
Cross-references
- Protocol: catalog signing follows the HTTP message signature.
Code samples
Python (oid4pay-oid4ac)
from oid4pay_oid4ac import DiscoveryClient
disc = DiscoveryClient(base_url="https://sandbox.discover.oid4pay.com")
reply = disc.list_merchants(country="NL", currency="EUR", rail="ideal")
for m in reply.merchants:
print(m.id, m.business_name, m.accepted_rails)Node (@oid4pay/oid4ac-merchant)
import { listMerchants } from "@oid4pay/oid4ac-merchant/discovery";
const reply = await listMerchants(
{ country: "NL", currency: "EUR", rail: "ideal" },
{ baseUrl: "https://sandbox.discover.oid4pay.com" },
);
for (const m of reply.merchants) {
console.log(m.id, m.business_name, m.accepted_rails);
}Raw JSON-RPC (MCP transport)
{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "discovery_list_merchants",
"arguments": {
"country": "NL",
"currency": "EUR",
"rail": "ideal",
"page": 1,
"page_size": 20
}
}
}agent_browse_merchant
Calls GET /merchants/{id}/catalog-preview on the
OID4Pay discovery service. The discovery service proxies the merchant's
RFC 9421 signed catalog (first page only); the response carries the
merchant's signature, signature_input, and content_digest fields. The MCP rejects responses missing any
signed field (UnverifiedCatalogError); refusing to act on an
unsigned catalog is where the HTTP message signature is enforced.
Input schema
{
"type": "object",
"properties": {
"merchant_id": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9_:-]+$"
}
},
"required": ["merchant_id"]
}Output
{
"merchant_id": "merch_alpacanica",
"audience": "https://shop.alpacanica.com",
"signature": ":MEUCIQDt...:",
"signature_input": "sig1=(\"@method\" \"@target-uri\" \"content-digest\");keyid=\"merch_alpacanica_2026Q2\";alg=\"ed25519\";created=1715750488;expires=1715750548",
"content_digest": "sha-256=:bdaTFvfksp...:",
"items": [
{
"sku": "alpaca-sock-blue-43",
"title": "Alpaca wool sock, sky blue, size 43",
"unit_price_minor": 1299,
"currency": "EUR",
"in_stock": true,
"category": "apparel",
"offer_url": "https://shop.alpacanica.com/oid4ac/offer/alpaca-sock-blue-43"
}
]
}Errors
| Operator-facing message | Cause |
|---|---|
agent_browse_merchant rejected upstream catalog (missing signed field "signature") | Upstream catalog response omitted a signed field; the MCP refuses to act on an unsigned catalog. |
agent_browse_merchant failed: 404 not_found | No merchant with that id in the directory. |
agent_browse_merchant failed: 429 rate_limited | Discovery service rate-limit. Backoff before retrying. |
agent_browse_merchant failed: 502 bad_gateway | Upstream merchant catalog endpoint unreachable. |
agent_browse_merchant failed: invalid_merchant_id | Path-traversal defence: merchant_id must match ^[A-Za-z0-9_:-]+$. |
Auth requirement
None. The MCP verifies the merchant's RFC 9421 signature before returning.
Cross-references
- Protocol: RFC 9421 HTTP message signature.
Code samples
Python (oid4pay-oid4ac)
from oid4pay_oid4ac import DiscoveryClient
disc = DiscoveryClient(base_url="https://sandbox.discover.oid4pay.com")
catalog = disc.browse_merchant("merch_alpacanica")
for item in catalog.items:
print(item.sku, item.unit_price_minor, item.currency)Node (@oid4pay/oid4ac-merchant)
import { browseMerchant } from "@oid4pay/oid4ac-merchant/discovery";
const catalog = await browseMerchant(
"merch_alpacanica",
{ baseUrl: "https://sandbox.discover.oid4pay.com" },
);
for (const item of catalog.items) {
console.log(item.sku, item.unit_price_minor, item.currency);
}Raw JSON-RPC (MCP transport)
{
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "agent_browse_merchant",
"arguments": {
"merchant_id": "merch_alpacanica"
}
}
}agent_wallet_register
RFC 7591 Dynamic Client Registration against the AS, persisting the new client_id and DPoP keypair locally. Differs from agent_register in that it ships a client_name and purpose that the Wallet Portal renders in its agents
list (the user-facing labels). Returns the bound principal_id alongside
the issued client_id. Follows the OIDC discovery metadata.
Input schema
{
"type": "object",
"properties": {
"setup_token": { "type": "string", "minLength": 1 },
"as_url": { "type": "string", "format": "uri" },
"redirect_uris": { "type": "array", "minItems": 1,
"items": { "type": "string", "format": "uri" } },
"client_name": { "type": "string", "minLength": 1 },
"purpose": { "type": "string", "minLength": 1 },
"scope": { "type": "string", "default": "wallet:read wallet:write" }
},
"required": ["setup_token", "as_url", "redirect_uris", "client_name", "purpose"]
}Output
{
"ok": true,
"client_id": "agent_3ff8a1d2b9c4e7f0",
"principal_id": "principal_8a2c1d8b9e3f70a4",
"client_name": "acme-research-agent",
"purpose": "Find and book research papers under EUR 50/month.",
"scope": "wallet:read wallet:write",
"as_url": "https://sandbox.oid4pay.com"
}Errors
| Operator-facing message | Cause |
|---|---|
agent_wallet_register failed: invalid_client_metadata | client_name empty or violates the AS validators. |
agent_wallet_register failed: invalid_software_statement | Setup token expired, single-use, or unbound. |
agent_wallet_register failed: invalid_redirect_uri | One of the redirect_uris rejected (disallowed scheme, mismatched host). |
Auth requirement
One-shot setup token issued by the Wallet Portal QR scan.
Cross-references
- Protocol: OIDC discovery metadata
Code samples
Python (oid4pay-oid4ac)
from oid4pay_oid4ac import WalletClient
wallet = WalletClient(as_url="https://sandbox.oid4pay.com")
reg = wallet.register_agent(
setup_token="setup_4f6a2c1d8b9e3f70",
redirect_uris=["https://my-agent.example.com/callback"],
client_name="acme-research-agent",
purpose="Find and book research papers under EUR 50/month.",
scope="wallet:read wallet:write",
)
print(reg.client_id, reg.principal_id)Node (@oid4pay/oid4ac-merchant)
import { WalletClient } from "@oid4pay/oid4ac-merchant/wallet";
const wallet = new WalletClient({ asUrl: "https://sandbox.oid4pay.com" });
const reg = await wallet.registerAgent({
setupToken: "setup_4f6a2c1d8b9e3f70",
redirectUris: ["https://my-agent.example.com/callback"],
clientName: "acme-research-agent",
purpose: "Find and book research papers under EUR 50/month.",
scope: "wallet:read wallet:write",
});
console.log(reg.client_id, reg.principal_id);Raw JSON-RPC (MCP transport)
{
"jsonrpc": "2.0",
"id": 6,
"method": "tools/call",
"params": {
"name": "agent_wallet_register",
"arguments": {
"setup_token": "setup_4f6a2c1d8b9e3f70",
"as_url": "https://sandbox.oid4pay.com",
"redirect_uris": ["https://my-agent.example.com/callback"],
"client_name": "acme-research-agent",
"purpose": "Find and book research papers under EUR 50/month.",
"scope": "wallet:read wallet:write"
}
}
}agent_wallet_list_agents
Runs a wallet:read OAuth dance against the AS to mint a
JWT-AT scoped wallet:read, then GET /wallet/agents.
Returns the principal's agents (IDOR-safe: the AS filters on the JWT-AT sub claim resolved by the wallet session). Errors with insufficient_scope if the AS refuses the requested scope.
Input schema
{
"type": "object",
"properties": {
"as_url": { "type": "string", "format": "uri" },
"resource": { "type": "string", "format": "uri" },
"redirect_uri": { "type": "string", "format": "uri" }
},
"required": ["as_url", "resource", "redirect_uri"]
}Output
{
"agents": [
{
"client_id": "agent_3ff8a1d2b9c4e7f0",
"client_name": "acme-research-agent",
"purpose": "Find and book research papers under EUR 50/month.",
"registered_at": "2026-05-01T09:14:22Z",
"last_used_at": "2026-05-15T05:21:28Z",
"scope": "wallet:read wallet:write",
"status": "active"
}
]
}Errors
| Operator-facing message | Cause |
|---|---|
agent_wallet_list_agents: insufficient_scope (need wallet:read); ... | AS refused the wallet:read scope (the principal has not granted it). |
agent_wallet_list_agents failed (401 invalid_token): ... | JWT-AT expired or DPoP proof invalid. |
agent_wallet_list_agents failed (403 forbidden): ... | Token valid but principal mismatch; the AS-side IDOR filter rejected the request. |
Auth requirement
Wallet-session JWT-AT, scope wallet:read.
Cross-references
- Protocol: JWT-AT claim set, resource indicators.
Code samples
Python (oid4pay-oid4ac)
from oid4pay_oid4ac import OID4PayClient, WalletClient
client = OID4PayClient.from_persisted()
token = client.get_access_token(
redirect_uri="https://my-agent.example.com/callback",
resource="https://sandbox.oid4pay.com",
scope="wallet:read",
)
wallet = WalletClient(as_url="https://sandbox.oid4pay.com")
reply = wallet.list_agents(token.access_token)
for a in reply.agents:
print(a.client_id, a.client_name, a.status)Node (@oid4pay/oid4ac-merchant)
import { OID4PayClient } from "@oid4pay/oid4ac-merchant";
import { WalletClient } from "@oid4pay/oid4ac-merchant/wallet";
const client = await OID4PayClient.fromPersisted();
const token = await client.getAccessToken({
redirectUri: "https://my-agent.example.com/callback",
resource: "https://sandbox.oid4pay.com",
scope: "wallet:read",
});
const wallet = new WalletClient({ asUrl: "https://sandbox.oid4pay.com" });
const reply = await wallet.listAgents(token.access_token);
for (const a of reply.agents) console.log(a.client_id, a.client_name);Raw JSON-RPC (MCP transport)
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "agent_wallet_list_agents",
"arguments": {
"as_url": "https://sandbox.oid4pay.com",
"resource": "https://sandbox.oid4pay.com",
"redirect_uri": "https://my-agent.example.com/callback"
}
}
}agent_wallet_revoke_agent
Runs a wallet:write OAuth dance against the AS to mint a
JWT-AT scoped wallet:write, then POST /wallet/agents/{agent_client_id}/revoke. The
AS cascade-revokes every active token family for the agent (mandates,
refresh tokens, access tokens), as set out in authorization code single-use and /oauth/revoke. Errors with insufficient_scope if the AS refuses the requested scope.
Input schema
{
"type": "object",
"properties": {
"as_url": { "type": "string", "format": "uri" },
"agent_client_id": { "type": "string", "minLength": 1 },
"resource": { "type": "string", "format": "uri" },
"redirect_uri": { "type": "string", "format": "uri" }
},
"required": ["as_url", "agent_client_id", "resource", "redirect_uri"]
}Output
{
"ok": true,
"agent_client_id": "agent_3ff8a1d2b9c4e7f0",
"revoked_at": "2026-05-15T05:21:28Z",
"cascade": {
"mandates_revoked": 12,
"refresh_tokens_revoked": 1,
"access_tokens_revoked": 1
}
}Errors
| Operator-facing message | Cause |
|---|---|
agent_wallet_revoke_agent: insufficient_scope (need wallet:write); ... | AS refused the wallet:write scope. |
agent_wallet_revoke_agent failed (404 not_found): ... | No agent with that client_id bound to this principal. |
agent_wallet_revoke_agent failed (409 conflict): ... | Agent already revoked; the cascade is idempotent but the AS reports the prior state. |
agent_wallet_revoke_agent failed (503 server_error): ... | AS transient failure; retry safe (revoke is idempotent). |
Auth requirement
Wallet-session JWT-AT, scope wallet:write.
Cross-references
- Protocol: authorization code single-use, /oauth/revoke.
Code samples
Python (oid4pay-oid4ac)
from oid4pay_oid4ac import OID4PayClient, WalletClient
client = OID4PayClient.from_persisted()
token = client.get_access_token(
redirect_uri="https://my-agent.example.com/callback",
resource="https://sandbox.oid4pay.com",
scope="wallet:write",
)
wallet = WalletClient(as_url="https://sandbox.oid4pay.com")
reply = wallet.revoke_agent(token.access_token, "agent_3ff8a1d2b9c4e7f0")
print(reply.cascade.mandates_revoked)Node (@oid4pay/oid4ac-merchant)
import { OID4PayClient } from "@oid4pay/oid4ac-merchant";
import { WalletClient } from "@oid4pay/oid4ac-merchant/wallet";
const client = await OID4PayClient.fromPersisted();
const token = await client.getAccessToken({
redirectUri: "https://my-agent.example.com/callback",
resource: "https://sandbox.oid4pay.com",
scope: "wallet:write",
});
const wallet = new WalletClient({ asUrl: "https://sandbox.oid4pay.com" });
const reply = await wallet.revokeAgent(
token.access_token,
"agent_3ff8a1d2b9c4e7f0",
);
console.log(reply.cascade.mandates_revoked);Raw JSON-RPC (MCP transport)
{
"jsonrpc": "2.0",
"id": 8,
"method": "tools/call",
"params": {
"name": "agent_wallet_revoke_agent",
"arguments": {
"as_url": "https://sandbox.oid4pay.com",
"agent_client_id": "agent_3ff8a1d2b9c4e7f0",
"resource": "https://sandbox.oid4pay.com",
"redirect_uri": "https://my-agent.example.com/callback"
}
}
}agent_buy_cheapest_from_store
Sugar tool. Fetches the merchant's /.well-known/oid4ac-catalog, picks the cheapest in-stock
item matching the optional category and shipping filters under max_amount_minor, and pays for it through the full OID4AC
flow. The MCP combines fetchCatalog, pickCheapest, and agent_payment_initiate in one
call. Returns the chosen item + the payment result.
Input schema
{
"type": "object",
"properties": {
"store_url": { "type": "string", "format": "uri" },
"merchant_verify_url":{ "type": "string", "format": "uri" },
"max_amount_minor": { "type": "integer", "minimum": 1 },
"currency": { "type": "string", "length": 3, "default": "EUR" },
"category": { "type": "string" },
"ships_to_country": { "type": "string" },
"redirect_uri": { "type": "string", "format": "uri" }
},
"required": ["store_url", "merchant_verify_url", "max_amount_minor", "redirect_uri"]
}Output
{
"chosen": {
"sku": "alpaca-sock-blue-43",
"title": "Alpaca wool sock, sky blue, size 43",
"unit_price_minor": 1299,
"currency": "EUR",
"in_stock": true,
"category": "apparel"
},
"result": {
"mandate_id": "mandate_7b2c4d6e8f0a1b3c",
"payment_intent_id": "pi_3OQ8Z2L8KZ4f5G2c1AbCdE",
"payment_provider_ref": "ch_3OQ8Z2L8KZ4f5G2c0HiJkLmN",
"settled_at": "2026-05-15T05:21:28Z"
}
}Errors
| Operator-facing message | Cause |
|---|---|
agent_buy_cheapest_from_store: no in-stock matching items under the cap | No item satisfies the category, currency, max_amount_minor, and ships_to_country filters. |
agent_buy_cheapest_from_store failed: catalog 404 | The merchant does not serve a catalog at the expected well-known path. |
agent_buy_cheapest_from_store failed: catalog 502 | Upstream merchant catalog endpoint unreachable. |
agent_buy_cheapest_from_store failed: PAR failed: 400 | PAR rejected after the pick; same causes as agent_payment_initiate. |
agent_buy_cheapest_from_store failed: verify-mandate failed: 502 | Mandate was issued but merchant verifier upstream errored; the mandate is still valid; retry verify with the same idempotency key. |
Auth requirement
Agent-registered client_id with a DPoP keypair (same
preconditions as agent_payment_initiate).
Cross-references
- Protocol: PAR response through authorization code single-use for the payment chain.
Code samples
Python (oid4pay-oid4ac)
from oid4pay_oid4ac import OID4PayClient
client = OID4PayClient.from_persisted()
out = client.buy_cheapest_from_store(
store_url="https://shop.alpacanica.com",
merchant_verify_url="https://shop.alpacanica.com/verify-mandate",
max_amount_minor=2500,
currency="EUR",
category="apparel",
redirect_uri="https://my-agent.example.com/callback",
)
print(out.chosen.sku, out.result.payment_intent_id)Node (@oid4pay/oid4ac-merchant)
import { OID4PayClient } from "@oid4pay/oid4ac-merchant";
const client = await OID4PayClient.fromPersisted();
const out = await client.buyCheapestFromStore({
storeUrl: "https://shop.alpacanica.com",
merchantVerifyUrl: "https://shop.alpacanica.com/verify-mandate",
maxAmountMinor: 2500,
currency: "EUR",
category: "apparel",
redirectUri: "https://my-agent.example.com/callback",
});
console.log(out.chosen.sku, out.result.payment_intent_id);Raw JSON-RPC (MCP transport)
{
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": {
"name": "agent_buy_cheapest_from_store",
"arguments": {
"store_url": "https://shop.alpacanica.com",
"merchant_verify_url": "https://shop.alpacanica.com/verify-mandate",
"max_amount_minor": 2500,
"currency": "EUR",
"category": "apparel",
"redirect_uri": "https://my-agent.example.com/callback"
}
}
}agent_dsar_initiate
Initiates a GDPR Article 15 (access) or Article 20 (portability) Data Subject Access Request on behalf of the principal. The agent presents the principal's wallet-session JWT-AT as proof of consent. Article 15 dumps deliver asynchronously to the principal's verified email; Article 20 dumps stream synchronously as JSON in the response. Agent-only JWT-ATs are refused (DSAR requires a wallet-session principal). Rate-limit is 10 per day per principal per endpoint.
Input schema
{
"type": "object",
"properties": {
"right": { "enum": ["access", "portability"] },
"principal_token": { "type": "string", "minLength": 1 },
"delivery_preference": {
"enum": ["email", "synchronous_json"],
"default": "email"
}
},
"required": ["right", "principal_token"]
}Output (Article 15 access, asynchronous email)
{
"request_id": "dsar_4f6a2c1d8b9e3f70",
"status": "queued",
"right": "access",
"estimated_delivery": "2026-05-15T05:51:28Z",
"delivery_channel": "email"
}Output (Article 20 portability, synchronous JSON)
{
"schema_version": "oid4pay.dsar.portability.v1",
"principal": {
"principal_id": "principal_8a2c1d8b9e3f70a4",
"wallet_operator": "https://wallet.oid4pay.com"
},
"agents": [...],
"mandates": [...],
"payments": [...],
"audit_chain": [...]
}Errors
| Operator-facing message | Cause |
|---|---|
agent_dsar_initiate failed: DSAR requires a wallet-session JWT-AT; agent-only tokens are refused | AS returned 401 invalid_token; the principal_token was an agent-only AT. |
agent_dsar_initiate failed: rate limited; DSAR caps at 10/day per principal per endpoint | AS returned 429 rate_limited. |
agent_dsar_initiate failed: insufficient_scope (need privacy:dsar); ... | Wallet-session JWT-AT does not carry the privacy:dsar scope. |
agent_dsar_initiate failed: agent is not registered yet; call agent_register first | No persisted client.json. |
Auth requirement
Wallet-session JWT-AT (the principal's), passed in the principal_token input. Agent-only tokens are refused.
Cross-references
- Protocol: audit-chain entries are emitted per the audit chain entry envelope.
Code samples
Python (oid4pay-oid4ac)
from oid4pay_oid4ac import WalletClient
wallet = WalletClient(as_url="https://sandbox.oid4pay.com")
reply = wallet.dsar_initiate(
right="access",
principal_token=principal_jwt_at,
delivery_preference="email",
)
print(reply.request_id, reply.estimated_delivery)Node (@oid4pay/oid4ac-merchant)
import { WalletClient } from "@oid4pay/oid4ac-merchant/wallet";
const wallet = new WalletClient({ asUrl: "https://sandbox.oid4pay.com" });
const reply = await wallet.dsarInitiate(
"access",
principalJwtAt,
"email",
);
console.log(reply.request_id, reply.estimated_delivery);Raw JSON-RPC (MCP transport)
{
"jsonrpc": "2.0",
"id": 10,
"method": "tools/call",
"params": {
"name": "agent_dsar_initiate",
"arguments": {
"right": "access",
"principal_token": "eyJhbGciOiJFZERTQSIs...",
"delivery_preference": "email"
}
}
}agent_audit_chain_query
Forensic query against the audit chain for events involving the authenticated principal. The agent presents the principal's wallet-session JWT-AT as proof of consent. Returns the audit entries within the requested time window, scoped to the principal (the AS-side IDOR filter prevents an agent from reading another principal's chain). Rate-limit is 30/min per principal. Hard cap of 200 results per call.
Input schema
{
"type": "object",
"properties": {
"principal_token": { "type": "string", "minLength": 1 },
"from_iso": { "type": "string", "format": "date-time" },
"to_iso": { "type": "string", "format": "date-time" },
"event_types": {
"type": "array",
"items": {
"enum": [
"oid4ac.mandate.issued",
"oid4ac.payment.succeeded",
"oid4ac.payment.failed",
"oid4ac.payment.disputed",
"oid4ac.token.revoked",
"oid4ac.privacy.access",
"oid4ac.privacy.erasure"
]
}
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 50
}
},
"required": ["principal_token"]
}Output
{
"entries": [
{
"seq": 4231,
"ts": "2026-05-15T05:21:28Z",
"event": "oid4ac.payment.succeeded",
"tenant_id": "tenant_oid4pay_prod",
"actor": {
"principal_id": "principal_8a2c1d8b9e3f70a4",
"agent_client_id": "agent_3ff8a1d2b9c4e7f0"
},
"payload": {
"mandate_id": "mandate_7b2c4d6e8f0a1b3c",
"payment_intent_id": "pi_3OQ8Z2L8KZ4f5G2c1AbCdE",
"amount_minor": 1299,
"currency": "EUR",
"merchant_audience": "https://shop.alpacanica.com"
}
}
],
"has_more": false
}Errors
| Operator-facing message | Cause |
|---|---|
agent_audit_chain_query failed: audit chain query requires a wallet-session JWT-AT | AS returned 401 invalid_token (agent-only token). |
agent_audit_chain_query failed: principal_id mismatch | AS returned 403 principal_mismatch (IDOR defence). |
agent_audit_chain_query failed: rate limited; audit chain caps at 30/min per principal | AS returned 429 rate_limited. |
agent_audit_chain_query failed: insufficient_scope (need audit:read); ... | Wallet-session JWT-AT does not carry the audit:read scope. |
agent_audit_chain_query failed: agent is not registered yet; call agent_register first | No persisted client.json. |
Auth requirement
Wallet-session JWT-AT, scope audit:read. Agent-only tokens
are refused.
Cross-references
- Protocol: audit chain entry envelope (event emission).
Code samples
Python (oid4pay-oid4ac)
from oid4pay_oid4ac import WalletClient
wallet = WalletClient(as_url="https://sandbox.oid4pay.com")
reply = wallet.audit_chain_query(
bearer=principal_jwt_at,
from_iso="2026-05-01T00:00:00Z",
to_iso="2026-05-15T23:59:59Z",
event_types=["oid4ac.payment.succeeded", "oid4ac.payment.disputed"],
limit=100,
)
for e in reply.entries:
print(e.seq, e.event, e.payload)Node (@oid4pay/oid4ac-merchant)
import { WalletClient } from "@oid4pay/oid4ac-merchant/wallet";
const wallet = new WalletClient({ asUrl: "https://sandbox.oid4pay.com" });
const reply = await wallet.auditChainQuery(
principalJwtAt,
{ fromIso: "2026-05-01T00:00:00Z", toIso: "2026-05-15T23:59:59Z" },
["oid4ac.payment.succeeded", "oid4ac.payment.disputed"],
100,
);
for (const e of reply.entries) console.log(e.seq, e.event, e.payload);Raw JSON-RPC (MCP transport)
{
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {
"name": "agent_audit_chain_query",
"arguments": {
"principal_token": "eyJhbGciOiJFZERTQSIs...",
"from_iso": "2026-05-01T00:00:00Z",
"to_iso": "2026-05-15T23:59:59Z",
"event_types": ["oid4ac.payment.succeeded", "oid4ac.payment.disputed"],
"limit": 100
}
}
}agent_payment_history
UI-friendly projection over the audit chain. Filters the chain to oid4ac.payment.succeeded (and optionally oid4ac.payment.disputed), then projects each entry into a
payment-centric row shape suitable for direct rendering in a chat-style
agent UI. Returns at most 100 payments per call; paginate older payments
via from_iso. Inherits the same wallet-session JWT-AT
requirement and IDOR discipline as agent_audit_chain_query.
Input schema
{
"type": "object",
"properties": {
"principal_token": { "type": "string", "minLength": 1 },
"from_iso": { "type": "string", "format": "date-time" },
"to_iso": { "type": "string", "format": "date-time" },
"include_disputed": { "type": "boolean", "default": true },
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 50
}
},
"required": ["principal_token"]
}Output
{
"payments": [
{
"payment_intent_id": "pi_3OQ8Z2L8KZ4f5G2c1AbCdE",
"merchant_audience": "https://shop.alpacanica.com",
"amount_minor": 1299,
"currency": "EUR",
"processed_at_iso": "2026-05-15T05:21:28Z",
"status": "succeeded",
"mandate_id": "mandate_7b2c4d6e8f0a1b3c",
"agent_client_id": "agent_3ff8a1d2b9c4e7f0",
"receipt_url": "https://sandbox.oid4pay.com/receipts/r_4f6a2c1d8b9e.json"
},
{
"payment_intent_id": "pi_3OQ8Z2L8KZ4f5G2c2XyZ12",
"merchant_audience": "https://shop.bristleandslate.com",
"amount_minor": 4500,
"currency": "EUR",
"processed_at_iso": "2026-05-12T11:08:01Z",
"status": "disputed_pending",
"mandate_id": "mandate_9c3d5e7f1a2b4c6d",
"agent_client_id": "agent_3ff8a1d2b9c4e7f0"
}
],
"total_count": 2,
"has_more": false
}Status values
| Status | Source event |
|---|---|
succeeded | oid4ac.payment.succeeded |
disputed_pending | oid4ac.payment.disputed with dispute_status=pending |
disputed_won | oid4ac.payment.disputed with dispute_status=won |
disputed_lost | oid4ac.payment.disputed with dispute_status=lost |
Errors
| Operator-facing message | Cause |
|---|---|
agent_payment_history failed: payment history query requires a wallet-session JWT-AT | AS returned 401 invalid_token (agent-only token). |
agent_payment_history failed: principal_id mismatch | AS returned 403 principal_mismatch (IDOR defence). |
agent_payment_history failed: rate limited; payment history caps at 30/min per principal | AS returned 429 rate_limited. |
agent_payment_history failed: insufficient_scope (need audit:read); ... | Wallet-session JWT-AT does not carry the audit:read scope. |
agent_payment_history failed: agent is not registered yet; call agent_register first | No persisted client.json. |
Auth requirement
Wallet-session JWT-AT, scope audit:read. Agent-only tokens
are refused.
Cross-references
- Protocol: audit chain entry envelope.
Code samples
Python (oid4pay-oid4ac)
from oid4pay_oid4ac import WalletClient
wallet = WalletClient(as_url="https://sandbox.oid4pay.com")
reply = wallet.payment_history(
bearer=principal_jwt_at,
from_iso="2026-05-01T00:00:00Z",
to_iso="2026-05-15T23:59:59Z",
include_disputed=True,
limit=50,
)
for p in reply.payments:
print(p.processed_at_iso, p.amount_minor, p.currency, p.status)Node (@oid4pay/oid4ac-merchant)
import { WalletClient } from "@oid4pay/oid4ac-merchant/wallet";
const wallet = new WalletClient({ asUrl: "https://sandbox.oid4pay.com" });
const reply = await wallet.paymentHistory(
principalJwtAt,
{ fromIso: "2026-05-01T00:00:00Z", toIso: "2026-05-15T23:59:59Z" },
true,
50,
);
for (const p of reply.payments) {
console.log(p.processed_at_iso, p.amount_minor, p.currency, p.status);
}Raw JSON-RPC (MCP transport)
{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "agent_payment_history",
"arguments": {
"principal_token": "eyJhbGciOiJFZERTQSIs...",
"from_iso": "2026-05-01T00:00:00Z",
"to_iso": "2026-05-15T23:59:59Z",
"include_disputed": true,
"limit": 50
}
}
}Permissions and consent
The MCP server prompts for principal consent through the wallet on every
new merchant audience. Mandates are scoped per merchant; the agent cannot
silently extend a mandate from one merchant to another. The wallet:read + wallet:write scopes gate the
wallet-management tools; audit:read gates the audit-chain and
payment-history tools; privacy:dsar gates the DSAR tool.
Scopes that the principal has not granted come back as insufficient_scope at the AS.
Algorithm whitelist
The MCP server enforces the algorithm whitelist internally
and refuses to call any endpoint that returns a JWT or RFC 9421 signature
using a rejected algorithm. EdDSA only for JWT-AT / SD-JWT VC / KB-JWT
signatures; ed25519 or ecdsa-p256-sha256 for RFC
9421 HTTP message signatures. alg=none, HMAC for DPoP, and
RS256 outside the legacy window are refused.
Source
The server lives at oid4pay-mcp/ in the OID4Pay repo. Run oid4pay-mcp --help for the full CLI reference, or npm test in the package directory to run the conformance
harness against the sandbox AS. The tool-by-tool test inventory lives at oid4pay-mcp/docs/test-inventory.md.