Hacking the Italian Tax System (Legally): How I Built an AI Agent for Tax Returns in 2 Hours
📥 Download Tax-MCP: tax-mcp.mop.dev
It all started this morning. I was sitting at my desk, armed with a steaming moka pot, my SPID credentials ready for use, and a great deal of goodwill. I was ready to face the final boss of every freelance and Italian citizen: DIY tax returns on the Agenzia delle Entrate portal.
If you've ever done it, you know the thrill. That user interface that seems stuck in 2005, infinitely nested submenus, and the constant, creeping fear that entering a number in the wrong box could trigger the wrath of a tax audit. After getting lost for the third time between "Quadro RP", "Rigo RN13", and a 150-page ministerial instruction PDF written in pure ancient bureaucratese, I had an epiphany.
We are in the era of Artificial Intelligence, I chat with advanced language models every day to write complex code; why on earth am I manually calculating VAT separation or searching with a flashlight for veterinary expense deductions?
So I closed the Agenzia delle Entrate tab, took a deep breath, and opened the terminal. I wanted an AI assistant that would file my taxes for me. But, as I soon discovered, the road between a brilliant prompt and functioning software was littered with legal and technical mines.
The Problem: Law, Privacy, and "Context Bloat"
The initial instinct of every developer facing a boring problem is simple and brutal: let's whip up a Python script with Puppeteer or Selenium, perform brutal scraping of the Agenzia delle Entrate site DOM, spit the HTML into an LLM, and have it click the buttons as if it were a very fast human.
Terrible, terrible idea.
- Criminal Risk (Art. 615-ter): Public Administration servers are (rightly) protected. Bypassing CAPTCHAs, ignoring anti-bot systems, or automating a login via SPID with unauthorized scripts constitutes the crime of "unauthorized access to a computer system". It doesn't matter if the data is yours: to the State, you are an intruder picking the lock.
- Privacy Risk (GDPR): Sending your tax data (annual income, medical expenses, tax code, family situation) to the cloud APIs of overseas companies like OpenAI or Anthropic is, from a privacy perspective, assisted suicide. You are giving away your financial DNA to servers over which you have no control.
- "Context Bloat" and Hallucinations: The ministerial instructions for the tax return (the various PF1, PF2, PF3 files) are enormous. Inserting hundreds of pages of PDF directly into an LLM prompt saturates the context window. Not only does it cost you a fortune in tokens, but it causes the notorious "Lost in the Middle" phenomenon: the model forgets the rules halfway through the document and starts inventing non-existent tax deductions. A disaster foretold.
I needed a completely different architecture. Not a rebel bot navigating the internet in secret, but a local, isolated, legal, and model-agnostic "sandbox".
The Architectural Pivot: The MCP Server in Rust
I decided to build the heart of the project using the Model Context Protocol (MCP). If you aren't familiar with it, think of MCP as the USB-C for Artificial Intelligence: a universal standard that allows LLMs to interface securely with local tools, file systems, and databases.
My solution embraces the Bring Your Own Model (BYOM) philosophy: I, as the developer, provide only the software "bridge", the local infrastructure written in Rust. You, as the user, connect the LLM engine of your choice. If you care about your privacy, you can download Gemma 4 via Ollama and run everything on your computer. Zero data sent over the internet. Privacy by Design.
The technology stack I chose makes no concessions:
- Backend: Pure Rust with Axum to concurrently handle the MCP protocol (via stdio) and an HTTP/WebSocket server for the interface.
- Database: SQLite managed with sqlx. No Docker containers for the user to install; the entire database lives within a single
istruzioni.dbfile. - Frontend: Fast and secure WebAssembly thanks to Yew.
Challenge 1: Taming Ministerial HTML
The Agenzia delle Entrate web forms are a nightmare for parsers. They consist of thousands of lines of chaotic HTML, infinite CSS classes (stuff like <div class="col-md-3-margin-top-weird">) and ARIA attributes designed for accessibility but illegible for a machine searching only for data.
Instead of driving the LLM crazy by making it read that disaster, I split the problem. I wrote a quick Python script with BeautifulSoup to extract only the pure business logic offline: field names, input types (radio, select, text), and associated labels.
Result? A monstrous 7800-line div became an elegant 89KB minified JSON.
At this point, the magic: my Yew frontend loads this JSON at startup and, thanks to WebAssembly, regenerates the entire user interface dynamically in the browser, creating html!{} components on the fly without any page reload. If the Agency changes the order of the forms next year, I will only need to update the JSON files. The Rust code won't even be touched.
Challenge 2: Asynchronous Concurrency and the "Stdio" Disaster
The standard MCP protocol communicates with the LLM using the terminal's stdin and stdout channels. This means that if you do a trivial println!("Utente connesso!") in your backend code for a bit of debugging, that text ends up in the data stream destined for the LLM, which instead expects a strict JSON-RPC format; when it sees your log string, it panics and the entire MCP connection drops.
I had to structure the asynchronous ecosystem (tokio) surgically. I confined the Axum server (which serves the Web UI and WebSockets) to a separate background thread, and I strictly redirected every single logging library (tracing) to write exclusively to stderr.
Once this snag was resolved, I implemented the MCP tool fill_field. The interaction is incredibly smooth: the LLM decides to fill a line, Axum intercepts the command, executes the UPDATE on SQLite and sends a broadcast via WebSocket to the Yew frontend. The user sitting in front of the screen literally sees the form fields fill themselves in real time. It's like having a ghost accountant (remembering always that this does not replace the opinion of an expert) typing on your keyboard.
Challenge 3: The Internal Search Engine (The End of the PDF Nightmare)
To solve the problem of bureaucratic mega-PDFs, I avoided complex external solutions and implemented a lightweight and completely offline RAG (Retrieval-Augmented Generation) engine, leveraging a native but little-known extension: SQLite's FTS5 (Full-Text Search).
I wrote a super-optimized Python script (costruisci_kb.py) to "slice" the Agency's PDFs (previously converted to Markdown, the preferred language of LLMs). The trick was using granular regexes (\\d+\\.\\s+) to split the documents exactly at the start of each new "Quadro" or "Rigo" (e.g., "Quadro E", "Rigo RN13"), creating a unique slug for each block.
Now, the MCP server exposes the tool search_instructions. When the LLM is unsure if contact lenses are deductible, it doesn't hallucinate an answer. It calls the tool passing the query "detrazione lenti a contatto", my local SQLite grinds the full-text search in milliseconds and returns to the agent only the exact 30 lines of the official manual needed to calculate the amount. Zero context bloat, maximum ministerial precision.
Challenge 4: Double Validation (Better not to trust)
Golden rule of prompt engineering applied to the real world: LLMs make mistakes. Often and willingly, especially with rigid formats. If you ask an LLM to enter a Codice Fiscale, it might make up an obviously fake MRRMRR99M99M999X or forget the final check character.
To prevent the entry of garbage data, I leveraged one of the greatest superpowers of the Rust ecosystem: code sharing. I created a shared Rust crate used by both the backend (Axum) and the frontend (Yew compiled to WASM). This crate contains all the validation logic via strict Regex (Codice Fiscale format, VAT number, phone numbers).
Validation occurs on two levels:
- Frontend: If the human makes a typing mistake, Yew immediately shows the red border without bothering the server.
- Backend (The Guardian): If the LLM tries to enter incorrect data via the MCP tool, Axum intercepts the failed Regex, blocks the database transaction, and returns a descriptive text error directly to the LLM ("Error: The Codice Fiscale must be 16 valid alphanumeric characters"). The LLM reads the error, understands its mistake, corrects itself, and attempts a new call. It is a self-correcting system that is fascinating to watch in the logs.
The Epilogue: The Legal "Cover-Your-Back"
One final question remained: how do we completely bypass the obstacle of unauthorized access that I mentioned at the beginning, while legally protecting myself in case someone uses my software and gets a fine from the Agenzia delle Entrate?
The answer is as simple as it is unassailable: my software sends nothing to the State. It never interfaces, in any way, with government servers.
Once the local interaction between the user and the AI is concluded and both are satisfied with the calculations, the user presses a large and reassuring button at the bottom of the page: "Generate Completion Guide".
At that point, Yew and yew-router hide the complex forms and generate a very clean summary "view". The page shows only the fields that were actually filled in, neatly grouped by Sections (Quadri), with a convenient "Copy" button next to each value (leveraging the browser's navigator.clipboard API).
It will be the human user, and only the human user, who uses their browser to access the Agenzia delle Entrate website, opens the menus, and manually pastes the data provided by the guide. No bots, no scraping, 100% total human validation. The tax responsibility for the submission remains exactly where the law stipulates it should be: with the taxpayer who clicks "Submit Declaration".
Conclusion
What started this morning as a moment of pure frustration in front of yet another incomprehensible bureaucratic form transformed into a frantic 2-hour race, becoming one of the most fun and technically stimulating projects in my portfolio.
It combined the elegance of WebAssembly for ultra-fast interfaces, the solidity of asynchronous concurrency in Rust, the frontier of LLM integration with the MCP standard, and a healthy, generous dose of legal engineering to survive in the Italian ecosystem. Developing tools to solve one's own real-world problems remains the purest and most satisfying form of software engineering.