Read and keep what ANAF posts to your Spațiul Privat Virtual
(pfinternet.anaf.ro) from your AI assistant:
decisions, summonses, notifications, payment confirmations and answers. It also
requests documents, writes to ANAF through SPV's contact form (a dry run unless
you say otherwise), validates declarations and files a D212. Nothing here moves
money; the one money tool, submit_declaration, files a declaration, and
your client asks you before each call.
You need an SPV account for a person ("persoană fizică") that signs in with a user name and password, not a qualified certificate; its password in a file or a vault MCP server; and a mail MCP server that reads the mailbox SPV sends sign-in codes to (without one, someone types each code). Validating a declaration also needs Java 8 or newer. ANAF has no API for such an account, so the server reads SPV's web pages.
What you can ask
- "Is there anything new from ANAF in my SPV?"
- "Download the decision ANAF posted last week into my 2026-10 folder."
- "Ask ANAF for my Obligatii de plata statement and save it when it arrives."
- "Save every SPV document I have not saved yet."
- "Check the D300 for September in d300-2026-09.xml with ANAF's validator. How much VAT does it make payable?"
- "What is the status of the filing INTERNT-123456789-2026?"
Install
Install in VS Code Install in Cursor Download for Claude Desktop
Note
@foreverworks/mcp-anaf-spv is not on npm yet: the buttons and the npx commands below work once it is published. Until then, run it from a clone.
With Claude Code:
claude mcp add anaf-spv -- npx -y @foreverworks/mcp-anaf-spv
Claude Desktop (Settings, Developer, Edit Config) and most other clients read the same command as JSON:
{
"mcpServers": {
"anaf-spv": {
"command": "npx",
"args": ["-y", "@foreverworks/mcp-anaf-spv"]
}
}
}
Each runs the published package on Node 22.19 or newer. VS Code and the Claude Desktop bundle ask for each setting as they add the server; leave an optional one empty. Cursor reads each from your environment instead (${env:ANAF_SPV_USERNAME} and so on), so set them before you start Cursor. Every setting is optional; Configuration says which ones you need for which tools.
From a clone
git clone https://github.com/foreverworks/mcps.git && cd mcps
npm ci && npm run build
claude mcp add anaf-spv -- node "$PWD/servers/anaf-spv/dist/index.js"
In a JSON configuration, the command is node and its one argument the full path of servers/anaf-spv/dist/index.js. To share one running copy between several clients over HTTP, see docs/deploying.md.
A complete configuration, with this repository's bitwarden and google servers over HTTP as the vault and the mailbox:
claude mcp add anaf-spv \
-e ANAF_SPV_VAULT_MCP_URL=http://127.0.0.1:<vault port>/mcp \
-e ANAF_SPV_VAULT_MCP_TOKEN_FILE=<path to the vault token> \
-e ANAF_SPV_VAULT_ITEM=<item id> \
-e ANAF_SPV_MAIL_MCP_URL=http://127.0.0.1:<mail port>/mcp \
-e ANAF_SPV_MAIL_MCP_TOKEN_FILE=<path to the mail token> \
-e ANAF_SPV_MAIL_ACCOUNT=<mailbox label> \
-- npx -y @foreverworks/mcp-anaf-spv
docs/onboarding.md walks through the setup, with other vault and mail servers or with codes typed by hand. Over HTTP, one running copy holds one session for every client, so fewer code e-mails go out.
Configuration
Every variable is optional for the server to start: a tool that needs one says
which, and health lists what is missing.
Where the password comes from
A vault or a file. Prefer a vault: the password then lives in one place, and a rotation is a change there and nothing else.
| Variable | Required | Meaning |
|---|---|---|
ANAF_SPV_VAULT_MCP_URL |
one source | A vault MCP server over Streamable HTTP that holds the SPV login. |
ANAF_SPV_VAULT_MCP_TOKEN_FILE |
if the vault wants one | File holding its bearer token (ANAF_SPV_VAULT_MCP_TOKEN for a local run). |
ANAF_SPV_VAULT_MCP_COMMAND |
instead of the URL | A vault MCP server to spawn over stdio: a JSON array (["npx","-y","some-vault-mcp"]) or words. It gets this process's environment without the ANAF_SPV_* and MCP_* variables and CREDENTIALS_DIRECTORY. |
ANAF_SPV_VAULT_ITEM |
with a vault | The vault item holding the SPV login (its id). |
ANAF_SPV_VAULT_TOOL |
no | The tool that hands out a password. Default get_password (this repository's bitwarden server). |
ANAF_SPV_VAULT_ARGS |
no | Its arguments, a JSON template with {{item}}. Default {"id":"{{item}}"}. |
ANAF_SPV_VAULT_PASSWORD_FIELD |
no | Where the password is in its JSON answer, a dot path. Default password; . takes the whole answer. |
ANAF_SPV_VAULT_USERNAME_FIELD |
no | Where the user name is. Default username. |
ANAF_SPV_PASSWORD_FILE |
the other source | File holding the SPV password, mode 0600 (ANAF_SPV_PASSWORD for a local run). |
ANAF_SPV_USERNAME |
with the file | The SPV user name. With a vault, it overrides the item's. |
Where the e-mailed code comes from
A mail MCP server, read by a driver chosen at each sign-in
(The mail drivers). Without one, every sign-in waits for
complete_login.
| Variable | Required | Meaning |
|---|---|---|
ANAF_SPV_MAIL_MCP_URL |
for automatic sign-in | A mail MCP server over Streamable HTTP that reads the mailbox SPV sends codes to. |
ANAF_SPV_MAIL_MCP_TOKEN_FILE |
if it wants one | File holding its bearer token (ANAF_SPV_MAIL_MCP_TOKEN for a local run). |
ANAF_SPV_MAIL_MCP_COMMAND |
instead of the URL | A mail MCP server to spawn over stdio, as for the vault. |
ANAF_SPV_MAIL_DRIVER |
no | auto (the default with a mail server), google, text, or manual (the default without one). |
ANAF_SPV_MAIL_ACCOUNT |
no | Which mailbox, for a server that holds several (the google driver's account; {{account}} in a template). |
ANAF_SPV_MAIL_QUERY |
no | The search, in Gmail's syntax by default: from:Autentificare.SpatiuPrivat@anaf.ro subject:"Cod de siguranta" newer_than:1d. |
ANAF_SPV_MAIL_SEARCH_TOOL |
no | The text driver's search tool. Default: the server's tool that looks like a mail search. |
ANAF_SPV_MAIL_SEARCH_ARGS |
no | Its arguments, a JSON template with {{query}} and {{account}}, for a tool that needs more than a query. |
ANAF_SPV_CODE_WAIT_SEC |
no | How long a sign-in waits for the code to reach the mailbox. Default 90, at most 280 (SPV honours a code for 300). |
Everything else
| Variable | Required | Meaning |
|---|---|---|
ANAF_SPV_KEEPALIVE_MIN |
no | Load a page every N minutes while a session is stored, so SPV's idle timeout does not end it (each new sign-in e-mails a code). 0, the default, is off. |
ANAF_SPV_STATE_DIR |
no | Where session.json, pending.json, synced.json, contact.json, cui-only.json, declarations.json and formularespv-pf's forms-session.json live, all 0600; the default is in docs/configuration.md. It holds a live session: keep it readable by the server's user only. |
ANAF_SPV_DOWNLOADS_DIR |
no | The outbox downloads land in. Default <state dir>/downloads. |
ANAF_SPV_JAVA |
for declarations | The Java runtime ANAF's DUKIntegrator runs on (8 or newer). Default java. |
ANAF_SPV_DUK_DIR |
no | Where DUKIntegrator kits are built and kept, one per form and package. Default <state dir>/duk. |
ANAF_SPV_PORTAL |
no | The portal. Default https://pfinternet.anaf.ro. |
ANAF_SPV_FORMS_PORTAL |
no | Where a person files declarations, a sign-in of its own. Default https://formularespv-pf.anaf.ro. |
ANAF_SPV_STATUS_SERVICE |
no | ANAF's public status service for filings. Default https://stare.anaf.ro/StareD112. |
A file a tool takes (a declaration's XML, an attachment) goes in byte for byte,
by path only, from the server's file roots (MCP_FILE_ROOTS). Files come out
into the outbox at mode 0640, whole or not at all, and never over a file holding
other bytes (that one gets the next numbered name); a save_dir must be a
folder inside it. Under MCP_EGRESS_ALLOW, allow pfinternet.anaf.ro,
formularespv-pf.anaf.ro, static.anaf.ro, stare.anaf.ro and the vault and
mail servers' hosts. The variables every server shares are in
docs/configuration.md.
Getting the SPV account ready
- Check that the account signs in at pfinternet.anaf.ro with a user name and password. A person registers one on anaf.ro, online with an identity document or at an ANAF office.
- Find the mailbox SPV sends codes to (the address in SPV's "Editare
profil"): it holds "Cod de siguranta" mails from
Autentificare.SpatiuPrivat@anaf.ro. - Have the account holder put the user name and password in a vault item, or the password in a file only the server's user can read, rather than dictate it to an assistant.
- Give a mail MCP server read access to that mailbox, and point
ANAF_SPV_MAIL_MCP_URL(or_COMMAND) at it.
First run
- Ask your assistant to run SPV's
healthcheck (no network). A good answer saysautomatic_login: true, names the credential source and the mail driver, and lists noproblems. - Ask for your SPV messages (
list_messages). The first call signs in: SPV e-mails the account holder a code the mail driver reads, so they can ignore it. Messages come back newest first, each with akey. - If a tool answers
needs_login, pass the newest code tocomplete_loginwithin five minutes (Troubleshooting); with no mail server, every sign-in ends this way. - Download a message (
download_messagewith itskey), then runsync_messagesdaily: SPV drops most documents about two months after posting.
Tools
read changes nothing at ANAF (some save files to the outbox); write changes the session this server holds (a sign-in makes SPV e-mail a code) or sends ANAF something that is not a fiscal document; money files a declaration, and your client asks you before each call.
Session
| Tool | Access | What it does |
|---|---|---|
health |
read | Where the credential and the code come from, whether a session is stored, whether a sign-in waits for its code. No network. |
session_status |
read | The stored session, since when, when it last worked; probe loads a page to prove it alive. |
login |
write | Sign in now (any tool does it when it needs to); code_by_hand stops at the code. |
complete_login |
write | Finish a sign-in that waits for its e-mailed code. |
logout |
write | Sign out at SPV and forget the session. |
Messages
| Tool | Access | What it does |
|---|---|---|
list_messages |
read | The message list, newest first, filtered by type, dates, tax id, request or words; each with a key, when it was posted and when SPV will archive it. |
download_message |
read | One message's document, saved to the outbox with its SHA-256. |
sync_messages |
read | Save every message not saved before, oldest first: the archive SPV does not keep. |
posting_dates |
read | When SPV posted documents, from its alert e-mails: the lasting record once SPV archives them. |
Requests and the contact form
| Tool | Access | What it does |
|---|---|---|
list_document_types |
read | What the live request form offers, and the fields each type takes. |
request_document |
write | Ask SPV for a document; free, and its answer arrives as a message. |
await_reply |
read | Wait for the answer to a request (up to 120 seconds a call), and save it. |
submit_contact_form |
write | Write to ANAF through SPV's contact form; a dry run by default, the registration number when sent. |
Declarations
| Tool | Access | What it does |
|---|---|---|
validate_declaration |
read | ANAF's own validator for a D212, D100, D300 or D112 and its period: the errors verbatim, what it makes payable, and the filing PDF, checked to carry the XML. |
submit_declaration |
money | File a D212's PDF at formularespv-pf: the index de depunere, then SPV's RECIPISA. A D100's, D300's or D112's is checked, then refused before anything is sent. |
declaration_status |
read | A filing's state at ANAF and its signed receipt, by its number, from ANAF's public status page. No sign-in. |
Money
submit_declaration files a declaration in the account holder's name, which
cannot be withdrawn, only rectified. It is marked money in tools/list, and
your client asks you to approve each call (Claude Code does, in every
permission mode). It files only a PDF that validate_declaration made and
checked (by SHA-256, size and the embedded XML), only with confirm: true, and
the same bytes only once unless refile is true (a filing whose answer never
came counts). With MCP_CONFIRM=elicit the server asks you itself, in one line
read from the PDF: the form, the period and the taxpayer of the bytes that
will be sent.
anaf-spv: File D212 for reporting year 2025 for 2990101999999 at ANAF (D212_an-2025_0123456789ab.pdf, 123456 bytes, sha256 abcdef012345); a filed declaration cannot be withdrawn, only rectified
For a company's form the line also names the company and what the return makes payable:
anaf-spv: File D100 for 09.2026 for 99999909 (EXEMPLU TEST SRL) at ANAF, to pay 1850 lei (121: 1250, 150: 600) (D100_2026-09_fedcba987654.pdf, 34567 bytes, sha256 0123456789ab); a filed declaration cannot be withdrawn, only corrected by a D710
ANAF takes a D100, D300 or D112 only through e-guvernare.ro, signed with the
qualified certificate of someone entitled to file for the company, which this
server does not hold. So submit_declaration checks such a PDF, then answers
unsupported before anything is sent; the PDF is ready to be signed and
uploaded there.
Troubleshooting
needs_login with awaiting_code or code_not_received. No driver reads
the mail (manual, code_by_hand), or no new code came within
ANAF_SPV_CODE_WAIT_SEC. Pass the newest code to complete_login before the
answer's expires_at. If the driver should have read it, check the mailbox,
that the search shows the mails' text, and ANAF_SPV_MAIL_QUERY's syntax.
needs_login with pending_expired, or login_refused at step: code.
The code came after SPV's five minutes, or was wrong or stale. Call login
again and pass only the newest code.
login_refused at step: password. SPV refused the user name or password
(its words are in the message). Fix the credential source health names, then
call login. Do not retry in a loop: repeated failures can lock the account.
upstream. The vault or mail server (source) failed. Check that it
answers and that the vault holds ANAF_SPV_VAULT_ITEM. Without the mail
server, login with code_by_hand: true, then complete_login, still signs
in.
config naming a variable. Set it from Configuration
and call again; nothing reached SPV.
not_found. No message with that key, or no reply yet. list_messages
gives every key; a reply appears a minute or two after request_document.
refused. SPV refused a request, or formularespv-pf a file, in its own
words. For a request, list_document_types gives the fields each type takes.
wrong_document. SPV served a file named for another message; nothing was
saved. Download the same key again, and if it repeats, please report it.
unsupported. The declaration needs something this server does not have
(Money). Nothing was sent; the message says where the PDF can go.
api. Status 0: SPV was unreachable. Another status: SPV failed, as it
often does during maintenance at night and on weekends. Try again later.
drift. SPV's pages changed under this server. It needs updating; please
report the message.
How it works
Signing in
SPV's front door is an F5 access policy (/my.policy) with two forms: the
user name and password, then the e-mailed code, good for 300 seconds. The
session is then only cookies, stored 0600. When a page sends the client back
to /my.policy, the session has ended: the tool signs in and calls again, and
callers that meet it together cause one code, not two (one sign-in at a time
per state dir). The credential is read at each sign-in and never kept; neither
it nor a code appears in an answer, the audit line or the state dir.
The mail drivers
The mailbox is reachable only through an MCP server; the driver decides how to ask it.
google: a server shaped like this repository's google server (search_threads,get_thread). It takes the newest code in SPV's Gmail thread dated after the sign-in began (less a minute for clock skew) that was not there before.text: any mail server with a search tool. It notes the codes the search shows before the password is sent, then searches until a new one appears. It guesses the tool and its query argument fromtools/list, unlessANAF_SPV_MAIL_SEARCH_TOOLand_ARGSname them.auto:googlewhen the server has those two tools, elsetext.manual: every sign-in stops atawaiting_code.
A driver that sees no new code in time leaves the sign-in open for
complete_login, so a slow mailbox costs a typed code, never a lost sign-in.
Messages and downloads
A message is named by a key, a hash of what its row shows: SPV gives a
row no id, only a download link that is its position in the list last
rendered. So a download lists first, and nothing else reaches the portal until
the listing and its downloads end, from any process on the same state dir
(portal.lock); otherwise a reply posted in between would shift the rows and
save one request's PDF under another's metadata.
The file's name is checked against its row. A name carrying another row's
id fails as wrong_document, and nothing is saved; a name with no such id (a
decision's SV<number>.pdf) is taken.
The bytes are SPV's. SPV serves the same signed PDF every time, so a second
download finds the first. A row leaves the list for good at its Data Arhivare, about two months after posting (ten years for payments).
Posting dates
An act posted in SPV counts as communicated on the day it was made available,
which starts the terms to contest it (art. 47 alin. (15) of the Fiscal
Procedure Code); once its row is gone, SPV no longer shows that day. SPV's
"Documente noi SPV" alert e-mails, from Portal.ANAF@anaf.ro, are the lasting
record: posting_dates reads them through a mail server shaped like this
repository's google server.
Requests
The request form's own script decides which of year, month, registration
number and PUI each type takes; src/domain/requests.ts follows it. SPV
answers a registered request with a RASPUNS SOLICITARE message carrying its
id, usually within a couple of minutes. Some types on a person's list are made
only for a company, and SPV refuses them for a CNP. list_document_types
lists them under cui_only and request_document refuses them: "Notificare",
and any other type SPV has refused that way (cui-only.json). Types are not
probed, because some requests make real documents.
The contact form
SPV's contact form takes an identity, a category (with a subcategory for 1, 2 and 5), up to 1,500 characters and one attachment (pdf, tiff, rtf, zip, jpeg, jpg, gif or png, at most 10 MB). ANAF answers a sent message later in SPV.
cifis required and read back: the form preselects its first identity, the company for someone who also represents one.- Category 3 is the default ("Asistență și îndrumare de specialitate în domeniul fiscal"), answered on the merits; a petition (category 1) gets a redirect.
- A dry run is the default, showing the message as it would go: diacritics stripped, typographic marks made plain, the attachment's name and SHA-256.
- The same message is not sent twice without
again: true, even when a post's answer never came (contact.json). A page with no registration number isrefused: the form answers a bad post with itself.
Declarations
A past year's declaration has no web form (the Declarația unică app serves only the current year), and a company's returns are made with ANAF's own programs. So the server runs ANAF's "soft J" as ANAF documents it for Linux: DUKIntegrator with the validator and PDF jars of the package for the period (tried on invented D100, D300 and D112 returns).
- A kit is built once per form and package in
ANAF_SPV_DUK_DIR, each file's source and SHA-256 inkit.json. A period no package covers is refused, never validated with the nearest one. - D212 has a package per reporting year (the year filed in: 2023 declares 2022's income), and another year's fails it. D100, D300 and D112 have one package for many periods, so the newest also validates an earlier month.
- The verdict is read from DUKIntegrator's output, because it exits 0 either way; the errors are answered verbatim.
- The PDF embeds the XML, and is saved only once the embedded file is the validated XML byte for byte.
ANAF's packages
From the forms pages on static.anaf.ro (Declaratii_R/declaratie_unica.html
for D212, 100.html, 300.html, 112.html), with each validator's table of
periods and update5's versiuni.xml for versions. A test holds this table to
src/domain/declarations/kits.ts, where a new ANAF version is a new row.
| Form | Package | Periods | Version | Published | What it implements |
|---|---|---|---|---|---|
| D212 | D212_20200622.zip |
reporting year 2020 | 2020-06-22 | ||
| D212 | D212_20210512_1607.zip |
reporting year 2021 | 2021-05-12 | ||
| D212 | D212_20220301.zip |
reporting year 2022 | 2022-03-01 | ||
| D212 | D212_20231002.zip |
reporting year 2023 | 2023-10-02 | ||
| D212 | D212_20240708.zip |
reporting year 2024 | 2024-07-08 | ||
| D212 | D212Validator_01082025.zip |
reporting year 2025 | J13.0.1, PDF P10.0.0 | 2025-08-01 | |
| D100 | D100_14092026.zip |
01.2024 on | J21.0.8, PDF P6.0.0 | 2026-09-14 | OPANAF 57/2026 from 01.2026; obligation 704 from 08.2026 |
| D300 | D300Validator_11022026_2.zip |
05.2024 on | J12.0.1, PDF P9.0.0 | 2026-02-11 | OPANAF 174/2026, structure v12 from 01.2026 |
| D112 | D112_09102026.zip |
01.2025 on | J27.0.7, PDF P3.0.1 | 2026-10-09 | joint order 605/95/928/2314/2026, structure v7 from 07.2026 |
What a return makes payable
For a D100, D300 or D112, read from the XML by the form's own rules ("Anexa
validari"), in whole lei, summed exactly: a D100's suma_plata (to pay) and
suma_rest (to refund) per obligation code, its root totalPlata_A being only
a control sum; a D300's R41_2 (the VAT to pay) and R42_2 (the negative
balance, refunded when solicit_ramb is D, else carried forward); a D112's
A_plata per obligation code, whose sum must equal its totalPlata_A or the
file is refused before validation.
Filing a D212
submit_declaration signs in to formularespv-pf.anaf.ro with SPV's user name,
password and e-mailed code, in a session of its own (one sign-in at a time
across both: their codes come from one address). An accepted file gets an
index de depunere. SPV then posts a DECLARATIE (<index>_depunere.pdf) and a
RECIPISA within seconds; the tool downloads both and says whether the
DECLARATIE carries the index. The RECIPISA names the index de încărcare, an
unrelated number that ANAF's status service knows the filing by.
declarations.json remembers every PDF made and what became of it.
A filing's status
declaration_status reads ANAF's public status page,
stare.anaf.ro/StareD112, with no sign-in (the old www.anaf.ro address
resets the connection). For an index de încărcare and a tax id, it lists the
last 200 filings of the last three months: ANAF's state (processing,
not_registered, validation_errors with the errors on the receipt, or
valid), the registration number INTERNT-<index>-<year>, and the signed
receipt for 60 days, which the tool saves. It also takes the registration
number or the index de depunere.
scripts/smoke.ts drives the built server against the real SPV. It changes
nothing at ANAF unless given --request "<type>", which registers a free
document request.
Limitations
- Only a user name, password and e-mailed code. An account that signs in with a qualified certificate is not supported.
- Only a D212 is filed here. All four forms are validated, for the periods the packages above cover; a D100, D300 or D112 is filed through e-guvernare.ro (Money). A contestation is not offered.
declaration_statussees three months, as far back as ANAF's status page lists; an older filing goes through a "Duplicat Recipisa" request.- It does not read the documents. It answers SPV's metadata and the files;
reading a PDF is the caller's job (
pdftotextreads every one tried so far). - One account per server. A person who also represents a company sees both
in one list (
cif) when SPV links them to the same login.
Other servers for ANAF SPV
Searched 2026-10-15. ANAF publishes none, and its SPV web service (SPVWS2,
documented only by its 2019 sample client MfpAnaf/ClientSPV) takes only a
qualified certificate. One community server uses it; nothing else turned up on
npm, PyPI, GitHub or the MCP Registry:
anafpyon PyPI (Python, stdio): lists messages, saves a message's PDF, and requests reports and waits for them, signing in with a qualified certificate from the operating system's key store and a PIN on every login.
This one is for the accounts anafpy cannot serve, which sign in with a user
name, password and e-mailed code: it signs in by itself, saves each document
byte for byte with its SHA-256, keeps an archive, and over HTTP holds one
session for every client. With a qualified certificate, anafpy is the better
fit.