Skip to main content
Glama

taxme-mcp

Read and fill your Canton of Bern tax return (TaxMe / BE-Login). Drafts only — submission is gated.

npm   CI   node   licence


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 explicit confirm: 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 cache

    npx playwright install chromium is 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.

  1. Run taxme_login once. A visible Chromium window opens; complete the SwissID / AGOV login (incl. 2FA) yourself. The server waits up to ~8 minutes.

  2. 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), and

    • the full session — including session cookies — mirrored to ~/.taxme-mcp/state.json via Playwright storageState() after login and after every successful call.

  3. 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.

  4. When it does expire, any tool returns {"status": "login_required"} — just run taxme_login again.

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 3

The 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 the profile/ directory) contain live session cookies for your tax account. They are secrets. Both are in .gitignorenever commit or share them. Anyone with state.json can 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

TAXME_PROFILE

~/.taxme-mcp/profile

browser profile dir (holds the session — secret); empty = a throwaway profile

TAXME_STATE

~/.taxme-mcp/state.json

cached storageState json (session cookies — secret); empty = don't cache the session

TAXME_BROWSER

auto-detect

chrome, chrome-canary, edge, brave, chromium, or an absolute path

TAXME_CHROMIUM

legacy alias for TAXME_BROWSER

TAXME_BASE_URL

the real portal

portal base URL; exists so the test suite can drive a local fixture instead of a real taxpayer's account

TAXME_WAIT_SCALE

1

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 (0, 1] is ignored, and no scaled pause goes below 25 ms. Exists for the same reason as TAXME_BASE_URL — a fixture has nothing to rebuild

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.js

That 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

taxme_status

ok or login_required

taxme_login

open a visible window for the SwissID/AGOV login (waits up to ~8 min); caches the session. Only status: "ok" means the login went through — the login page is served on the portal's own address, so being there proves nothing, and the portal is asked afterwards whether anybody is actually logged in. A login that was never completed comes back as login_required and nothing is cached

taxme_account_statement

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 status: "unparsable" rather than an empty list that would read as nothing owed. A page that is not a Kontoauszug at all — a maintenance notice, an error page — is unparsable too: an empty list of open amounts is only ever reported when the statement itself is on the page

taxme_list_returns

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 status: "unparsable", because there is nothing to file must not be concluded from a page that was never asked the question

Navigate & edit a return

Tool

Args

Purpose

taxme_open_return

year (number)

open a return for editing; returns the menu sections (handles the edit popup tab). Only status: "ok" means it is open — the tab that appears is checked against the year you asked for, so an expired session comes back as login_required, a page that is no return as not_open, and a different case as wrong_year with the year the portal actually opened

taxme_menu

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. taxme_goto_section, taxme_results and taxme_submit_return say the same thing about the same page, rather than reporting a section they cannot find

taxme_goto_section

name (string)

click a menu section by name (substring); returns its fields — cut at 60 like taxme_get_fields, and saying so with truncated/total. Landing there is checked: TaxMe refuses to open a section while the form still has errors, and the page it hands back instead is reported as an error naming the section you are really on — not as that section's fields under the name you asked for. Which section that is is decided by the menu entry the breadcrumb names, not by the requested name turning up somewhere in it: these names nest both ways round ("Wertschriften" sits at the front of "Wertschriftenverzeichnis", "Einkünfte" at the end of "Übrige Einkünfte"), so a substring test says yes on the wrong page. A breadcrumb is a path and the page is the end of it, so it is read as a path: the entry it names is the one in the last of its steps that names any entry at all, and within that step the longest, since a longer name that fits contains every shorter one nested inside it. A breadcrumb that names no entry at all, or one that is a shortened form of the name that was clicked — an entry that name begins with — is ambiguous and is not read as a refusal; any other entry is a different page and is. A page with no menu on it at all is no return, and says so

taxme_get_fields

limit (number)

interactive fields on the current page (id, type, value, label, context, name for a radio — the group, i.e. the one question, its button belongs to — and locked when the portal has switched the field off); long forms are cut at limit (default 60) and the reply says how many were left out

taxme_snapshot

screenshot (bool)

breadcrumb + url of the current page; screenshot: true writes a PNG and returns its path

taxme_fill

values: [{target, value}]

set fields — target = field id or a label/context substring; value must be text, a number or true/false; text→typed, radio→option value or label, checkbox→true/false (ja/nein, 1/0, on/off are understood too). An option neither a radio group nor a dropdown has is refused with a list of the ones it does, rather than attempted. Every value is read back after it is set and compared with what was asked for, so a widget the portal re-renders and hands back unchanged — a radio left unanswered, a dropdown back on another option or on none — comes back as ok: false rather than as set. fields_after is cut at 60 too, and says so

taxme_click

label (string)

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 clicked names the button that was actually pressed. An empty or blank label is refused rather than resolved: a substring that is empty matches every element on the page, so it would press whichever button happens to come first

taxme_results

read the Ergebnisse / Steuerberechnung of the open return. Reaching that section is a precondition, and it is settled the way taxme_goto_section settles it — by the menu entry the breadcrumb names — before a word of the page is read as a calculation: a portal that refused to open the section comes back as an error naming the page you are actually on, not as that page's text. That check stands down on a breadcrumb naming no entry of this menu, and where it has settled nothing the page itself has to say the section is open before an amount is read off it: the calculation is only ever read from a line the word heads — never from the left-menu entry, which is on every page of the return — and, without a breadcrumb naming the entry, only when that line is a heading of the page (a heading tag, a legend, a caption, a title/header panel) rather than a sentence beginning with the same word. A refusal banner can be written either way round in German, so word order is the portal's phrasing and not evidence about which page is open. And if the click landed somewhere carrying no menu — an expired session lands on the login form — that is reported as no return being open, not as a page with no calculation on it

Submit (gated)

Tool

Args

Purpose

taxme_submit_return

confirm (bool)

⚠️ DANGER — irreversible final submission (Abschluss → Steuererklärung einreichen). Without confirm: true it only opens the Abschluss page and returns a dry-run naming in would_click the one button a confirmed call would press; nothing is submitted. Reaching that page is a precondition — with no submit button on it the call is refused instead of pressing whatever the current page offers. A page that already reports the return as filed (already_submitted) is refused as well, confirm: true and all: a confirmation that was on the page beforehand cannot prove anything about a second click. So is a page whose text could not be read at all (page_unreadable), for the same reason one step further back — a read that failed is not a page that said no — the submit button is then named as not_clicked, because would_click is a promise about the confirmed call and that call presses nothing. And a page carrying no menu is no return at all — the Abschluss entry links to the edit view, and a session that died in the meantime is bounced to the login form — which is said in those words instead of reporting the Abschluss page as merely not open, the shape a portal refusing it over a form error has. Only confirm: true actually files the return.

A typical edit session: taxme_logintaxme_list_returnstaxme_open_return {year}taxme_goto_section {name}taxme_get_fieldstaxme_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 JS click() + a dispatched change event — plain .check() on the input doesn't reliably trigger JSF's listeners. In taxme_fill a radio value may 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, so taxme_fill refuses one (locked: "disabled") instead of setting it in JavaScript and reporting a value the portal will never receive.

  • Amounts are whole francs. Enter 12000, not 12000.00 / 12'000. The form drops the centimes silently, so taxme_fill reads every value back and returns a warning when 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_return waits 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). Run taxme_login and complete SwissID/AGOV in the window that opens. This is normal and expected periodically.

  • The login window doesn't appear / login can't completetaxme_login runs 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 point TAXME_CHROMIUM at an existing Chromium/Chrome-for-Testing binary. The server auto-detects the Playwright cache (~/Library/Caches/ms-playwright/chromium-* on macOS).

  • Everything says login_required even right after logging in — your state.json / profile may be stale or corrupt. Delete ~/.taxme-mcp/state.json (and, if needed, ~/.taxme-mcp/profile/) and run taxme_login again.

  • Selectors broke after a portal update — TaxMe changed its markup. Use taxme_snapshot { "screenshot": true } and taxme_get_fields to 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-tags

The 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-mcp

Provenance 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 changed

npm 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

Install Server
A
license - permissive license
A
quality
A
maintenance

Maintenance

Maintainers
Response time
2dRelease cycle
3Releases (12mo)
Commit activity

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

View all related MCP servers

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).

View all MCP Connectors

Latest Blog Posts

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