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]
With Claude Code:
Claude Desktop (Settings, Developer, Edit Config) and most other clients read the same command as JSON:
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
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:
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.
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.
Everything else
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
[email protected].
- 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
health check (no network). A good answer
says automatic_login: true, names the credential source and the mail
driver, and lists no problems.
- 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 a key.
- If a tool answers
needs_login, pass the newest code to complete_login
within five minutes (Troubleshooting); with no mail
server, every sign-in ends this way.
- Download a message (
download_message with its key), then run
sync_messages daily: 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
Messages
Requests and the contact form
Declarations
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.
For a company's form the line also names the company and what the return makes
payable:
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 from tools/list,
unless ANAF_SPV_MAIL_SEARCH_TOOL and _ARGS name them.
auto: google when the server has those two tools, else text.
manual: every sign-in stops at awaiting_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 [email protected], 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.
cif is 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 is refused: 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 in kit.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.
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_status sees 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 (
pdftotext reads 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:
anafpy on 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.