How to Connect AI Agents to Xero and QuickBooks: MCP Server Architecture Guide
Learn how to connect AI agents to Xero, QuickBooks, and Brex using an MCP server architecture. Master OAuth concurrency, rate limits, and schema normalization.
If you need an AI agent to read invoices from Xero, create bills in QuickBooks Online, pull expense data from Brex, or reconcile transactions across all three—without building and maintaining separate API connectors for every AI model your customers use—this is the architecture guide you need.
Connecting AI agents to enterprise accounting systems is a nightmare of state management, strict schemas, and aggressive rate limits. If you are a product manager or engineering leader tasked with giving an LLM read and write access to financial data, you already know that brute-forcing point-to-point accounting integrations is an unscalable approach.
Search intent dictates we answer the core architectural question immediately: To connect AI agents to Xero and QuickBooks securely and at scale, you must deploy an MCP (Model Context Protocol) server backed by a unified accounting schema. This architecture translates the agent's natural language intent into standardized JSON-RPC tool calls, normalizes the disparate data models of different ERPs, and passes strict API rate limits back to the agent so it can manage its own execution backoff.
The demand for this architecture is massive. A January 2026 Deloitte study found that 63% of finance organizations have fully deployed AI in their operations, and nearly 50% of CFOs report having integrated AI-driven agents into parts of the finance function like forecasting or expense management. According to PwC, over 79% of finance leaders believe AI agents are already changing how financial data is analyzed. The accuracy gains are tangible: in recent testing, AI bookkeeping agents demonstrated 97.8% accuracy in automated transaction categorization, compared to 79.1% for human outsourced accountants across real workflows. Goldman Sachs is already deploying agentic workflows for transaction reconciliation and trade accounting.
AI agents can automate up to 95% of the entire bookkeeping workflow—but only if your underlying infrastructure can reliably handle the API layer. This guide breaks down the exact architecture required to build a production-ready MCP server for QuickBooks Online and Xero, covering OAuth concurrency, rate limit normalization, schema mapping, and pagination.
The N×M Problem of Connecting AI Agents to Accounting APIs
Historically, exposing a SaaS product to an AI model meant building custom connectors for OpenAI, Anthropic, Google, and whatever LangChain or LlamaIndex wrapper your enterprise customers decided to use. If you support five AI models and ten accounting systems, your engineering team is responsible for maintaining fifty separate integration paths.
Accounting APIs compound this N×M problem. Double-entry ledgers, strict tax rate dependencies, and multi-currency edge cases mean that a failed write operation doesn't just throw an error—it corrupts a financial record. Each API has its own OAuth flow, its own pagination style, its own field names for the same concept (Xero calls them "Contacts," QuickBooks calls them "Customers" and "Vendors" separately), and its own rate limit scheme.
The Model Context Protocol (MCP) acts as the universal adapter that collapses this complexity. Anthropic launched MCP in November 2024, and the adoption curve has been staggering. By March 2026, the protocol hit 97 million monthly SDK downloads across Python and TypeScript, with first-class client support in Claude, ChatGPT, Gemini, Cursor, and VS Code. In December 2025, Anthropic donated MCP to the Agentic AI Foundation under the Linux Foundation, with OpenAI, Google, Microsoft, and AWS as backing members.
By deploying an MCP server, you build the accounting integration layer exactly once. Any MCP-compatible client—whether it is Claude Desktop, a custom LangGraph agent, or an internal support bot—can immediately discover and execute tools against Xero and QuickBooks without a single line of model-specific glue code.
How an MCP Server Architecture Works for Xero and QuickBooks
An MCP server for accounting is a JSON-RPC 2.0 endpoint that dynamically exposes an integrated account's available API operations as callable tools (e.g., create_a_quickbooks_invoice or list_all_xero_contacts), complete with JSON Schema validation for the agent's inputs.
In a highly scalable architecture, MCP tools are not hardcoded. Instead, they are dynamically generated from the integration's configuration and documentation records. When an AI agent connects to the MCP server endpoint, the server reads the target accounting platform's OpenAPI specification, filters the available endpoints based on the customer's OAuth scopes, and returns a structured tool list.
A tool only appears if it has a corresponding description and JSON Schema—this acts as both a quality gate and a curation mechanism, ensuring LLMs only see well-documented operations. Each tool includes a descriptive name, a JSON Schema for input parameters (query filters, cursors, request body fields), and auto-injected instructions.
Here is what the architecture looks like in practice:
graph TD
A[AI Agent / LLM Client] -->|JSON-RPC 2.0 over HTTP| B[MCP Server Router]
B -->|tools/list| C[Dynamic Tool Generator]
C -->|Reads Schemas| D[(Integration Config DB)]
B -->|tools/call| E[Unified API Proxy Layer]
E -->|JSONata Mapping| F[Unified Accounting Schema]
F -->|Transformed Request| G[Third-Party ERP]
G -->|Native API| H[QuickBooks Online]
G --> I[Xero]
G --> J[NetSuite]The execution flow between the agent and the underlying API follows a strict sequence:
sequenceDiagram
participant Agent as AI Agent<br>(Claude, ChatGPT)
participant MCP as MCP Server
participant Auth as OAuth Token<br>Manager
participant API as Xero / QBO API
Agent->>MCP: tools/list (JSON-RPC)
MCP-->>Agent: Available tools + JSON schemas
Agent->>MCP: tools/call: list_invoices
MCP->>Auth: Refresh token if expiring
Auth-->>MCP: Fresh access token
MCP->>API: GET /Invoices (Xero) or<br>GET /query?query=SELECT * FROM Invoice (QBO)
API-->>MCP: Provider-specific response
MCP-->>Agent: Normalized result + rate limit headersThe Flat Input Namespace Problem
When an AI agent invokes an MCP tool via the tools/call method, it passes all arguments as a single, flat JSON object. However, accounting APIs require strict separation between URL path parameters, query strings, and JSON request bodies.
Your MCP server router must intelligently split this flat input namespace. It does this by comparing the agent's arguments against the query_schema and body_schema derived from the integration's documentation. If the agent calls get_single_quickbooks_invoice_by_id with {"id": "123", "minorversion": "65"}, the router identifies id as a path parameter and minorversion as a query parameter, constructing the correct upstream HTTP request without the agent needing to understand REST semantics.
For write operations, the architecture is the same. An agent calling create_a_quickbooks_invoice passes a body conforming to the tool's JSON Schema. The MCP server maps that to QuickBooks' native format, applies the stored OAuth credentials, and executes the request. For a deeper look at write-side safety patterns, see our guide on whether AI agents can safely write data back to accounting systems.
Handling the Brutal Reality of Accounting API Rate Limits
This is where most AI-agent-to-accounting integrations break in production. AI agents are chatty by nature—a single user query like "summarize my outstanding receivables" can trigger dozens of API calls across invoices, contacts, and payments. Accounting APIs were not designed for this access pattern.
Here is what you are actually up against when connecting to these systems:
| Constraint | Xero | QuickBooks Online | Brex |
|---|---|---|---|
| Per-minute limit | 60 calls/min per tenant | 500 calls/min per company | 1,000 calls/min per account |
| Daily limit | 5,000 calls/day per tenant | No daily cap | 1,000 transfers/day |
| Concurrent requests | 5 simultaneous | 10 simultaneous | Not documented |
| App-wide limit | 10,000 calls/min across all tenants | N/A | N/A |
| Rate limit response | HTTP 429 + Retry-After header |
HTTP 429 | HTTP 429 |
Xero's limits are particularly punishing for agent workflows. At 60 calls per minute and 5,000 per day, syncing a single invoice with line items, contacts, and payments can consume 6 API calls. That means you can sync roughly 833 invoices per day before you are locked out. An AI agent that is naively fetching context for a natural-language financial query will blow through that minute limit in seconds.
QuickBooks is more generous on throughput at 500 requests per minute, but the 10-concurrent-request ceiling means a multi-tenant application serving several companies simultaneously needs careful request scheduling.
Brex sits at the more permissive end of the spectrum at 1,000 requests per minute per account. That headroom is deceptive, though. An AI agent performing bulk expense categorization - iterating through hundreds of expenses, fetching receipt data, and writing back categories - can burn through that budget fast, especially when multiple agents or background syncs share the same client credentials. Brex also enforces separate daily caps on financial operations: 1,000 transfers and 100 international wires per 24 hours.
And here is the part that matters for your architecture: the rate limit response formats are completely different between providers. Xero returns X-MinLimit-Remaining and X-DayLimit-Remaining custom headers. QuickBooks returns a bare 429 with no standardized remaining-count headers. Brex also returns a bare 429 without remaining-count headers. Every other accounting API (NetSuite, Zoho Books, FreshBooks) has its own convention.
The Anti-Pattern: Absorbing Rate Limits in the Infrastructure
Many engineering teams make the mistake of implementing automatic retries with exponential backoff directly in their API proxy layer. When the proxy hits a 429, it holds the HTTP connection open, waits 30 seconds, and tries again.
Do not do this with AI agents.
LLMs operate on strict timeout windows. If your infrastructure holds a connection open for 45 seconds while silently retrying a Xero rate limit, the agent will timeout, assume the tool call failed, and likely hallucinate a response to the user.
The Correct Pattern: Normalization and Agent-Side Backoff
A scalable architecture takes a different, highly opinionated approach: The infrastructure does NOT retry, throttle, or apply backoff on rate limit errors.
When an upstream API returns an HTTP 429, the proxy layer passes that error directly back to the caller. There is no automatic retry, no backoff, no absorption of the error. The agent or calling application receives the 429 and is responsible for deciding what to do.
What the proxy layer does provide is standardized rate limit information on every response—successful or not. Regardless of whether the upstream is Xero or QuickBooks, the platform normalizes the provider-specific headers into three consistent response headers based on the IETF RateLimit header fields specification:
ratelimit-limit: The maximum number of requests permitted in the current window.ratelimit-remaining: The number of requests remaining in the current window.ratelimit-reset: The number of seconds until the rate limit window resets.
By passing these normalized headers back through the MCP isError: true response, the AI agent's reasoning loop can natively pause its own execution. Your agent's retry logic can be entirely provider-agnostic:
import time
def call_with_rate_awareness(client, endpoint, params):
response = client.request(endpoint, params)
remaining = int(response.headers.get("ratelimit-remaining", 100))
reset_seconds = int(response.headers.get("ratelimit-reset", 60))
if response.status_code == 429:
wait_time = reset_seconds + 1 # Add jitter in production
time.sleep(wait_time)
return call_with_rate_awareness(client, endpoint, params)
if remaining < 5:
# Proactively slow down before hitting the wall
time.sleep(reset_seconds / max(remaining, 1))
return responseThe same code works whether the underlying provider is Xero (with its 60/min limit) or QuickBooks (with its 500/min limit). The agent reads the same headers either way. This is the kind of plumbing that saves you from writing per-provider rate limit handling code that inevitably falls behind when providers change their policies.
Deterministic Backoff with Capped Retries
The simple Python example above is fine for interactive agents that can afford naive sleep() calls. For production systems - background sync jobs, batch write pipelines, or long-running agent loops - you want something more disciplined: capped retry counts, exponential backoff with jitter, and a hard preference for server-provided reset hints over local guesses.
Here is the pattern in TypeScript:
type RetryPolicy = {
maxRetries: number
baseDelayMs: number
maxDelayMs: number
jitterMs: number
}
const DEFAULT_POLICY: RetryPolicy = {
maxRetries: 3,
baseDelayMs: 500,
maxDelayMs: 30_000,
jitterMs: 250,
}
async function callWithBackoff(
fn: () => Promise<Response>,
policy: RetryPolicy = DEFAULT_POLICY
): Promise<Response> {
let attempt = 0
while (true) {
const response = await fn()
const isRetryable =
response.status === 429 || (response.status >= 500 && response.status < 600)
if (!isRetryable) return response
if (attempt >= policy.maxRetries) return response // Let the caller (agent) decide
// Prefer server hints; fall back to exponential backoff
const resetHeader = response.headers.get('ratelimit-reset')
const retryAfter = response.headers.get('retry-after')
const serverDelayMs =
(resetHeader && Number(resetHeader) * 1000) ||
(retryAfter && Number(retryAfter) * 1000) ||
null
const expDelay = Math.min(
policy.baseDelayMs * 2 ** attempt,
policy.maxDelayMs
)
const jitter = Math.floor(Math.random() * policy.jitterMs)
const delay = (serverDelayMs ?? expDelay) + jitter
await new Promise(res => setTimeout(res, delay))
attempt++
}
}Three rules encoded here are worth internalizing:
- Trust the server. If
ratelimit-resetorRetry-Afteris present, honor it. Xero and Brex frequently return values that reflect true window boundaries, and guessing shorter delays only gets you throttled again. - Cap retries. Three attempts is usually enough for transient errors. Beyond that, bubble the failure up so the agent can decide whether to abandon or reschedule the operation. Never loop indefinitely inside your proxy - LLMs will time out first.
- Add jitter. When many agent sessions hit the same provider simultaneously (say, midnight UTC sync jobs), synchronized retries produce thundering herds. A few hundred milliseconds of random jitter smooths the load curve.
When this backoff is applied inside a client SDK or agent framework (not at the proxy), the MCP server still returns the normalized headers and the raw 429 - the agent's own logic handles the delay. This preserves the contract that the proxy never absorbs rate limits.
Solving OAuth Token Management and Concurrency
Giving an AI agent access to an accounting platform requires maintaining a persistent connection via OAuth 2.0. Both Xero and QuickBooks issue short-lived access tokens alongside a refresh token. Xero tokens expire after 30 minutes. QuickBooks tokens expire after 60 minutes.
Managing this lifecycle in a traditional web app is straightforward: when a user clicks a button, you check the token expiry and refresh if necessary. But AI agents operate asynchronously and concurrently. A single agent might spin up five parallel threads to query invoices, contacts, accounts, tax rates, and payments simultaneously.
If your infrastructure attempts to refresh the OAuth token for all five requests at the exact same millisecond, you will trigger a race condition. Two concurrent agent tool calls both check the token, both see it is expired, and both attempt a refresh. One succeeds. The other uses a refresh token that has now been invalidated by the first call (most OAuth providers invalidate old refresh tokens on rotation). Result: the second call fails, the integrated account enters a broken state, and your customer has to re-authenticate manually.
The Durable Lock Pattern
To solve this, your infrastructure must implement a distributed mutex or durable lock for token refreshes per integrated account. A robust OAuth token refresh architecture operates on two parallel tracks:
- Proactive Refresh: A background scheduler monitors all integrated accounts. Approximately 60 to 180 seconds before an access token expires, the scheduler acquires a lock and exchanges the refresh token. This ensures the token is almost always fresh when an API call arrives, imposing no latency penalty on the agent.
- On-Demand Refresh with Locking: If an agent fires a request and the token is within 30 seconds of expiration (e.g., after a long idle period), the API proxy intercepts the request. It attempts to acquire the per-account lock.
If Thread A acquires the lock, it performs the HTTP request to QuickBooks to get the new token. Threads B, C, D, and E will see that the lock is held and simply await the completion of Thread A's operation. Once Thread A writes the new token to the relational database, the lock is released, and all five threads proceed using the fresh token. No duplicate refresh attempts occur.
Here is the pattern reduced to code. Two layers cooperate: a per-account durable lock (survives process restarts) and an in-process promise cache (deduplicates concurrent callers within a single node):
type OAuthToken = {
access_token: string
refresh_token: string
expires_at: string // ISO datetime
isExpiredWithin(seconds: number): boolean
}
// Layer 1: per-account durable lock. Only one refresh runs at a time per account.
async function refreshWithLock(
accountId: string,
currentToken: OAuthToken
): Promise<OAuthToken> {
if (!currentToken.isExpiredWithin(30)) return currentToken
const lock = await acquireLock(`oauth-refresh:${accountId}`, {
timeoutMs: 30_000,
})
try {
// Re-read after acquiring the lock: a peer may have refreshed while we waited
const account = await db.getIntegratedAccount(accountId)
if (!account.token.isExpiredWithin(30)) return account.token
const newToken = await oauth.refresh({
refreshToken: account.token.refresh_token,
clientId: account.credentials.client_id,
clientSecret: account.credentials.client_secret,
})
// Store new access AND rotated refresh token in a single transaction
await db.updateToken(accountId, newToken)
return newToken
} catch (err) {
if (isInvalidGrant(err)) {
await db.markForReauth(accountId, err)
await webhooks.emit('integrated_account:authentication_error', accountId)
}
throw err
} finally {
await lock.release()
}
}
// Layer 2: in-process deduplication. Concurrent callers await the same promise.
const inFlight = new Map<string, Promise<OAuthToken>>()
export async function getFreshToken(accountId: string): Promise<OAuthToken> {
const existing = inFlight.get(accountId)
if (existing) return existing
const account = await db.getIntegratedAccount(accountId)
const promise = refreshWithLock(accountId, account.token).finally(() =>
inFlight.delete(accountId)
)
inFlight.set(accountId, promise)
return promise
}The design choices worth calling out:
- Double-check inside the lock. The
isExpiredWithin(30)check happens twice: once before acquiring the lock (fast path) and once after (the race-safe path). This avoids paying the refresh cost for callers that arrived a millisecond after a peer already refreshed. - Lock timeout matters. The 30-second timeout is a safety net. If a refresh hangs (network stall, upstream 504), the lock auto-releases so the next agent request can retry rather than piling up behind a dead operation.
- In-process cache is per-node. The
inFlightmap deduplicates within a single server. The distributed lock deduplicates across servers. You need both - one without the other leaves races. - Atomic token storage. The new access token and rotated refresh token must be written together. If your database write splits them (or your ORM lazily flushes), a crash between writes will leave the account in a broken state.
If a refresh fails entirely—because the user revoked access or the provider is down—the integrated account is marked as requiring re-authentication, and a webhook fires so your application can notify the customer.
Unified Accounting Models: One Schema for Xero and QuickBooks
The final piece of the architecture is schema normalization. If you simply expose raw Xero and QuickBooks endpoints via an MCP server, your AI agent still has to understand the proprietary data models of each platform.
Xero and QuickBooks represent the same financial concepts with different field names, different nesting structures, and different enumeration values. An invoice in Xero has Contact.ContactID; in QuickBooks it is CustomerRef.value. Xero uses "AUTHORISED" as an invoice status; QuickBooks uses a numeric Balance field where zero implies paid. Xero calls them Contacts while QuickBooks calls them Customers and Vendors.
If your AI agent is generating tool calls from natural language, it cannot learn two sets of field names for the same operation. Your prompt engineering becomes polluted with platform-specific instructions. The solution is a Unified Accounting API layer sitting between the MCP server and the third-party endpoints. This layer abstracts away provider-specific nuances into a single, standardized data model.
Declarative JSONata Mapping
Instead of writing integration-specific code (e.g., if (provider === 'xero') { ... }), scalable architectures use declarative JSONata expressions to map payloads. JSONata is a Turing-complete query and transformation language for JSON.
When an agent requests a list of unified Contacts, the mapping engine executes a JSONata expression against the raw upstream response.
For QuickBooks Online, the mapping expression might look like this:
response.{
"id": $string(Id),
"name": DisplayName,
"email": PrimaryEmailAddr.Address,
"phone": PrimaryPhone.FreeFormNumber,
"status": Active ? "active" : "inactive",
"created_at": MetaData.CreateTime
}For Xero, the expression for the exact same unified output looks entirely different:
response.{
"id": ContactID,
"name": Name,
"email": EmailAddress,
"phone": Phones[PhoneType='DEFAULT'].PhoneNumber,
"status": ContactStatus = "ACTIVE" ? "active" : "inactive",
"created_at": UpdatedDateUTC
}The AI agent only ever sees the unified output: id, name, email, phone, status, created_at. It can execute a write operation back to the accounting system using the exact same unified schema, and the infrastructure will run a reverse JSONata mapping to translate the request body into the format expected by Xero or QuickBooks.
Because the unified schema is consistent across providers, the MCP tool definitions are identical regardless of whether the connected account is Xero or QuickBooks. The LLM sees list_all_contacts with one JSON Schema. Fewer tools means smaller context windows, which means better model performance and lower token costs. For a deeper comparison of unified accounting APIs, see our 2026 guide to the best unified accounting API.
Critically, the original provider-specific response is always preserved as remote_data on the unified response. If an agent needs a Xero-specific field that isn't in the unified schema—like tracking categories or branding themes—it is right there. The unified model doesn't strip data; it normalizes access to the most common fields while keeping everything else available.
Pagination and Cursor Instructions
Accounting APIs return massive datasets, making pagination critical. Xero uses page numbers, while QuickBooks uses SQL-like offset queries (STARTPOSITION).
The unified API normalizes these into standard next_cursor strings. However, LLMs are notorious for trying to parse, decode, or alter pagination cursors. To prevent this, the MCP server dynamically injects strict prompt instructions into the tool's query_schema description:
LLM Prompt Engineering Tip: In your dynamically generated tool schema, explicitly define the cursor behavior: "The cursor to fetch the next set of records. Always send back exactly the cursor value you received (nextCursor) without decoding, modifying, or parsing it."
Xero policy change (March 2, 2026): Xero's updated Developer Terms now explicitly prohibit using API data to train AI/ML models. If your agent architecture involves fine-tuning on customer financial data pulled from Xero, this is a compliance blocker. Read-time inference (where an agent queries data, reasons over it, and takes action) is a different pattern and is not affected—but you should always check with your legal team.
Writing Data Back to QuickBooks and Xero: Typed Tool Schemas for Agent Binding
Read-only agents are useful; write-capable agents are transformative. Once an AI agent can create invoices, log payments, and update customer records in QuickBooks or Xero, entire finance workflows become autonomous - order-to-cash, expense reconciliation, and bill pay run without human intervention. This is the automation angle that most "ai quickbooks xero api write data" queries are actually asking about.
But write operations are also where accounting integrations most often break. A hallucinated field, an unvalidated tax code, or a retried request without an idempotency key can produce duplicate invoices, corrupted ledgers, or worse - real financial impact on a customer's books. The safe pattern is to constrain agent writes with typed tool schemas that mirror the unified accounting model, then let the proxy translate each write into the correct provider payload.
End-to-End Flow: LLM → Tool Schema → Unified /tools → Provider API
Here is what a single write operation looks like from the LLM's tool call down to the QuickBooks or Xero API response:
sequenceDiagram
participant LLM as LLM
participant Schema as Tool Schema<br>(JSON Schema)
participant MCP as MCP /tools endpoint
participant Unified as Unified API Proxy<br>(JSONata mapping)
participant Provider as QuickBooks / Xero
LLM->>Schema: Read create_an_invoice signature
LLM->>MCP: tools/call create_an_invoice<br>{contact_id, line_items, ...}
MCP->>MCP: Validate arguments<br>against inputSchema
MCP->>Unified: POST /unified/accounting/invoices<br>(unified body)
Unified->>Unified: Refresh token if needed<br>(per-account lock)
Unified->>Unified: Apply reverse JSONata mapping<br>to provider shape
Unified->>Provider: POST /invoices (native format)<br>+ Idempotency-Key header
Provider-->>Unified: Provider response
Unified-->>MCP: Normalized invoice + remote_data<br>+ ratelimit headers
MCP-->>LLM: {result, next_cursor, request_id}Six boundaries are crossed in a single write, and each one enforces a specific guarantee: schema validation at the MCP layer, token freshness at the proxy, provider translation via declarative mapping, idempotency at the HTTP boundary, response normalization on the way back, and rate limit metadata surfaced to the agent for its own backoff decisions.
Unified Write Tool: Create an Invoice
Because the unified accounting schema is provider-agnostic, the same tool definition works for both QuickBooks and Xero. An agent binds to one signature and gets automation across every connected accounting platform:
{
"name": "create_an_invoice",
"description": "Create an invoice on the connected accounting platform (Xero, QuickBooks, or any other supported ERP). Returns the created invoice with its remote ID and normalized fields. Idempotent when reusing the same tool call ID within the retry window.",
"inputSchema": {
"type": "object",
"properties": {
"contact_id": {
"type": "string",
"description": "The unified Contact ID for the customer being billed. Use list_all_contacts to discover valid IDs. Required."
},
"issue_date": {
"type": "string",
"description": "ISO 8601 date (YYYY-MM-DD) when the invoice is issued. Required."
},
"due_date": {
"type": "string",
"description": "ISO 8601 date (YYYY-MM-DD) when payment is due."
},
"currency": {
"type": "string",
"description": "ISO 4217 currency code (e.g., USD, GBP, AUD). Must match a currency configured in the connected account."
},
"line_items": {
"type": "array",
"description": "Line items on the invoice. Each item is billed separately.",
"items": {
"type": "object",
"properties": {
"description": { "type": "string" },
"quantity": { "type": "number" },
"unit_price": { "type": "number" },
"account_id": {
"type": "string",
"description": "Unified Account ID for the revenue account. Use list_all_accounts to discover valid IDs."
},
"tax_rate_id": {
"type": "string",
"description": "Unified TaxRate ID. Required for VAT/GST jurisdictions."
}
},
"required": ["quantity", "unit_price", "account_id"]
}
},
"memo": {
"type": "string",
"description": "Optional note visible to the customer."
}
},
"required": ["contact_id", "issue_date", "line_items"]
}
}Three design choices in this schema are worth copying:
- Required fields are minimal. Only what the underlying provider truly needs to accept the invoice. Everything else is optional, so the agent doesn't hallucinate values to satisfy the schema.
- IDs point to sibling tools. The description for
contact_idandaccount_idtells the LLM exactly which discovery tool to call first. This dramatically reduces "invented ID" hallucinations. - Enum-like fields are validated downstream. Rather than embedding a giant
enum: [...]in the schema (which providers change frequently), the description points to the discovery pattern. The proxy validates the ID against the connected account's actual data.
Provider-Specific Adapters (Under the Hood)
Behind the unified schema, the proxy applies a reverse JSONata mapping to translate the agent's payload into the exact shape each provider expects. For QuickBooks Online:
{
"CustomerRef": { "value": contact_id },
"TxnDate": issue_date,
"DueDate": due_date,
"CurrencyRef": { "value": currency },
"Line": line_items.{
"DetailType": "SalesItemLineDetail",
"Amount": quantity * unit_price,
"Description": description,
"SalesItemLineDetail": {
"ItemAccountRef": { "value": account_id },
"Qty": quantity,
"UnitPrice": unit_price,
"TaxCodeRef": { "value": tax_rate_id }
}
},
"PrivateNote": memo
}For Xero, the same unified input maps to a very different shape:
{
"Type": "ACCREC",
"Contact": { "ContactID": contact_id },
"Date": issue_date,
"DueDate": due_date,
"CurrencyCode": currency,
"LineItems": line_items.{
"Description": description,
"Quantity": quantity,
"UnitAmount": unit_price,
"AccountCode": account_id,
"TaxType": tax_rate_id
},
"Reference": memo
}The agent never sees these transformations. It writes to one schema; the proxy handles the rest, including provider-specific enum values (ACCREC for accounts-receivable invoices in Xero) and nested reference objects (CustomerRef.value in QuickBooks).
Provider-Native Tool Examples
Sometimes an agent needs to bypass the unified schema and hit a provider-native endpoint directly - for example, when the operation is provider-specific (Xero's PurchaseOrders, QuickBooks' TimeActivity) or when the customer wants to write to a field the unified model doesn't expose. The MCP server generates typed provider-specific tools for these cases:
{
"name": "create_a_quickbooks_bill",
"description": "Create a bill (vendor invoice) in QuickBooks Online for the connected company. Bills represent money you owe to vendors. Returns the created bill with its QuickBooks-assigned Id and SyncToken.",
"inputSchema": {
"type": "object",
"properties": {
"vendor_id": {
"type": "string",
"description": "The QuickBooks Vendor.Id being billed. Required."
},
"txn_date": {
"type": "string",
"description": "ISO 8601 date the bill was issued."
},
"due_date": {
"type": "string",
"description": "ISO 8601 date the bill is due."
},
"line_items": {
"type": "array",
"description": "Bill line items. Each item requires an amount and an expense account.",
"items": {
"type": "object",
"properties": {
"amount": { "type": "number" },
"description": { "type": "string" },
"account_id": {
"type": "string",
"description": "QuickBooks Account.Id (expense account). Required per line."
}
},
"required": ["amount", "account_id"]
}
}
},
"required": ["vendor_id", "line_items"]
}
}And the Xero equivalent for creating a bank transaction (spent money):
{
"name": "create_a_xero_bank_transaction",
"description": "Create a spend or receive bank transaction in the connected Xero organisation. Use this to log payments made or received directly against a bank account, outside the accounts-payable/receivable flow.",
"inputSchema": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "Transaction type. Use 'SPEND' for money out, 'RECEIVE' for money in.",
"enum": ["SPEND", "RECEIVE"]
},
"contact_id": {
"type": "string",
"description": "The Xero ContactID for the counterparty. Required."
},
"bank_account_id": {
"type": "string",
"description": "The Xero AccountID of the bank account. Required."
},
"date": {
"type": "string",
"description": "ISO 8601 transaction date."
},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": { "type": "string" },
"quantity": { "type": "number" },
"unit_amount": { "type": "number" },
"account_code": {
"type": "string",
"description": "Xero Account code for the expense/revenue account."
}
},
"required": ["unit_amount", "account_code"]
}
}
},
"required": ["type", "contact_id", "bank_account_id", "line_items"]
}
}Provider-native tools live alongside unified tools in the same MCP server. The agent picks whichever operation matches its intent - and because the naming convention is consistent (create_a_{provider}_{resource}), the LLM can reason about scope without documentation lookups.
Idempotency for Financial Writes
Every write tool call gets an auto-generated idempotency key derived from the tool invocation ID. If the LLM retries the same operation because of a timeout or a transient network error, the proxy attaches the same Idempotency-Key header, and QuickBooks (or Brex) will return the original result rather than creating a duplicate invoice.
For providers that don't natively support idempotency headers (Xero's Invoice endpoint does not), the proxy enforces idempotency client-side: it caches the (idempotency-key → provider response) pair for a short TTL and short-circuits duplicate requests before they reach the provider. This is critical for AI agents, which will retry tool calls on ambiguous errors more often than deterministic code.
The implementation is straightforward:
async function executeWrite(
toolCallId: string,
accountId: string,
provider: string,
request: WriteRequest
): Promise<WriteResponse> {
// Deterministic key derived from the LLM's tool call ID + operation
const idempotencyKey = `${toolCallId}:${request.operation}`
// Short-circuit if we've seen this key recently (24h TTL)
const cached = await idempotencyCache.get(idempotencyKey)
if (cached) return cached
const token = await getFreshToken(accountId)
const providerPayload = await applyReverseMapping(provider, request)
const response = await callProvider(provider, providerPayload, {
headers: {
Authorization: `Bearer ${token.access_token}`,
'Idempotency-Key': idempotencyKey,
},
})
await idempotencyCache.set(idempotencyKey, response, { ttlSeconds: 86_400 })
return response
}With this pattern in place, an agent can safely retry any write operation without risking duplicate invoices, duplicate bills, or duplicate payments.
Connecting AI Agents to Brex Expense Data via MCP
The architectural patterns above for Xero and QuickBooks apply directly to Brex, but with important differences. Brex is not a traditional double-entry accounting system - it is a unified spend management platform covering corporate cards, expense management, reimbursements, travel, and bill pay. The API surface reflects this: instead of invoices and chart-of-accounts entries, your AI agent works with expenses, card transactions, budgets, spend limits, and receipt uploads.
Why MCP Servers for Brex
Brex is where spend data lives for thousands of startups and enterprises. Finance teams want AI agents that can categorize expenses automatically, match receipts to card transactions, flag policy violations, and generate spend reports - all without manual dashboard work.
Without an MCP server, you are back to the N×M problem. Every AI model that needs Brex access requires its own integration code. Your agent framework needs custom Brex tool definitions. And when Brex updates its API, every integration path breaks.
An MCP server for Brex solves this: you define the tools once, and any MCP-compatible client discovers and executes them. The same list_all_brex_expenses tool works whether the caller is Claude Desktop, ChatGPT, or a custom ReAct agent. Because Brex publishes OpenAPI specs for all its APIs (Expenses, Transactions, Payments, Team, Budgets), the MCP server can dynamically generate tools from those specs using the same documentation-driven approach that works for Xero and QuickBooks.
End-to-End Flow: Agent to Brex via MCP Server
Here is the complete request lifecycle when an AI agent queries Brex expense data through an MCP server backed by a unified API proxy:
sequenceDiagram
participant Agent as AI Agent<br>(Claude, ChatGPT)
participant MCP as MCP Server
participant Lock as Token Refresh<br>Lock
participant Proxy as Unified API<br>Proxy Layer
participant Brex as Brex API<br>(api.brex.com)
Agent->>MCP: tools/list (JSON-RPC)
MCP-->>Agent: list_all_brex_expenses,<br>update_a_brex_card_expense_by_id,<br>brex_card_expenses_receipt_upload
Agent->>MCP: tools/call: list_all_brex_expenses<br>{"purchase_date_after": "2026-03-01"}
MCP->>Lock: Check token expiry, acquire lock
Lock->>Brex: POST /oauth2/token (refresh grant)
Brex-->>Lock: New access_token (1h TTL)<br>+ rotated refresh_token
Lock-->>MCP: Fresh access token
MCP->>Proxy: Forward request with Bearer token
Proxy->>Brex: GET /v1/expenses?purchase_date_after=...
Brex-->>Proxy: {items: [...], next_cursor: "abc"}
Proxy-->>MCP: Normalized response +<br>ratelimit-remaining header
MCP-->>Agent: {result: [...], next_cursor: "abc"}The proxy layer handles the translation between the agent's flat argument namespace and Brex's actual REST endpoints. The agent never knows it is talking to Brex - it just calls tools and gets structured JSON back.
MCP Tool Schema Examples for Brex Expense Operations
Here is what dynamically generated MCP tool definitions look like for Brex expense operations. These are the JSON objects returned in a tools/list response:
Read: List all expenses
{
"name": "list_all_brex_expenses",
"description": "List all expenses from the connected Brex account. Returns card expenses, reimbursements, and other expense types with amounts, merchants, categories, and receipt status.",
"inputSchema": {
"type": "object",
"properties": {
"expense_type": {
"type": "string",
"description": "Filter by expense type (e.g., CARD, REIMBURSEMENT)"
},
"purchase_date_after": {
"type": "string",
"description": "ISO 8601 date. Only return expenses purchased on or after this date."
},
"limit": {
"type": "string",
"description": "The number of records to fetch"
},
"next_cursor": {
"type": "string",
"description": "The cursor to fetch the next set of records. Always send back exactly the cursor value you received (nextCursor) without decoding, modifying, or parsing it."
}
}
}
}Write: Update a card expense
{
"name": "update_a_brex_card_expense_by_id",
"description": "Update a card expense in the connected Brex account by its expense ID. Use this to categorize an expense, add a memo, or update metadata. Admin and bookkeeper roles can update any expense; regular users can only update their own.",
"inputSchema": {
"type": "object",
"properties": {
"expense_id": {
"type": "string",
"description": "The ID of the card expense to update. Required."
},
"memo": {
"type": "string",
"description": "A memo or note for the expense"
},
"category": {
"type": "string",
"description": "The category of the expense"
}
},
"required": ["expense_id"]
}
}Custom method: Upload a receipt
{
"name": "brex_card_expenses_receipt_upload",
"description": "Upload a receipt for a specific Brex card expense. The receipt will be attached to the expense for compliance and audit purposes.",
"inputSchema": {
"type": "object",
"properties": {
"expense_id": {
"type": "string",
"description": "The ID of the card expense to attach the receipt to. Required."
},
"receipt_name": {
"type": "string",
"description": "The filename of the receipt image"
},
"receipt_url": {
"type": "string",
"description": "A URL where the receipt image can be downloaded"
}
},
"required": ["expense_id"]
}
}The tool naming convention follows the same pattern as other integrations: list_all_brex_expenses, get_single_brex_expense_by_id, update_a_brex_card_expense_by_id. Custom methods like receipt upload get a descriptive compound name. This consistency means an agent that already knows how to call list_all_xero_contacts can immediately work with Brex tools without any prompt changes.
Here is a code snippet demonstrating how an agent invokes these tools and handles the response:
import json
def invoke_brex_tool(mcp_client, tool_name, arguments):
response = mcp_client.call_tool(tool_name, arguments)
if response.get("isError"):
error = json.loads(response["content"][0]["text"])
if error.get("status_code") == 429:
reset = int(error.get("headers", {}).get("ratelimit-reset", 60))
return {"retry_after": reset, "error": "rate_limited"}
raise Exception(f"Tool call failed: {error}")
return json.loads(response["content"][0]["text"])
# List recent Brex expenses
expenses = invoke_brex_tool(client, "list_all_brex_expenses", {
"purchase_date_after": "2026-03-01",
"limit": "50"
})
# Categorize an expense
invoke_brex_tool(client, "update_a_brex_card_expense_by_id", {
"expense_id": expenses["result"][0]["id"],
"memo": "Q1 team offsite - meals",
"category": "Meals & Entertainment"
})OAuth Concurrency and Durable Locks for Brex
Brex supports two authentication methods, and the one you need depends on your use case. For multi-tenant SaaS applications connecting to your customers' Brex accounts, you must use OAuth 2.0 as a registered Brex partner. For internal tools connecting to your own Brex account, you can use API tokens generated from the Brex dashboard.
The OAuth path is where concurrency gets dangerous. Brex partner OAuth access tokens expire after 3,600 seconds (1 hour) - more generous than Xero's 30-minute window, but still short enough that any long-running agent session will need at least one refresh. The critical detail: Brex enforces refresh token rotation on every exchange. Each time you trade a refresh token for a new access token, Brex returns a new refresh token and invalidates the old one.
This makes the durable lock pattern described earlier even more important for Brex than for providers with more lenient token policies. If two concurrent agent threads both try to refresh using the same refresh token, the first one succeeds and gets a fresh token pair. The second thread's refresh token is now invalid - Brex has already rotated it. Without a lock, that second thread's failure cascades: the integrated account enters a broken state, and your customer has to re-authorize through the OAuth flow.
The lock pattern for Brex works identically to Xero and QuickBooks:
- Proactive refresh: A background scheduler refreshes Brex tokens approximately 60 to 180 seconds before the 1-hour expiry. The scheduler acquires the per-account lock, exchanges the refresh token, stores both the new access token and the new refresh token, then releases the lock.
- On-demand refresh with locking: If an agent request arrives and the token is near expiry, the proxy acquires the lock. Concurrent callers wait on the lock rather than attempting their own refresh. Once the lock holder completes the exchange, all waiting threads proceed with the fresh token.
For Brex API tokens (the non-OAuth path), the concurrency problem is simpler. These tokens do not expire on a fixed schedule - they remain valid as long as they are used at least once every 30 days. No refresh flow is needed, so no lock is needed. But if your MCP server supports both OAuth-connected and API-token-connected Brex accounts, the token management layer must handle both paths.
Rate-Limit Handling for Brex
Brex allows 1,000 requests per 60 seconds per client ID and Brex account. That sounds generous, but consider a bulk expense categorization workflow: an agent lists 200 expenses, fetches details on each one, looks up the associated card transaction for context, then writes back a category. That is 600+ API calls for a single user request.
Brex returns a bare HTTP 429 when you exceed the limit - no Retry-After header, no remaining-count headers. This makes client-side rate tracking important. Since Brex does not tell you how many requests remain in the current window, your proxy layer should maintain a sliding window counter per client-ID-and-account pair. When the counter approaches 1,000, proactively slow down before hitting the wall.
The normalized rate limit headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset) that the proxy layer returns to the agent work the same way as for Xero and QuickBooks. Even though Brex does not natively provide remaining-count headers, the proxy can compute them from its own request counter and pass them through to the agent. The agent's backoff logic remains provider-agnostic.
One gotcha specific to Brex: the 1,000-transfer-per-day limit and the 100-international-wire-per-day limit are separate from the general API rate limit. These are financial operation caps, not API throughput caps. If your agent performs payment operations through Brex, it needs to track these daily budgets independently.
Idempotency for Brex Write Operations
Brex requires an Idempotency-Key header on certain write endpoints - particularly Create transfer and Create card. This is a safety mechanism: if a network failure causes the agent to retry a payment creation, the idempotency key ensures Brex processes it only once.
Your MCP server should auto-generate and attach idempotency keys for every Brex write operation. When the agent calls tools/call for a write tool, the proxy layer generates a UUID, attaches it as the Idempotency-Key header, and stores the mapping between the tool call ID and the idempotency key. If the agent retries the same logical operation (same tool, same arguments), the proxy reuses the stored key rather than generating a new one.
This is especially important for AI agents because LLMs will sometimes retry tool calls if they do not receive a response within their timeout window. Without idempotency keys, a retry could create duplicate payments or duplicate cards.
Testing, Monitoring, and Observability
Shipping an MCP server for financial APIs without proper observability is asking for trouble. Financial data has zero tolerance for silent failures.
Testing against rate limits. Before going to production, load-test your MCP server against realistic agent workloads. Simulate a bulk expense categorization workflow (list, fetch details, update) and verify that your sliding window counter correctly throttles requests before hitting the provider's limit. Brex provides a staging environment at api-staging.brex.com, but note that it is not a sandbox and does not work with customer tokens - you will need partner credentials for meaningful testing.
Monitoring token refresh health. Track the success rate of OAuth token refreshes per integrated account. A spike in refresh failures typically means a customer revoked access or Brex changed something on their end. Alert on any account that enters a needs-reauth state so your support team can proactively reach out.
Tracking 429 rates per tenant. Aggregate rate limit errors by provider and tenant. If a specific Brex account consistently hits 429s, it likely means an agent workflow is too aggressive for that account's API budget. Surface this data in a dashboard so your team can tune the agent's batch sizes or add deliberate pauses.
Logging MCP tool invocations. Every tools/call request should be logged with the tool name, the integrated account ID, a request ID for traceability, latency, and the HTTP status code from the upstream provider. This lets you trace a single agent action from the LLM's tool call all the way through to the Brex API response. When a customer reports "the agent said my expense was categorized but it wasn't," you need that audit trail.
Alerting on schema drift. Brex, Xero, and QuickBooks all evolve their APIs over time. Monitor for unexpected response shapes - new fields, changed enum values, deprecated endpoints. Brex explicitly recommends that integrations handle unknown enum types and new response fields gracefully. If your JSONata mappings start producing null values for previously populated fields, that is a signal to update your integration configuration.
Integration Testing and Deployment Checklist
Before you flip an MCP server for Xero, QuickBooks, or Brex into production, walk through this checklist. Each item corresponds to a class of production failure that is much cheaper to catch pre-launch than at 2am.
OAuth and token management
- Per-account durable lock verified: 10 concurrent refresh calls result in exactly one upstream refresh
- In-process promise deduplication verified alongside the distributed lock
- Proactive refresh scheduled 60-180s before token expiry with randomized jitter to spread load
-
invalid_granterrors mark the account asneeds_reauthand fire theintegrated_account:authentication_errorwebhook - Refresh token rotation handled atomically: new access + refresh token written in a single transaction
- Lock timeout (30s) triggers a forced unlock so stuck refreshes don't block subsequent requests
- Successful refresh on a
needs_reauthaccount reactivates it and firesintegrated_account:reactivated
Rate limits
-
ratelimit-limit,ratelimit-remaining,ratelimit-resetheaders returned on every response - 429 responses passed through unmodified - no silent retries at the proxy layer
- Sliding window counter maintained for providers without native remaining-count headers (Brex, QuickBooks)
- Load test simulating an agent workflow (list → get details → write) sustains target throughput without breaching daily caps
- Client-side backoff code respects
Retry-Afterandratelimit-resetbefore falling back to exponential delays
Tool generation and schemas
- Only endpoints with matching documentation records surface as tools (documentation acts as the quality gate)
-
listmethods havelimitandnext_cursorinjected with the "do not modify" cursor instruction -
get/update/deletemethods haveidinjected as a required parameter -
tools/listresponse size stays under the target LLM's context budget for typical accounts - Filter combinations (
methods+tags) validated at MCP server creation time to reject empty tool sets - Tool descriptions reference sibling discovery tools (e.g., "Use list_all_contacts to discover valid IDs")
Write operations
- Every write tool call generates a deterministic idempotency key from the tool invocation ID
- Retry with the same key returns the cached response rather than creating duplicates
- Reverse JSONata mappings tested against real provider sandboxes for each write op
- Tax codes, currency codes, and account IDs validated against provider-specific values before submission
-
create,update, anddeletetool calls covered by end-to-end tests that assert both the provider response and the resulting ledger state - Dry-run mode available in staging so agents can be evaluated without touching production ledgers
Observability
- Every
tools/calllogged with tool name, integrated account ID, request ID, latency, and upstream status code - Refresh success rate tracked per integrated account with alerts on sudden drops
- 429 rates aggregated per tenant to catch runaway agent workflows
- Schema drift alerts trigger when JSONata mappings produce unexpected
nullvalues - Audit trail links every LLM tool call to the corresponding provider API response via a shared request ID
Security
- MCP token URL treated as a secret; never logged in plaintext or included in error messages
-
require_api_token_authenabled for MCP servers shared in customer-facing environments - Refresh tokens encrypted at rest (AES-GCM or equivalent) with keys rotated periodically
- Time-limited MCP servers (
expires_at) used for contractor or short-lived access - Webhook signatures verified on
integrated_account:authentication_errorbefore triggering user notifications - Rate limit and 429 responses do not leak internal request IDs or stack traces to the agent
If you can tick all of these boxes, you have an MCP server that behaves predictably under agent load. If you can't, the failure modes will find you in production.
Strategic Wrap-Up and Next Steps
Connecting AI agents to Xero and QuickBooks requires far more than just wrapping REST endpoints in an MCP server. You are building distributed systems infrastructure.
To achieve production-grade reliability, your architecture must solve three hard problems: rate limit awareness, OAuth lifecycle management, and schema normalization. Specifically, your system must:
- Eliminate custom code by dynamically generating MCP tools from API schemas.
- Normalize rate limit headers and pass 429 errors back to the agent so it can handle its own backoff logic using standard IETF headers.
- Implement a durable lock to prevent OAuth token refresh race conditions during concurrent agent tool calls.
- Deploy a unified accounting schema using declarative transformations so your agents only need one set of prompts to interact with any ERP.
If you are evaluating this stack, here is the honest trade-off matrix:
| Approach | Time to first integration | Multi-provider support | Maintenance burden |
|---|---|---|---|
| Build direct connectors | 2-4 weeks per provider | Each provider is a separate codebase | High (OAuth, rate limits, schema drift) |
| Use a unified API + MCP | Days | One schema, one tool set | Low (managed by the platform) |
| Single-provider MCP server | 1-2 weeks | Only covers one provider | Medium (you own the OAuth and rate limit logic) |
The math gets worse as you add providers. QuickBooks and Xero cover a large share of the SMB market, but enterprise deals will ask for NetSuite, Sage Intacct, and Dynamics 365. Each new direct connector multiplies your maintenance surface.
Start by reading the QuickBooks Online API integration guide and the Xero API integration guide to understand the provider-specific quirks. Then evaluate whether abstracting those quirks behind a unified API and MCP server layer saves your team enough time to justify the dependency.
Building this infrastructure in-house requires months of dedicated engineering time—time that should be spent improving your agent's reasoning capabilities, not debugging QuickBooks API quirks.
FAQ
- What is an MCP server for accounting APIs?
- An MCP (Model Context Protocol) server translates standardized JSON-RPC requests from AI models into specific API calls for accounting platforms like Xero and QuickBooks, eliminating the need for custom connectors.
- How do you handle QuickBooks and Xero API rate limits with AI agents?
- Xero strictly limits traffic to 60 requests per minute, and QuickBooks to 500. Instead of absorbing 429 errors in the infrastructure, you should pass normalized IETF rate limit headers back to the AI agent so it can natively pause its execution loop.
- Can AI agents write data back to QuickBooks and Xero?
- Yes. Using a unified accounting schema and an MCP server, agents can safely execute write operations like generating invoices or creating expense records across different platforms using a single standardized data model.
- How do you prevent OAuth token race conditions with concurrent AI agents?
- Because AI agents often make multiple concurrent API calls, you must implement a durable lock (mutex) per integrated account. This ensures that if a short-lived token expires, only one thread refreshes it while the others wait, preventing the provider from invalidating the connection.