Skip to content

Add a Runnable Coupa MCP Quickstart (With Code) - 2026 Guide

A complete architectural blueprint and runnable code for building a Coupa MCP server. Learn how to handle XML defaults, 429 rate limits, and offset pagination.

Uday Gajavalli Uday Gajavalli · · 20 min read
Add a Runnable Coupa MCP Quickstart (With Code) - 2026 Guide

You are sitting in a pipeline review meeting, staring at a stalled six-figure enterprise deal. The prospect loves your B2B SaaS product, the technical evaluation went perfectly, and their security team approved your architecture. Then procurement steps in with a hard requirement: your platform's AI agent must be able to read and interact directly with their Coupa instance before they will sign the contract.

If you're trying to wire an AI agent into a customer's Coupa instance to unblock this deal, naive MCP function calling will fail in week one. Coupa's Core REST API caps pagination at 50 records per page, defaults to XML, runs on OAuth 2.0 Client Credentials with roughly 24-hour token expiry, and ships zero documented rate limits. Your Model Context Protocol (MCP) server has to absorb all of that complexity so the LLM never sees it.

Below is a runnable architectural blueprint, with complete TypeScript code, for shipping a Coupa MCP server that an agent (Claude, ChatGPT, Cursor, or anything else speaking JSON-RPC 2.0) can actually use in production. If you searched for an MCP server for the Coupa procurement API, this is the reference implementation you can clone, run, and point Claude Desktop at within an hour.

If you want the broader architecture context first, our Coupa MCP architecture guide covers the strategic design trade-offs before diving into the code.

Why AI Agents Struggle with the Coupa API

Exposing Coupa's legacy Core REST API directly to an LLM via basic function calling is a guaranteed failure. The API is hostile to generative AI workflows because it combines offset-based pagination with a hard ceiling, XML-by-default responses, deeply nested payloads, and undocumented throttling. Each quirk is annoying on its own; together, they break any agent built on top of raw function calling.

1. The 50-Record Pagination Ceiling The pagination ceiling is the headline problem. Coupa allows up to 50 records per API GET and returns the next 50 via an offset query parameter. There is no way to increase that limit, so full enumeration requires iterating offset=0, 50, 100 until an empty array comes back. An agent searching for a single invoice across 10,000 records would issue 200 sequential calls. LLMs are notoriously bad at managing offset-based math (offset = page * limit) across long-running conversational turns. They will inevitably hallucinate offsets, skip pages, or get stuck in infinite loops.

2. XML Defaults and Payload Bloat Coupa's interface is UTF-8 XML based, where you create, update, and act on records by making HTTP request calls. While modern LLMs can parse XML, doing so wastes an enormous number of tokens. Even if you explicitly request Accept: application/json, Coupa payloads are deeply nested and highly verbose. A single purchase order response can include custom segments and approval chain references that consume thousands of tokens, easily blowing past an LLM's context window.

3. OAuth 2.0 Client Credentials & Token Expiry Auth is the next trap. You register an OAuth2 application under Setup > Integrations > OAuth2/OpenID Connect Clients, obtain a client_id and client_secret, then POST to https://{instance}.coupahost.com/oauth2/token with grant_type=client_credentials to receive a Bearer token, which you then send in the Authorization header for subsequent requests. Because these tokens expire in roughly 24 hours, any long-running agent session needs proactive refresh logic built into the middleware.

4. Undocumented Rate Limits Finally, throttling. Coupa publishes no official rate limit headers, recommends implementing exponential backoff on 429 or 503 responses, and expects bulk operations to be batched and spaced to avoid throttling. Your integration will hit HTTP 429 errors unexpectedly. If your AI agent does not know how to handle these errors, it will assume the API is broken and hallucinate a degraded response.

To understand the underlying complexities of the raw API before wrapping it in MCP, review our Coupa API developer tutorial.

The Solution: Model Context Protocol (MCP) for Procurement

Generative AI adoption in procurement is accelerating rapidly. To meet this demand safely, enterprise teams are aggressively adopting the Model Context Protocol (MCP).

MCP is a JSON-RPC 2.0 protocol that exposes a third-party API as a set of typed tools an LLM can discover and call. Instead of teaching Claude or ChatGPT how to authenticate with Coupa, how to parse XML, and how to paginate, you build an MCP server. The LLM acts as the MCP client, requesting available tools. The MCP server executes the complex API logic and returns clean, truncated text.

That M×N collapse is why adoption is moving fast. MCP adoption hit 97 million monthly SDK downloads by March 2026, up from 100,000 at launch, and OpenAI, Google, Microsoft, and Salesforce all shipped support within 13 months. 78% of enterprise AI teams report at least one MCP-backed agent in production in April 2026, with 67% of CTOs surveyed naming MCP their default agent-integration standard within 12 months.

For procurement specifically, the architecture matters more than the protocol. MCP enables any compliant AI agent to plug into calendars, Notion workspaces, code repositories, or enterprise databases using a consistent JSON-RPC-based protocol over Streamable HTTP, but Coupa's quirks still have to be handled somewhere. The point of building an MCP server for the Coupa procurement API is to push pagination, auth, and error normalization out of the agent and into a layer the agent doesn't have to think about.

If MCP itself is new to you, start with our hands-on MCP server guide.

flowchart LR
    A[AI Agent<br>Claude / ChatGPT] -->|JSON-RPC 2.0| B[Coupa MCP Server]
    B -->|OAuth 2.0<br>Client Credentials| C[Coupa Core REST API]
    B -->|Refresh ahead of<br>24h expiry| D[Token Store]
    B -->|Normalize<br>offset to cursor| A
    C -->|JSON via Accept Header<br>50 records max| B

Add Runnable Coupa MCP Quickstart with Code

Below is a complete, runnable TypeScript implementation of a Coupa MCP server. This server exposes a single tool - list_coupa_purchase_orders - and handles the underlying HTTP transport, proactive OAuth token refreshing, and JSON parsing.

The full source (both TypeScript and Python variants) is available as a companion repository:

GitHub: github.com/truto-one/coupa-mcp-quickstart

The repo contains two folders - typescript/ and python/ - each implementing the same list_purchase_orders tool against Coupa's Model Context Protocol contract. Clone it if you want a working baseline before reading the walkthrough.

Prerequisites and Sandbox Setup

Before you write any code, you need three things: a Coupa sandbox instance, an OAuth2 client registered in that instance, and a local dev environment with Node 20+ or Python 3.11+.

  1. Get a Coupa sandbox. If your account team has one, it will be at https://<your-instance>-test.coupahost.com. Coupa does not offer public developer sandboxes - you need an existing customer or partner relationship to get access.
  2. Confirm API access. Log into the sandbox as an admin and verify you can reach Setup > Integrations > OAuth2/OpenID Connect Clients. If this menu is missing, ask your Coupa admin to enable the REST API feature for your user role.
  3. Install runtimes. Node 20+ with npm (or bun), plus Python 3.11+ with pip. You only need one; the repo mirrors both implementations for parity.
  4. Install an MCP client. Claude Desktop is the easiest to test with; Cursor and Continue also work. Anything speaking the JSON-RPC 2.0 MCP transport is fine.

Coupa OAuth App Registration

The MCP server authenticates to Coupa using the OAuth 2.0 Client Credentials grant. You register the OAuth application once per Coupa instance:

  1. Log into your Coupa sandbox as an admin.
  2. Navigate to Setup → Integrations → OAuth2/OpenID Connect Clients.
  3. Click Create.
  4. Select Grants: Client Credentials.
  5. Under Scopes, add the minimum scopes your tool needs. For the purchase order example: core.purchase_order.read. Add more scopes only if your MCP server exposes tools that require them.
  6. Save. Coupa displays the client_id and client_secret once - copy both immediately into a password manager. The secret is not shown again.

The token endpoint is https://<instance>.coupahost.com/oauth2/token, and all API calls go to https://<instance>.coupahost.com/api/.... Both hosts share the same subdomain.

Environment Variables

Every implementation in the quickstart repo reads the same three environment variables:

export COUPA_INSTANCE="acme-test"                # subdomain only, no https://
export COUPA_CLIENT_ID="abcd1234..."
export COUPA_CLIENT_SECRET="ef56..."

For local runs, drop them in a .env file (both folders ship a .env.example). Never commit real credentials - the client_secret gives full API access under the scopes you granted.

Clone and Run the Minimal MCP Server

The repo has two folders: typescript/ and python/. Both expose the same list_purchase_orders tool over MCP stdio transport, so pick whichever runtime your team already uses.

TypeScript:

git clone https://github.com/truto-one/coupa-mcp-quickstart.git
cd coupa-mcp-quickstart/typescript
npm install
cp .env.example .env      # then fill in COUPA_INSTANCE, CLIENT_ID, CLIENT_SECRET
npm run dev                # starts the MCP server on stdio

Python:

cd coupa-mcp-quickstart/python
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
python server.py           # starts the MCP server on stdio

Once the process prints Coupa MCP Server running on stdio, wire it into your MCP client using the config in Step 3 below.

Step 1: Project Setup

First, initialize a new Node.js project and install the official Model Context Protocol SDK along with Zod for schema validation.

mkdir coupa-mcp-server
cd coupa-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init

Step 2: The MCP Server Implementation

Create a file named server.ts. This code initializes the server, defines the tool schema using Zod, manages the 24-hour OAuth token lifecycle, and implements the execution handler.

// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
 
// Environment configuration
const COUPA_INSTANCE = process.env.COUPA_INSTANCE;
const COUPA_CLIENT_ID = process.env.COUPA_CLIENT_ID;
const COUPA_CLIENT_SECRET = process.env.COUPA_CLIENT_SECRET;
 
if (!COUPA_INSTANCE || !COUPA_CLIENT_ID || !COUPA_CLIENT_SECRET) {
  console.error("Missing required environment variables.");
  process.exit(1);
}
 
// Initialize the MCP Server
const server = new McpServer({
  name: "coupa-mcp-server",
  version: "1.0.0"
});
 
// OAuth Token Cache
let tokenCache: { value: string; expiresAt: number } | null = null;
 
async function getCoupaToken(): Promise<string> {
  // Proactive refresh: Fetch new token 5 minutes before actual expiry
  if (tokenCache && tokenCache.expiresAt > Date.now() + 5 * 60 * 1000) {
    return tokenCache.value;
  }
 
  const res = await fetch(`https://${COUPA_INSTANCE}.coupahost.com/oauth2/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: COUPA_CLIENT_ID!,
      client_secret: COUPA_CLIENT_SECRET!,
      scope: 'core.purchase_order.read'
    })
  });
 
  if (!res.ok) throw new Error(`Coupa token fetch failed: ${res.status}`);
  const data = await res.json();
 
  tokenCache = {
    value: data.access_token,
    expiresAt: Date.now() + data.expires_in * 1000
  };
  return tokenCache.value;
}
 
// Define the Tool Schema with explicit prompt injection for cursors
const listPurchaseOrdersSchema = {
  status: z.enum(['draft', 'issued', 'cancelled', 'closed']).optional().describe('Filter by PO status'),
  limit: z.number().min(1).max(50).default(50).describe('Coupa hard-caps this at 50. Do not request more.'),
  next_cursor: z.string().optional().describe(
    'Opaque cursor from the previous response. Send back exactly the value you received, without decoding or modifying it.'
  )
};
 
// Register the Tool
server.tool(
  "list_coupa_purchase_orders",
  "List purchase orders from Coupa. Results are paginated. Pass the next_cursor value from the previous response back unchanged to get the next page.",
  listPurchaseOrdersSchema,
  async (args) => {
    const { status, limit, next_cursor } = args;
    
    // Decode the opaque cursor back to Coupa's offset
    const offset = next_cursor ? Number(Buffer.from(next_cursor, 'base64').toString()) : 0;
    
    try {
      const token = await getCoupaToken();
      const url = new URL(`https://${COUPA_INSTANCE}.coupahost.com/api/purchase_orders`);
      url.searchParams.append("limit", limit.toString());
      url.searchParams.append("offset", offset.toString());
      if (status) url.searchParams.append("status", status);
 
      const response = await fetch(url.toString(), {
        method: "GET",
        headers: {
          "Accept": "application/json", // Force JSON to prevent XML bloat
          "Authorization": `Bearer ${token}`
        }
      });
 
      if (!response.ok) {
        if (response.status === 429) {
          // Surface rate limits cleanly to the LLM
          return {
            isError: true,
            content: [{
              type: "text",
              text: `HTTP 429 Rate Limit Exceeded. Please wait 60 seconds before retrying. Headers: ${JSON.stringify(Object.fromEntries(response.headers))}`
            }]
          };
        }
        throw new Error(`Coupa API Error: ${response.status} ${response.statusText}`);
      }
 
      const data = await response.json();
      
      // Calculate the next offset and encode it as an opaque base64 cursor
      const nextOffset = data.length === limit ? offset + limit : null;
      const nextCursor = nextOffset ? Buffer.from(String(nextOffset)).toString('base64') : null;
 
      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            results: data.map((po: any) => ({
              id: po.id,
              status: po.status,
              total: po.total,
              currency: po.currency?.code
            })), // Strip deep nesting to save LLM context window
            next_cursor: nextCursor,
            count: data.length
          })
        }]
      };
    } catch (error: any) {
      return {
        isError: true,
        content: [{ type: "text", text: `Execution failed: ${error.message}` }]
      };
    }
  }
);
 
// Start the server over stdio for local testing
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Coupa MCP Server running on stdio");
}
 
main().catch(console.error);

Step 3: Running the Quickstart Locally

To test this server locally with Claude Desktop, you must add it to your claude_desktop_config.json file:

{
  "mcpServers": {
    "coupa": {
      "command": "npx",
      "args": ["tsx", "/path/to/coupa-mcp-server/server.ts"],
      "env": {
        "COUPA_INSTANCE": "your-domain",
        "COUPA_CLIENT_ID": "your_client_id_here",
        "COUPA_CLIENT_SECRET": "your_client_secret_here"
      }
    }
  }
}

Restart Claude Desktop, and you can now ask Claude: "Find the last 50 issued purchase orders in Coupa."

Tip

Production Transport: While stdio is great for local testing, production deployments usually require exposing the MCP server over HTTP. You can easily swap the StdioServerTransport for the StreamableHTTPServerTransport using Express.js to expose a /mcp endpoint that remote agents can connect to.

End-to-End Demo: LLM Discovers and Calls list_purchase_orders

Once the server is registered in Claude Desktop and Claude has restarted, you can watch the full MCP handshake and tool call in the Claude Desktop logs (~/Library/Logs/Claude/mcp-server-coupa.log on macOS, %APPDATA%\Claude\Logs\ on Windows).

Here is what happens when you ask Claude "List the last 10 issued purchase orders in Coupa":

  1. Discovery. Claude sends tools/list over JSON-RPC. Your server responds with the list_coupa_purchase_orders tool definition, including the Zod-derived JSON schema and the next_cursor description that instructs the model to echo cursors back unchanged.
  2. Invocation. Claude sends tools/call with { name: "list_coupa_purchase_orders", arguments: { status: "issued", limit: 10 } }.
  3. Token acquisition. The server checks the token cache, sees it is empty, and calls POST /oauth2/token with grant_type=client_credentials. The access_token is cached with its expires_in window.
  4. Coupa call. The server issues GET /api/purchase_orders?status=issued&limit=10&offset=0 with Accept: application/json and Authorization: Bearer <token>.
  5. Response shaping. The server trims each PO to { id, status, total, currency }, encodes the next offset as an opaque base64 cursor, and returns a single MCP text content block.
  6. Follow-up turn. If the user asks for the next page, Claude sends the same tool with next_cursor: "MTA=". The server decodes to offset=10 and repeats.
sequenceDiagram
    participant User
    participant Claude as Claude Desktop
    participant MCP as Coupa MCP Server
    participant Coupa
    User->>Claude: "List last 10 issued POs"
    Claude->>MCP: tools/list
    MCP-->>Claude: [list_coupa_purchase_orders]
    Claude->>MCP: tools/call(status=issued, limit=10)
    MCP->>Coupa: POST /oauth2/token
    Coupa-->>MCP: access_token (expires_in=86400)
    MCP->>Coupa: GET /api/purchase_orders?status=issued&limit=10
    Coupa-->>MCP: 10 records (JSON)
    MCP-->>Claude: trimmed results + next_cursor
    Claude-->>User: natural language answer

Expected server-side log output for a successful flow:

[INFO] Coupa MCP Server running on stdio
[INFO] tools/list requested
[INFO] tools/call name=list_coupa_purchase_orders args={"status":"issued","limit":10}
[DEBUG] Token cache miss; fetching new access_token
[DEBUG] Token acquired; expires_in=86400
[DEBUG] GET https://acme-test.coupahost.com/api/purchase_orders?status=issued&limit=10&offset=0 -> 200
[INFO] Returned 10 records; next_cursor=MTA=

If you see this exact shape in your logs, the MCP contract is healthy end to end.

Token Refresh Example (Code)

The token cache in server.ts above is the minimum implementation. Here is the same logic isolated so you can drop it into any MCP server, with a proactive 5-minute refresh window and single-flight deduplication so parallel tool calls do not stampede the token endpoint:

// coupaAuth.ts
type CachedToken = { value: string; expiresAt: number };
 
let tokenCache: CachedToken | null = null;
let inflight: Promise<string> | null = null;
 
async function fetchNewToken(): Promise<string> {
  const res = await fetch(
    `https://${process.env.COUPA_INSTANCE}.coupahost.com/oauth2/token`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: process.env.COUPA_CLIENT_ID!,
        client_secret: process.env.COUPA_CLIENT_SECRET!,
        scope: 'core.purchase_order.read'
      })
    }
  );
  if (!res.ok) {
    throw new Error(`Coupa token fetch failed: ${res.status} ${await res.text()}`);
  }
  const data = (await res.json()) as { access_token: string; expires_in: number };
  tokenCache = {
    value: data.access_token,
    expiresAt: Date.now() + data.expires_in * 1000
  };
  return data.access_token;
}
 
export async function getCoupaToken(): Promise<string> {
  const REFRESH_BUFFER_MS = 5 * 60 * 1000;
  if (tokenCache && tokenCache.expiresAt > Date.now() + REFRESH_BUFFER_MS) {
    return tokenCache.value;
  }
  if (!inflight) {
    inflight = fetchNewToken().finally(() => {
      inflight = null;
    });
  }
  return inflight;
}

Coupa Client Credentials tokens have roughly a 24-hour TTL. The 5-minute buffer prevents the edge case where a token expires mid-request. The inflight promise makes concurrent callers reuse the same refresh instead of hammering /oauth2/token.

Offset Pagination Example (Code)

The MCP tool handler exposes an opaque cursor to the LLM, but internally it still speaks Coupa's offset/limit dialect. This helper isolates that translation so it can be reused across every list tool you add:

// coupaPagination.ts
const PAGE_SIZE = 50; // Coupa hard cap
 
export function decodeCursor(cursor?: string): number {
  if (!cursor) return 0;
  const decoded = Number(Buffer.from(cursor, 'base64').toString());
  if (!Number.isFinite(decoded) || decoded < 0) {
    throw new Error('Invalid cursor');
  }
  return decoded;
}
 
export function encodeCursor(offset: number): string {
  return Buffer.from(String(offset)).toString('base64');
}
 
// For internal aggregation (rarely needed inside an MCP tool -
// prefer returning one page at a time and letting the LLM drive).
export async function fetchAllPages<T>(
  fetchPage: (offset: number, limit: number) => Promise<T[]>
): Promise<T[]> {
  const results: T[] = [];
  let offset = 0;
  while (true) {
    const page = await fetchPage(offset, PAGE_SIZE);
    results.push(...page);
    if (page.length < PAGE_SIZE) break;
    offset += PAGE_SIZE;
  }
  return results;
}

Use fetchAllPages only inside tools that must aggregate before responding. For LLM-facing pagination, return the encoded cursor so the model drives the loop one page at a time.

XML-to-JSON Trimming Utility (Code)

Coupa returns XML by default and JSON when you send Accept: application/json. The JSON is still bloated - a single purchase order response can include null fields, deeply nested custom_fields, and full approval chain references. Trim aggressively before returning the payload to the LLM:

// coupaTransform.ts
import { XMLParser } from 'fast-xml-parser';
 
const parser = new XMLParser({
  ignoreAttributes: false,
  attributeNamePrefix: ''
});
 
export function trimPurchaseOrder(po: any) {
  return {
    id: po.id,
    number: po['po-number'] ?? po.number,
    status: po.status,
    total: po.total,
    currency: po.currency?.code,
    supplier: po.supplier
      ? { id: po.supplier.id, name: po.supplier.name }
      : null,
    created_at: po['created-at'] ?? po.created_at
  };
}
 
// Fallback: if you ever receive XML (e.g. Accept header dropped by a proxy),
// collapse the collection wrapper into a plain array.
export function coupaXmlToJson(xml: string): any[] {
  const parsed = parser.parse(xml);
  const root = Object.values(parsed)[0] as any;      // <purchase-orders>
  const collection = Object.values(root)[0];          // <purchase-order> list
  return Array.isArray(collection) ? collection : [collection];
}

Rule of thumb: if a field is not documented as user-facing, drop it. Every kilobyte you send to the LLM eats context budget and slows the model down.

Retry/Backoff Example (Code)

The MCP server itself should not silently retry on 429s (see the rate limit section below), but transient network errors and 5xx responses are safe to retry with exponential backoff. Keep the total delay bounded so the LLM client does not time out:

// coupaRetry.ts
type RetryOpts = { maxRetries?: number; baseMs?: number; maxMs?: number };
 
export async function withRetry(
  fn: () => Promise<Response>,
  opts: RetryOpts = {}
): Promise<Response> {
  const { maxRetries = 3, baseMs = 500, maxMs = 4000 } = opts;
  let attempt = 0;
  while (true) {
    const res = await fn();
    // Do NOT retry 429 here - surface it to the caller so the agent can back off.
    if (res.status < 500 || attempt >= maxRetries) return res;
    const delay = Math.min(maxMs, baseMs * 2 ** attempt) + Math.random() * 250;
    await new Promise(r => setTimeout(r, delay));
    attempt++;
  }
}
 
// Usage inside the tool handler:
// const response = await withRetry(() => fetch(url, { headers }));

Wrap the raw fetch in withRetry for 5xx recovery. Leave 429 handling to the LLM by returning the error as MCP tool output with the ratelimit-reset guidance baked in.

Troubleshooting and Expected Logs

Most MCP quickstart failures fall into five buckets. Match your symptom to the fix:

Symptom Likely cause Fix
401 Unauthorized on /oauth2/token Wrong client_id/client_secret or wrong instance subdomain Re-check the OAuth2 client entry in Coupa. COUPA_INSTANCE is the subdomain only, not the full URL.
403 Forbidden on /api/purchase_orders OAuth scope missing Add core.purchase_order.read to the client, restart the server so the token cache clears.
Claude shows "no tools available" MCP server crashed on startup Read stderr in ~/Library/Logs/Claude/mcp-server-coupa.log (macOS) or %APPDATA%\Claude\Logs\ (Windows).
Response is XML instead of JSON Accept header missing from the outbound request Confirm the fetch call sets "Accept": "application/json". Coupa defaults to XML when the header is absent.
Repeated 429 Too Many Requests Agent is looping without honoring the reset window Confirm your MCP server returns 429 as an MCP error (not a retry), and that the client reads ratelimit-reset.

A healthy first-run session prints something like:

[INFO] Coupa MCP Server running on stdio
[INFO] initialize protocolVersion=2024-11-05
[INFO] tools/list -> 1 tool (list_coupa_purchase_orders)
[INFO] tools/call name=list_coupa_purchase_orders args={"status":"issued","limit":50}
[DEBUG] Token cache miss; POST /oauth2/token -> 200 (expires_in=86400)
[DEBUG] GET /api/purchase_orders?status=issued&limit=50&offset=0 -> 200 (37 records)
[INFO] tools/call complete; next_cursor=null

If you see all six lines in that order, your Coupa MCP server is working end to end.

Handling Coupa Rate Limits (HTTP 429) in Your MCP Server

When you expose Coupa to an AI agent, the agent will attempt to execute tasks as fast as possible. Because Coupa does not publish explicit rate limit thresholds, you will inevitably hit HTTP 429 (Too Many Requests) errors.

A common architectural mistake is attempting to build automatic retry logic with exponential backoff directly into the MCP server itself. Do not do this. An MCP server should never silently retry a 429. If the MCP server blocks a request for 30 seconds while retrying, the LLM client will time out, assume the tool failed, and hallucinate a response.

Instead, your MCP server must pass the error back to the caller immediately.

Standardizing Rate Limit Headers

The IETF has a draft spec for standardized rate limit headers: ratelimit-limit, ratelimit-remaining, and ratelimit-reset. Your MCP server should emit these regardless of what the upstream sent, so every agent sees the same shape. When Coupa returns a 429 with no useful metadata, populate ratelimit-reset with a conservative backoff window (60 seconds is a sensible default given Coupa publishes no official rate limit headers and recommends exponential backoff on 429 or 503 responses).

Info

Truto Architecture Fact: Truto does not retry, throttle, or apply backoff on rate limit errors. When an upstream API returns HTTP 429, Truto passes that error straight to the caller along with normalized ratelimit-* headers. The caller (the AI agent or your application backend) is entirely responsible for reading the ratelimit-reset header and implementing its own retry or backoff logic. Hiding 429s inside a unified API platform leads to silent latency spikes that nobody can debug.

By explicitly stating the wait time in the text response to the LLM (as shown in the code above), you give the model the context it needs to pause its execution loop or inform the user that the system is temporarily throttled.

Normalizing Coupa's Offset Pagination for LLMs

Coupa uses offset-based pagination. To get page two, you pass limit=50 and offset=50. To get page three, you pass limit=50 and offset=100.

LLMs are mathematically terrible at maintaining this pagination state. If an agent is deep into a reasoning loop, it will increment offsets by the wrong amount, drop the parameter entirely, attempt to pass a page number instead of the calculated offset, or hallucinate that pagination is complete.

The right pattern is opaque cursors: encode the offset as a base64 string, hand it to the LLM, and instruct the model to echo it back unchanged. Never expose offset= to the model directly.

In your MCP tool definition, the description of the next_cursor property acts as a system prompt. Use this exact description string to prevent hallucinations:

"Opaque cursor from the previous response. Send back exactly the value you received, without decoding or modifying it."

Inside your MCP server, you handle the translation:

  1. Receive next_cursor from the LLM (e.g., "NTA=", which is base64 for "50").
  2. Decode it into an integer and pass it to Coupa as the offset parameter.
  3. Calculate the next offset (current_offset + limit).
  4. Return the new offset to the LLM as an opaque base64 string in the next_cursor field.
sequenceDiagram
    participant LLM
    participant MCP as Coupa MCP Server
    participant Coupa
    LLM->>MCP: list_coupa_purchase_orders(status=issued)
    MCP->>Coupa: GET /api/purchase_orders?status=issued&offset=0&limit=50
    Coupa-->>MCP: 50 records
    MCP-->>LLM: { result, next_cursor: "NTA=" }
    LLM->>MCP: list_coupa_purchase_orders(status=issued, next_cursor="NTA=")
    MCP->>Coupa: GET /api/purchase_orders?status=issued&offset=50&limit=50
    Coupa-->>MCP: 23 records
    MCP-->>LLM: { result, next_cursor: null }

That one description blocks 90% of pagination bugs we see in agent workflows. By treating the cursor as an opaque string, you remove the mathematical burden from the AI agent, drastically reducing pagination failures.

Build vs. Buy: Generating Coupa MCP Tools Automatically

Building the quickstart above takes a few hours. Hardcoding every resource in the Coupa API - Invoices, Suppliers, Requisitions, Users, Approvals, Tracking Categories - takes months. Maintaining the OAuth Client Credentials lifecycle, handling schema drift, and writing JSON schemas for hundreds of endpoints (plus custom segments unique to each customer's instance) is a massive engineering drain.

This is the architectural choice that defines whether your Coupa MCP project takes a week or a quarter. The build cost itself is real: a custom Coupa MCP server typically requires 8-12 weeks of initial engineering effort covering OAuth token management, tool schema design, response filtering, pagination handling, and MCP protocol compliance, and ongoing maintenance is required as Coupa releases three major updates per year.

This is why treating integrations as code is an architectural dead end.

Truto takes a radically different, data-driven approach. The entire platform contains zero integration-specific code. Integration behavior is defined entirely as declarative data. When you use Truto to connect an AI agent to Coupa, you do not write MCP server code. Truto dynamically generates MCP tools on the fly based on documentation records.

Here is how the architecture works:

  1. Dynamic Tool Generation: When an MCP client requests tools via tools/list, Truto reads the integration's documentation records. It automatically generates descriptive snake_case tool names (e.g., list_all_coupa_purchase_orders) and builds the required JSON schemas for the LLM.
  2. Automatic Authentication: You do not need to build an OAuth token manager. Truto handles the OAuth Client Credentials flow securely behind the scenes, proactively refreshing tokens ahead of expiry.
  3. Generic Execution Pipeline: Pagination is normalized to opaque cursors automatically by the platform's execution pipeline. Rate limit responses are passed through with IETF-standard headers so your agent can implement proper backoff without guessing what Coupa meant.

If your engineering team needs to ship an enterprise Coupa integration this sprint, building an MCP server from scratch is a risky distraction. You need a unified API platform that handles the auth, normalizes the pagination, and generates the tools automatically.

To see how this architecture compares to building in-house, read our guide on the Best MCP Server for Coupa in 2026.

Warning

Watch out for Coupa's instance-specific IDs. Role IDs are instance-specific - always query /api/roles to resolve role names to IDs before assigning. Hardcoding role or group IDs across customers will silently break in production. Your tool schemas should expose lookup tools, not raw ID inputs.

Where to Take This Next

The MCP protocol is settling into industry infrastructure. Every major AI platform now supports MCP as a client - ChatGPT, Claude, Gemini, Microsoft Copilot, VS Code, Cursor, and Replit - and Forrester predicts 30% of enterprise app vendors will launch their own MCP servers in 2026. The question for procurement-adjacent SaaS is no longer whether to ship a Coupa MCP integration, but how much of the underlying boilerplate you want to own.

Three pragmatic next steps:

  1. Prototype with the code above against a Coupa sandbox. You'll hit the 50-record ceiling on your second tool call. That's the moment to decide whether you're building infrastructure or building product.
  2. Pick an auth boundary. Either embed Coupa OAuth credentials per customer in your own vault, or use a unified API layer that owns the token lifecycle and only passes you back the data.
  3. Decide where 429s live. If your agent platform handles retries, surface raw rate-limit errors. If not, you'll need a backoff queue between the LLM and Coupa - which is its own engineering project.

Whatever you pick, do not let the Coupa quirks leak into your tool schemas. The whole value of MCP is that the model sees clean, well-typed operations and the messy parts stay on the server.

FAQ

Why can't I connect an LLM directly to the Coupa API?
Coupa defaults to XML responses, enforces strict 50-record offset pagination, and returns massive payloads that easily exceed LLM context windows without a middleware layer to filter them.
How do you handle Coupa API rate limits in an MCP server?
When Coupa returns a 429 error, the MCP server must pass the error directly to the caller and normalize the headers to IETF standards (ratelimit-limit, ratelimit-remaining, ratelimit-reset) so the AI agent can implement proper backoff.
What is the best way to paginate Coupa data for AI agents?
Abstract Coupa's offset pagination into an opaque base64 cursor system. Inject explicit instructions into the MCP tool's JSON Schema telling the LLM to pass the next_cursor value unchanged to prevent hallucinations.
How long does it take to build a Coupa MCP server from scratch?
Roughly 8-12 weeks of initial engineering work covering OAuth Client Credentials, token refresh, tool schema design, response filtering, offset-to-cursor pagination, and MCP protocol compliance.

More from our Blog