September 11, 2026

Validate Mexican RFC, CURP and CFDI Invoices from Claude, With No API Keys

Photo of Marco Orta Marco Orta | 10 min read
Compartir
Typographic cover on a dark green grid: RFC · CFDI in large serif letters, a validate_rfc call on XAXX010101000 showing check_digit_satisfied false and is_generic true, and the npx command for mx-fiscal-mcp-server
Table of Contents

    Ask an AI agent to validate a Mexican RFC and it will usually write a regex. Then it will reject XAXX010101000 — the RFC printed on every invoice issued to the general public — because that RFC does not satisfy its own check digit. The SAT assigned it by decree, and the modulus-11 algorithm asks for a 4 where the SAT wrote a 0.

    That is the kind of detail an agent cannot be trusted to remember, and Mexican tax data is full of them. So I built mx-fiscal-mcp-server: an open-source MCP server that gives Claude (or Cursor, or any MCP client) eight read-only tools to validate RFC, CURP, CLABE and NSS with their real algorithms, read CFDI 4.0 invoices, ask the SAT whether an invoice is still valid, and look up the SAT’s code tables.

    It needs no API keys, no CSD certificate and no PAC contract. This post covers the setup, what you can ask it, and the six details that a hand-rolled implementation gets wrong — including one I got wrong myself before release.

    Setup in one line

    The server is published on npm and in the official MCP Registry, so there is nothing to clone. For Claude Code:

    claude mcp add mx-fiscal -- npx -y mx-fiscal-mcp-server
    

    For Claude Desktop or Cursor, add it to claude_desktop_config.json or ~/.cursor/mcp.json:

    {
      "mcpServers": {
        "mx-fiscal": {
          "command": "npx",
          "args": ["-y", "mx-fiscal-mcp-server"]
        }
      }
    }
    

    On Windows, use "command": "cmd" with "args": ["/c", "npx", "-y", "mx-fiscal-mcp-server"]. It needs Node.js 20 or newer.

    It is built on the v2 MCP SDK, so it speaks the 2026-07-28 protocol revision and still serves the 2025-era clients that Claude Desktop, Claude Code and Cursor use today, from the same process. If you maintain a server yourself, I wrote up what changes when you migrate to 2026-07-28.

    The eight tools

    ToolWhat it does
    validate_rfcRFC for individuals (13 characters) and companies (12): check digit, parsed fields, birth or incorporation date, and whether it is one of the SAT generics
    validate_curp18-character CURP: check digit, birth date, sex, state of birth and the century marker
    validate_clabe18-digit bank account number (CLABE): control digit, bank and plaza code
    validate_nss11-digit IMSS social security number with its Luhn digit
    generate_test_data1 to 100 coherent fake people or companies: RFC and CURP derived from the same name and birth date, CLABE from a real bank
    parse_cfdiCFDI 4.0 XML to JSON, with every catalogue code labelled, both RFCs validated and the totals checked
    cfdi_statusAsks the SAT’s public status service whether the invoice exists, is cancelled, can be cancelled, and whether the issuer is on the 69-B list
    sat_catalog_lookupNine SAT code tables (tax regimes, CFDI uses, payment forms and methods, and more) by code or text

    Only cfdi_status touches the network. The other seven never leave the process.

    What you can ask it

    A few prompts that work well, with the shape of what comes back:

    • “Is GODE561231GR8 a valid RFC? Decode it.” — kind (individual or company), the date encoded in it, and a legible reason for every failure: not just invalid, but “the final check digit does not match the modulus-11 result”.
    • “Generate 20 Mexican customers with valid RFC, CURP and CLABE for my seed file.” — every check digit holds, and the RFC and CURP of each record describe the same person.
    • “Read this invoice and tell me whether it’s still valid at the SAT.” — Claude calls parse_cfdi to label everything and check the arithmetic, then cfdi_status with the four values it needs.

    That last one is the flow that saves the most time, and it hides most of the traps below.

    Six details a regex gets wrong

    1. The general-public RFC does not satisfy its own check digit

    XAXX010101000 is the RFC for sales to the general public, and XEXX010101000 is for foreign residents. Run the modulus-11 algorithm on the first twelve characters of the first one and it asks for a 4. The second one happens to satisfy it.

    A validator that only runs the algorithm rejects every invoice issued to the general public — which is why so many billing forms refuse them. A validator that only checks the shape accepts typos. The server reports the two facts separately: is_generic: true and check_digit_satisfied: false, and says in words why that combination is fine.

    2. CLABE reduces each product modulo 10 before summing

    The CLABE control digit weights the first 17 digits with 3, 7, 1, 3, 7, 1… Many implementations multiply, add everything up and take the result modulo 10, Luhn-style. The Banxico algorithm reduces each weighted product modulo 10 first, then sums, then subtracts from 10. The two approaches disagree often enough to accept wrong account numbers and reject right ones.

    3. The CURP’s 17th character carries the century

    A CURP stores the birth year in two digits, so 31 could be 1931 or 2031. RENAPO resolves it with the 17th character: a digit means born before 2000, a letter means born in 2000 or later.

    This is the one I got wrong. The first version of the server read that rule backwards, and because the birth date is itself decoded through that character, it reported a “disagreement between the marker and the date” on every valid CURP it saw. The test suite had captured the wrong message as expected behaviour. An independent review caught it before publication; the canonical test vector BOXW310820HNERXN09 now decodes, correctly, to 20 August 1931.

    4. CURP state keys are RENAPO’s, not INEGI’s

    Positions 12 and 13 of a CURP encode the state of birth, with RENAPO’s own keys: DF is Mexico City, MC is the State of Mexico and NE means born abroad. They match neither the INEGI state codes nor ISO 3166-2:MX, so a lookup against either table silently mislabels people.

    5. The SAT status query is picky about the total

    cfdi_status sends the SAT an expression built from four values: issuer RFC, receiver RFC, total and UUID. The total has to be formatted the way the printed-representation specification demands — six decimals with trailing zeros trimmed, but keeping one. So 1160.00 must travel as 1160.0:

    ?re=TES150312DX2&rr=PELJ900521DK2&tt=1160.0&id=5A7B3C1D-9E2F-4A6B-8C0D-1E2F3A4B5C6D
    

    Send 1160.00, or a hand-typed 1,160.00, and the SAT answers No Encontrado — “not found” — for an invoice that exists. That is the single most common cause of a false alarm, so the server screens the four values before spending a request, and recommends passing the whole XML so it derives them itself.

    Two more rules live in the same query. An RFC containing & travels encoded as &, following the nodecfdi reference implementation. And an empty ValidacionEFOS field does not mean the issuer is on the 69-B list of companies that invoice simulated operations: the SAT documents neither the field nor its codes, so an empty value is reported as unknown, never as “listed”.

    6. The total includes local taxes

    The arithmetic check — subtotal minus discount plus transferred taxes minus withheld taxes — is how you spot a tampered or broken invoice. But the CFDI 4.0 schema defines the total over federal and local taxes, and local ones live in a separate implocal complement. A hotel invoice with a state lodging tax looks off by exactly that tax if you ignore the complement. The server adds it in.

    Reading and verifying are different things

    parse_cfdi reads the XML: header, issuer, receiver, every line item with its taxes, the tax totals and the digital stamp. It labels every code (G03 is Gastos en general, 601 is General de Ley Personas Morales), validates both RFCs and checks the arithmetic.

    It does not verify the digital signature. A perfectly parseable invoice can be cancelled or fabricated wholesale, which is what cfdi_status is for. And an unreachable SAT is not an invalid invoice: the service publishes no rate limit, no SLA and no status page, and it does go down. When it fails, the tool returns available: false with the reason — a statement about the SAT, never about the document — after a 10-second timeout and one retry.

    The same honesty applies to identifiers. A check digit that adds up says the string is well formed and nothing more. Only the SAT can say an RFC is registered, and only RENAPO that a CURP belongs to someone; this server asks neither, and its wording never implies it did. Even the fake data is labelled carefully: it is generated from common names, so a generated CURP or phone number can coincide with a real person’s by chance.

    What it deliberately does not do

    It does not build or stamp invoices. That half of Mexican e-invoicing needs a digital-seal certificate and a contract with a PAC, and it already has an MCP server: mcp-cfdi-mx, in Python. This one is the read half, which needs nothing, and the two complement each other.

    Self-hosting it safely

    Besides stdio, the server speaks stateless Streamable HTTP for shared or remote use:

    TRANSPORT=http npx -y mx-fiscal-mcp-server
    

    It is safe by default: it binds to 127.0.0.1 and only accepts localhost Host and Origin headers, which blocks DNS-rebinding attacks from a web page. To expose it behind a reverse proxy, set HOST=0.0.0.0, ALLOWED_HOSTS with your public hostname and MCP_AUTH_TOKEN for Bearer authentication. Don’t skip the token: an open instance relays requests to the SAT for anyone and can get your IP rate-limited.

    Invoice XML is untrusted input, so a document that declares a DOCTYPE is refused before parsing (a CFDI never has one), and size and element-count caps bound the memory a single parse can take.

    The check-digit arithmetic lives in mx-identifiers, a zero-dependency library tested against the public SAT, Banxico and RENAPO vectors, and the server has 51 offline unit tests plus a smoke test that calls every tool over the real protocol in both protocol eras. If you are looking for MCP servers in other areas, I keep a list of the best MCP servers for SEO, including my own.

    Frequently asked questions

    Does the server need a SAT account, a CSD certificate or an API key?

    No. Every check-digit algorithm and every catalogue is public, and the SAT's CFDI status service — the one behind the QR code on printed invoices — is public and unauthenticated. The server only reads; it never stamps or cancels anything, so it needs no credentials of any kind.

    Why does the SAT say 'No Encontrado' for an invoice that exists?

    Almost always because one of the four query values does not match what the SAT stored. The usual culprit is the total: it must be formatted with six decimals and trailing zeros trimmed, keeping one, so 1160.00 travels as 1160.0. Thousands separators or a mistyped UUID have the same effect. Passing the whole XML to cfdi_status lets the server derive the four values itself.

    Is XAXX010101000 a valid RFC?

    Yes. It is the SAT's generic RFC for sales to the general public, assigned by decree, and it appears on real invoices. It does not satisfy the modulus-11 check digit — the algorithm asks for a 4 — so validators without an explicit allow-list reject it. XEXX010101000, for foreign residents, is also generic and does satisfy the algorithm.

    Can the generated test data belong to a real person?

    By chance, yes. generate_test_data builds each record from common Mexican names and a random birth date, and a CURP is derived from exactly those fields, so a collision with a real person's CURP or phone number is possible. The data is meant for fixtures, seeds and demos; never send it to the SAT or a PAC, and never use it as a stand-in for a customer's real data.

    Compartir

    Search

    Tags

    PHP AI Tutorial JavaScript Migration Laravel Web Development Best Practices Security Upgrade Claude Laravel 13 OpenAI Backend SEO