taxme-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@taxme-mcplist my tax returns"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
taxme-mcp
Read and fill your Canton of Bern tax return (TaxMe / BE-Login). Drafts only — submission is gated.
Unofficial, and inherently fragile. This drives a web portal with a real browser because the service offers no retrieval API. Portal updates break selectors without warning, and a broken selector means a failed run rather than a wrong result. It is published because it is useful, not because it is guaranteed — pin a version, read the errors, and expect to update. Use it for your own account and respect the provider's terms of service.
An MCP server for the Canton of Bern tax portal TaxMe / BE-Login (belogin.directories.be.ch). From any MCP client (Claude Code, Claude Desktop, …) you can read your account statement and your tax returns, and navigate and fill a return — open it, walk the menu sections, read the fields on a page, set values, click buttons and read the tax calculation.
What it is (and the SwissID / AGOV constraint)
BE-Login has no public API and authenticates through SwissID / AGOV (the Swiss government login, incl. 2FA). There is no headless way in: the login is an interactive browser flow you have to complete yourself. So this server drives the real portal with Playwright browser automation. Two consequences:
You log in once, in a visible browser window (
taxme_login). Everything after that runs headless against the session you established.It is inherently fragile. Portal releases can change page structure and break selectors; the server uses text/URL-based selectors with fallbacks, but expect occasional breakage after a TaxMe update.
It is full-featured: besides reading, it can open a return, walk the menu, read fields, fill them (text / radio / checkbox), click buttons (Neuen Eintrag erfassen, Speichern, Nächste Seite …) and read the results.
Safety: the server only fills drafts. The final submission (
taxme_submit_return) is gated behind an explicitconfirm: true; without it you get a dry-run of the Abschluss page and nothing is submitted.
Private use, for your own BE-Login account only. Respect the portal's terms of use.
Related MCP server: elster-mcp-server
Prerequisites
Node.js ≥ 20.19 (
node --version).Install dependencies and a Chromium build for Playwright:
git clone git@github.com:sapn95/taxme-mcp.git cd taxme-mcp npm install npx playwright install chromium # downloads a Chromium into the Playwright cachenpx playwright install chromiumis required unless a Playwright Chromium is already cached on the machine. See Troubleshooting if the browser can't be found.
Session model — log in once, stay logged in
The whole point of this server is that you don't re-login every time.
Run
taxme_loginonce. A visible Chromium window opens; complete the SwissID / AGOV login (incl. 2FA) yourself. The server waits up to ~8 minutes.The session is cached two ways so it survives server restarts:
a persistent Chromium profile in
~/.taxme-mcp/profile(keeps the trusted-device state, so AGOV doesn't re-prompt 2FA), andthe full session — including session cookies — mirrored to
~/.taxme-mcp/state.jsonvia PlaywrightstorageState()after login and after every successful call.
On startup the server re-seeds a fresh browser context from
state.json, so the AGOV session keeps working across restarts until it genuinely expires.When it does expire, any tool returns
{"status": "login_required"}— just runtaxme_loginagain.
So the normal flow is: taxme_login once, then use the read/edit tools freely;
re-login only when you actually get login_required.
flowchart TD
C["MCP client"] -->|"stdio JSON-RPC"| S["taxme-mcp"]
S --> Q{"Is the cached<br/>session still good?"}
Q -->|"yes"| DRIVE["Playwright drives<br/>the TaxMe portal"]
Q -->|"no"| LR["every tool answers<br/>login_required"]
LR --> LOGIN["taxme_login<br/>visible window, waits ~8 min"]
LOGIN --> ME["you complete SwissID / AGOV yourself —<br/>2FA, or one Touch ID on an<br/>installed, signed browser"]
DRIVE --> R[("BE-Login → TaxMe")]
ME --> R
DRIVE -.->|"after every successful call"| ST
ME -.->|"after login"| ST
subgraph disk["on disk — live cookies for your tax account"]
P["profile/<br/>keeps the device trusted,<br/>so AGOV stops re-prompting 2FA"]
ST["state.json<br/>Playwright storageState"]
end
disk -.->|"re-seeded into a fresh<br/>context on startup"| Q
classDef secret fill:#fdecea,stroke:#c0392b
classDef you fill:#fff4e5,stroke:#d9822b
class P,ST secret
class ME,LOGIN you
style disk fill:#fbfbfb,stroke:#999,stroke-dasharray: 4 3The two red boxes are the reason the section below says what it says: both hold live cookies for your tax account, and anyone holding them is you until the session expires.
Security:
state.json(and theprofile/directory) contain live session cookies for your tax account. They are secrets. Both are in.gitignore— never commit or share them. Anyone withstate.jsoncan act as you on the portal until the session expires. Delete them to force a clean logout.
Override the locations with env vars if you want:
Variable | Default | Purpose |
|
| browser profile dir (holds the session — secret); empty = a throwaway profile |
|
| cached |
| auto-detect |
|
| — | legacy alias for |
| the real portal | portal base URL; exists so the test suite can drive a local fixture instead of a real taxpayer's account |
|
| scales the fixed pauses that wait for TaxMe to rebuild the page, and nothing else — no timeout is derived from it. Only shortens: anything that is not a finite number in |
A variable that is set, even to the empty string, is authoritative: an empty
TAXME_STATE means no session cache, not "fall back to the default one".
Anything else would make "no session" impossible to express — and would let a
test that thought it was isolated quietly open the real account.
Which browser, and why it decides how you log in
BE-Login authenticates through SwissID/AGOV, and the browser is not a matter of
taste: Playwright's bundled Chromium reports
isUserVerifyingPlatformAuthenticatorAvailable() === false, so the portal never
offers a passkey and falls back to password plus SMS. An installed, signed
browser reports true and can reach the macOS platform authenticator, which
turns the same login into one Touch ID confirmation.
The server therefore prefers an installed system browser — chrome,
chrome-canary, edge, brave, in that order — and only falls back to the
bundled Chromium. Override with TAXME_BROWSER (a key from that list, chromium,
or an absolute path); TAXME_CHROMIUM still works as an alias.
For the underlying detail, including why a software passkey cannot be used at all, see the write-up in private-routines/reference/swissid-login.md.
Register a passkey once in Safari or Chrome under your SwissID account settings; after that the portal offers it ahead of the password.
Register in Claude Code
From the repo directory, register the server for your user (use an absolute
path to index.js):
claude mcp add taxme --scope user -- node /absolute/path/to/taxme-mcp/index.jsThat writes an entry into ~/.claude.json. Equivalent manual snippet:
{
"mcpServers": {
"taxme": {
"command": "node",
"args": ["/absolute/path/to/taxme-mcp/index.js"]
// optional:
// "env": { "TAXME_STATE": "/custom/path/state.json" }
}
}
}Restart Claude Code (or reconnect the MCP server), then run the taxme_login
tool once to establish the session.
Other MCP clients (Claude Desktop, etc.) take the same command / args in
their own MCP config.
Tool reference
Read / session
Tool | Args | Purpose |
| — |
|
| — | open a visible window for the SwissID/AGOV login (waits up to ~8 min); caches the session. Only |
| — | open amounts (CHF) per tax year — Kantons-/Gemeindesteuern, direkte Bundessteuer, Gemeindeabgaben. A statement prints a due date under every claim, and the 2024 assessment falls due in 2025, so an amount is only reported under a year the page itself puts it under; when none can be, you get |
| — | tax returns (Steuererklärungen) with status (In Bearbeitung / Quittiert …). An empty list is only reported when the case list itself is on the page: a page that is not one — a maintenance notice, an error page — and a list whose rows carry no year we could tie them to are both |
Navigate & edit a return
Tool | Args | Purpose |
|
| open a return for editing; returns the menu sections (handles the edit popup tab). Only |
| — | left-menu sections + status of the open return. The menu is on every page of a return and on no other page, so a page carrying none is no return: that comes back as an error naming where the browser is, not as an empty list of sections. |
|
| click a menu section by name (substring); returns its fields — cut at 60 like |
|
| interactive fields on the current page ( |
|
| breadcrumb + url of the current page; |
|
| set fields — |
|
| click a button/link by visible text (Neuen Eintrag erfassen, Speichern, Nächste Seite, Vorherige Seite, Ändern …); an exact label wins, a substring is the fallback, and |
| — | read the Ergebnisse / Steuerberechnung of the open return. Reaching that section is a precondition, and it is settled the way |
Submit (gated)
Tool | Args | Purpose |
|
| ⚠️ DANGER — irreversible final submission (Abschluss → Steuererklärung einreichen). Without |
A typical edit session: taxme_login → taxme_list_returns →
taxme_open_return {year} → taxme_goto_section {name} → taxme_get_fields →
taxme_fill {values} → taxme_click {label: "Speichern"} → taxme_results.
JSF quirks handled
TaxMe is a JSF (JavaServer Faces) app with a few sharp edges the server already smooths over, so you don't have to:
Radio buttons are set by clicking the associated
<label>, falling back to a JSclick()+ a dispatchedchangeevent — plain.check()on the input doesn't reliably trigger JSF's listeners. Intaxme_filla radiovaluemay be the option value or its visible label. The button is read back afterwards: a JSF group is re-rendered by the server when it hears the change and can come back unanswered, and an answer that did not stick must not be reported as given.Switched-off widgets. A section can be Ausgeschaltet aufgrund Ihrer Eingaben, and its inputs are then
disabled. The browser never submits a disabled input, sotaxme_fillrefuses one (locked: "disabled") instead of setting it in JavaScript and reporting a value the portal will never receive.Amounts are whole francs. Enter
12000, not12000.00/12'000. The form drops the centimes silently, sotaxme_fillreads every value back and returns awarningwhen the field ended up holding something other than what it was given — a wrong number in a tax return should not look like a success.The edit popup tab: opening a return spawns a new browser tab;
taxme_open_returnwaits for and switches to that popup, and the other edit tools always target the live edit tab automatically.JSF component ids are unstable across releases, so selectors are text/URL-based with fallbacks.
Troubleshooting
{"status": "login_required"}— the session expired (or you never logged in). Runtaxme_loginand complete SwissID/AGOV in the window that opens. This is normal and expected periodically.The login window doesn't appear / login can't complete —
taxme_loginruns headed on purpose (AGOV needs interaction). It must run on a machine with a display; it won't work over a headless/SSH session with no desktop. Everything else runs headless.Chromium not found — install it with
npx playwright install chromium, or pointTAXME_CHROMIUMat an existing Chromium/Chrome-for-Testing binary. The server auto-detects the Playwright cache (~/Library/Caches/ms-playwright/chromium-*on macOS).Everything says
login_requiredeven right after logging in — yourstate.json/ profile may be stale or corrupt. Delete~/.taxme-mcp/state.json(and, if needed,~/.taxme-mcp/profile/) and runtaxme_loginagain.Selectors broke after a portal update — TaxMe changed its markup. Use
taxme_snapshot { "screenshot": true }andtaxme_get_fieldsto see the current page, and open an issue.Only Canton of Bern. Other cantons use different portals; this server is TaxMe-specific.
Releasing
Published from CI with npm Trusted Publishing (OIDC) — there is no npm token anywhere: no secret to store, rotate or leak. npm recommends this over an automation token, and is restricting tokens that bypass 2FA.
One-time setup per package, on npmjs.com -> the package -> Settings -> Trusted Publisher:
Field | Value |
Organization or user | sapn95 |
Repository | taxme-mcp |
Workflow filename | release.yml |
Allowed actions | npm publish |
The workflow filename must match exactly. That is deliberate: it stops any other workflow in the repo from publishing under your name.
Then every release is one command:
npm version patch && git push --follow-tagsThe tag triggers the release workflow: it upgrades npm (trusted publishing needs
= 11.5.1 and Node >= 22.14), refuses a tag whose version disagrees with package.json, runs the gate, and publishes with a signed provenance statement.
If the publish fails with 404
npm notice publish Signed provenance statement ... from GitHub Actions
npm error 404 Not Found - PUT https://registry.npmjs.org/taxme-mcpProvenance was signed, so OIDC worked — the registry simply does not accept this workflow as a publisher yet. That means the trusted publisher is not configured, or the repository / workflow name does not match. npm answers 404 rather than 403 so as not to reveal whether the package exists. It is not a credential problem: there is no credential, by design.
Checks
npm run gate # syntax, lint, smoke, hygiene, tests with coverage floors
npm test # the test suite alone
npm run coverage # the suite plus the enforced coverage thresholds
npm run mutate # mutation-test the lines this branch changednpm run gate is what CI runs. It needs a Chromium
(npx playwright install chromium), because the tests drive the real automation
rather than a mock of it.
How the portal is tested without a portal. test/fixture-portal.mjs is a
local HTTP server that serves the DOM the automation depends on, including the
traps that made it what it is: a radio whose <input> is invisible so only its
label can be clicked, a second radio group with no label that swallows the click
and commits only on a dispatched change, a checkbox the portal has switched off
that can still be ticked from JavaScript but never submitted, element ids with
colons in them, an amount field that drops the centimes, a return that opens in a
second tab (and one that comes back as the login page, as a maintenance page, or
as a different tax year), menu entries and buttons that are prefixes of each
other, a radio group the server re-renders and hands back unanswered, a section
the portal refuses to open so that the overview you were on comes back with a
banner, a Kontoauszug whose due dates print years that are not headings (and a
Kontoauszug link that answers with no Kontoauszug on it at all), and a
session cookie whose absence shows up as a perfectly normal-looking page saying
Angemeldet als: Benutzer. TAXME_BASE_URL points the server at it, so no test can reach the
real BE-Login. Every assertion about a click or a submission is made against
what the fixture received, not against what the server reported.
The submission gate has its own file: test/safety.test.mjs calls
taxme_submit_return without confirmation, with confirm:false, and with every
value that looks like consent but is not the boolean ("true", 1, "yes",
["true"]), and asserts each time that the fixture received nothing. The last
test in that file confirms with confirm:true and checks that a submission
does arrive — otherwise the six tests above it would prove nothing.
The smoke test completes the MCP handshake over stdio and asserts the things that have actually broken here — a server version drifting from package.json, a tool in the dispatcher but missing from the tool list (or advertised and unhandled), a required property absent from a schema, and descriptions too thin to choose a tool from. The hygiene scan refuses secrets, tracked session files and personal identifiers.
Roughly 90% of index.js is covered. The rest is mostly the callbacks handed to
Playwright's evaluate(): they execute inside Chromium, so Node's coverage never
sees them run — they are exercised, just not counted.
Mutation testing
npm run mutate asks a different question from everything above: not "do the
tests pass" but "would they notice if a guard were removed". StrykerJS deletes
one piece of behaviour at a time and reruns the suite; whatever survives is
something no assertion is watching.
It found eleven real gaps in the sibling pingen-mcp
after model review rounds had stopped turning anything up. Here it runs over
the lines a branch changed rather than the whole file, because this suite
drives a real browser once per mutant and a full pass costs hours.
npm run mutate:all does the whole file if you have the time;
stryker.config.json explains every setting that is not a default, including
why incremental mode is off.
License
MIT © sapn95
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- Alicense-qualityCmaintenanceAn MCP server for interacting with Kanton Luzern tax declarations on eSteuern.LU, enabling field catalogue search and live declaration editing.Last updatedMIT
- AlicenseAqualityCmaintenanceAn MCP server that allows AI assistants like Claude to automate interactions with the German tax portal ELSTER via Puppeteer, including form filling, submission, and session management.Last updated14456MIT
- Alicense-qualityDmaintenanceMCP server exposing all major Swiss official public APIs as native tools for any MCP-compatible AI agent.Last updated29MIT
- AlicenseAqualityCmaintenanceAn MCP server that automates filling out BLok Berichtsheft (training reports) via browser automation using Playwright.Last updated6MIT
Related MCP Connectors
TaxSort — Tollbooth-monetized MCP server for personal tax transaction classification
MCP server exposing the Backtest360 engine API as tools for AI agents.
MCP server for Brazilian Federal Senate open data (legislative, administrative, e-Cidadania).
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/sapn95/taxme-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server