Published Updated

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

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 or can be cancelled, and returns the result of its EFOS check (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 keeps the last digit of each product, not the sum of its digits

    The CLABE control digit weights the first 17 digits with 3, 7, 1, 3, 7, 1… From each product it keeps the last digit — the product modulo 10 — so a 63 contributes 3. Then it sums, takes the sum modulo 10 and subtracts from 10 (a result of 10 means the digit is 0).

    An implementation that copies Luhn does something else: when a product goes above 9, it adds up its digits, so the same 63 contributes 6 + 3 = 9. The two routes do not agree: for the 17 digits 09000000000000000, the CLABE algorithm gives 7 and the Luhn-style version gives 1. That is enough to accept mistyped account numbers and reject good 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 goes in the format the reference implementations use (nodecfdi and phpcfdi): six decimals with trailing zeros trimmed, but keeping at least one. So 1160.00 travels as 1160.0:

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

    A total that doesn’t follow that format, such as a hand-typed 1,160.00, can make the SAT answer No Encontrado — “not found” — for an invoice that exists. That is why 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’s documentation for the service defines the codes — 100 to 104 when the issuer or a third-party RFC on the invoice is listed (102 and 103 mean the issuer is not, only a third party is), 200 and 201 when neither is — but not an empty value, so the server reports an empty field as unknown, never as “listed”. Since version 1.0.2 it keeps the two questions apart: efos_state answers only for the issuer (100, 101 and 104 are listed; 102, 103, 200 and 201 are not_listed) and efos_third_party_state for third-party RFCs, with the raw code always in validacion_efos. Version 1.0.1 read 102 and 103 as “the issuer is listed”, which the SAT document contradicts. Either way, check the published 69-B list before acting on it.

    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.

    If you only want to open an invoice by hand, with no agent involved, the CFDI viewer that started on this site now lives on Siempre Contable (in Spanish).

    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 has no status page and it does go down (its documentation states a capacity of up to 2 million queries an hour and asks callers not to raise their volume, but promises no availability). 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, seal or stamp invoices. That half of Mexican e-invoicing needs your digital-seal certificate (CSD) and, for the stamp, a PAC, and it already has an MCP server: mcp-cfdi-mx, in Python, which builds, validates and seals CFDI 4.0 with your own CSD; the stamping itself still goes through a PAC. 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?

    Usually because one of the four query values does not match what the SAT stored: a total in another format (it goes with six decimals and trailing zeros trimmed, keeping one, so 1160.00 travels as 1160.0), a thousands separator or a mistyped UUID. 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.

    Found it useful? Share it

    Found it useful? Get the next one by email

    Once a week: what breaks when you upgrade, AI for developers and what I'm building, with sources. No spam.

    By subscribing you accept our privacy policy.

    Search

    Tags

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