epost-mcp
The epost-mcp server provides an MCP interface to the Swiss ePost digital letterbox, using the public API where available and browser automation as a fallback.
Session & Authentication: Check login status, perform SwissID login (including passkey support) with cached sessions, and view resolved server configuration.
Letter Reading & Searching: List inbox letters with details (sender, date, status), get detailed info on a specific letter, full-text search across inbox and storage, check unread count, and mark letters read/unread.
Downloading: Download individual letters or all letters as PDFs, and download thumbnail images.
Storage & Archiving: Archive letters non-destructively into storage folders; list storage folders and documents; read storage document metadata and optionally download PDFs; create new folders; move documents between folders with optional removal from source; unfile documents from folders (if multiple memberships).
Trash Management: List deleted letters with days until permanent deletion, restore letters, and delete letters (with confirmation required).
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., "@epost-mcplist all my scanned letters"
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.
epost-mcp
Read, download and archive your Swiss ePost digital letterbox — over the documented public API, with browser automation as a fallback.
Unofficial. Not affiliated with ePost or Swiss Post. It speaks their documented public API where it can and falls back to driving the web portal where it cannot — that fallback is inherently fragile, since portal updates break selectors without warning. Use it for your own letterbox and respect the provider's terms of service.
An MCP server for the Swiss ePost digital letterbox (app.epost.ch). It lets an MCP client (Claude Code, Claude Desktop, …) list and download your scanned letters and do basic housekeeping in the ePost Storage area — folders and moving documents.
ePost does publish a public API for the Digital Letterbox, and a private tenant can use it — see Transport below. That is the preferred path: no browser, no session, and letters arrive with a real sender description. Browser automation via Playwright remains for the few things the API does not cover.
How a call gets answered
Two ways in, and the server picks per call rather than per session — so the
same conversation can read a letter over the API and archive it in the browser
without you arranging anything. EPOST_TRANSPORT pins that choice if you would
rather it did not move.
flowchart TD
C["MCP client<br/>(Claude Code, Claude Desktop, …)"] -->|"stdio JSON-RPC"| S["epost-mcp"]
S --> T{"EPOST_TRANSPORT"}
T -->|"api — pinned"| A
T -->|"browser — pinned"| B
T -->|"auto (default)"| D{"Does this call<br/>reach for a page?"}
D -->|"no"| A["REST call to api.epost.ch"]
D -->|"yes — and only then<br/>is a browser launched"| B["Playwright drives<br/>app.epost.ch"]
K1["account password<br/>keychain: epost-mcp-api-password"] -->|"password grant"| KC["Keycloak token"]
KC -.->|"Authorization: Bearer"| A
K2["API key<br/>keychain: epost-mcp-api-key"] -.->|"X-API-KEY"| A
A --> E[("ePost<br/>Digital Letterbox")]
B --> E
subgraph disk["on disk — a live letterbox session, and secret"]
P["Chromium profile<br/>~/.epost-mcp/profile"]
ST["storageState<br/>~/.epost-mcp/state.json"]
end
disk -.->|"seeds the context"| B
B -.->|"state.json re-saved after<br/>every successful call"| ST
L["epost_login<br/>visible window, one Touch ID"] -.->|"fills state.json once"| ST
classDef pin fill:#fff4e5,stroke:#d9822b
classDef store fill:#fdecea,stroke:#c0392b
class T,D pin
class P,ST store
style disk fill:#fbfbfb,stroke:#999,stroke-dasharray: 4 3A pin is enforced both ways. Pinned to api, a browser-only tool is refused
rather than quietly redirected — the point of pinning is knowing what ran.
Related MCP server: mcp-chrome-server
Prerequisites
Node.js ≥ 20.19
A Chromium managed by Playwright
A Swiss ePost account with the Scanning-Service, reachable via SwissID
git clone https://github.com/sapn95/epost-mcp.git
cd epost-mcp
npm install
npx playwright install chromium # downloads the browser Playwright drivesSession / login model
The hard part of ePost automation is the login. This server keeps you logged in so you do not re-authenticate on every call or every restart:
The SwissID/ePost session is cached as a Playwright storageState file at
~/.epost-mcp/state.json(49 cookies incl. the KLARA/Keycloak SSO cookies).On startup the server loads that file, so a previously logged-in session is reused across server restarts.
After a successful
epost_loginand after every successful tool call, the server re-savesstate.json, keeping the cached session fresh.epost_loginopens a visible (headed) browser window so you complete the SwissID login (incl. 2FA) yourself. Every other tool runs headless.SwissID sessions are short-lived. When the session expires, tools return
login_required— just runepost_loginagain.
First-time / after-expiry flow:
Call
epost_status. If it returnslogin_required……call
epost_login. A browser window opens on app.epost.ch. Complete the SwissID login until you see your dashboard / the letterbox. The session is then cached to~/.epost-mcp/state.json.Use
epost_list_letters,epost_download_letter, etc. headlessly.
Security:
~/.epost-mcp/state.jsoncontains live session cookies for your ePost/SwissID account. Treat it like a password. It is git-ignored in this repo and must never be committed, shared, or synced to a cloud folder. Delete it to force a fresh login. Override its location withEPOST_STATE.
Transport: the public API, with the browser as fallback
ePost publishes a REST API for the Digital Letterbox, and a private tenant can use it — which is easy to miss, because the developer portal is written for business clients. The API is preferred for everything it covers; browser automation runs only when it cannot serve a call.
Set it up once:
Log in to app.epost.ch with SwissID as usual.
In the same browser, open
https://login.epost.ch/auth/realms/klara/account/→ Authentication → Set/update password, and set one. A SwissID login has no password of its own, which is the whole point of this step.Store it:
security add-generic-password -a epost -s epost-mcp-api-password -w -U—-wwith no value prompts, so the password never reaches your shell history or a process listing. (or setEPOST_API_PASSWORD). The account e-mail comes from the same place as before.
Documented at How to access the letterbox public APIs with a private tenant.
Why it is worth it
Measured against the same account:
Browser | API | |
List the inbox | tens of seconds, needs a live session | ~2s, no session at all |
What a letter says |
| a real |
Storage listing | 48 cards at a time, scrolled | every document in one call |
Archiving | two-step folder sheet | one |
That second row is the one that matters: the portal renders every scan with the same title, so an archived document could not be classified without opening it. The API has carried the sender all along.
What the API does not do
There is no endpoint to move a document that is already in Storage between
folders. PATCH /letters/{id}/archive is inbox → folder only and answers 400
for anything already archived — the documentation says so explicitly. Re-filing
therefore falls back to the browser.
A useful division follows from that: the browser acts, the API checks. The
move itself is not verified for you — epost_move_to_folder reports what the
portal accepted, not what it ended up holding. Run
epost_list_storage_documents afterwards and the API says which document id is
in which folder, which matters because Storage cards show only a date, and dates
repeat.
Auth: two schemes, both supported
The API documents an API key and Bearer auth, and this server does both.
Password grant (what the private-tenant guide describes):
POST /core/latest/tenants {username, password} -> tenant_id, company_id
POST /core/latest/token {username, password, grant_type=password,
tenant_id, company_id} -> access_token (600s)
GET /epost/v2/letters Authorization: Bearer …It re-authenticates a minute before expiry rather than tracking refresh tokens: the password is already at hand, so a refresh buys nothing.
API key, as an alternative or in addition:
# -w with no value prompts, so the key stays out of shell history and argv
security add-generic-password -a epost -s epost-mcp-api-key -w -U
# or: export EPOST_API_KEY=<key>Sent as X-API-KEY. A key on its own is a complete credential — the password
grant is then skipped entirely, which is the simpler setup if you have one. When
both are configured the key travels alongside the token, matching the portal's
own examples.
epost_settings reports which of the two are in play.
Choosing the transport
EPOST_TRANSPORT is auto by default: the API answers whatever it can, the
browser takes the rest. Pin it to api or browser to diagnose a result, or to
force the browser for the one thing only it can do.
Login: one fingerprint, nothing else
epost_login drives every step of the SwissID chain that does not need a human
and stops at the only one that does — the biometric prompt:
app.epost.ch → login.epost.ch clicks "Login mit SwissID"
→ login-email fills your account e-mail, "Weiter"
→ confirm-passkey "Weiter" → macOS asks for Touch ID ← you
→ app.epost.ch, authenticated; session cachedMeasured end to end: 19 seconds, one fingerprint, no password and no SMS code. Two things have to be in place:
A passkey on your SwissID account, created normally in Safari or Chrome (
account.swissid.ch→ Login-Einstellungen). Apple's authenticator is accepted; see the note below for why a software one is not.A signed browser. Playwright's bundled Chromium is an unsigned test build and reports
isUserVerifyingPlatformAuthenticatorAvailable() === false, so Touch ID is never offered and SwissID falls back to password + SMS. Installed Google Chrome reportstrue. The server therefore prefers a signed system browser automatically —chrome,chrome-canary,edge,brave, in that order — and falls back to the bundled Chromium. Override withEPOST_BROWSER.
Tell it which account to fill in, either way:
security add-generic-password -a epost -s epost-mcp-swissid-user \
-w 'you@example.com' -U # or: export EPOST_SWISSID_USER=you@example.comepost_settings prints what was resolved — browser, passkey capability, paths,
and whether the account e-mail is configured. Run it first if a login surprises
you.
Why the login is not fully unattended
Everything except the biometric prompt is driven. That is the ceiling, not a shortcoming of the implementation: a passkey cannot be used without genuine user presence.
The tempting shortcut — a software passkey through Chrome's WebAuthn virtual authenticator, so the server could sign the login itself — was built, tried, and removed. SwissID rejects software authenticators outright:
POST /api-login/authenticate/webauthn-register -> 400
ERROR::WebauthnVendorNotAllowedIt was removed rather than kept "just in case": it could never work against the one service this server talks to, and it wrote an exportable private key into the login keychain — a standing risk in exchange for nothing.
Register in Claude Code
Use an absolute path to index.js:
claude mcp add epost --scope user -- node /absolute/path/to/epost-mcp/index.jsOr add it directly to ~/.claude.json:
{
"mcpServers": {
"epost": {
"command": "node",
"args": ["/absolute/path/to/epost-mcp/index.js"]
}
}
}(Claude Desktop uses the same shape in its claude_desktop_config.json.)
Tools
Tool | Params | Returns |
| — |
|
|
| Opens a visible window and drives the SwissID chain up to the Touch ID prompt. |
| — | Resolved browser + why, Touch ID capability, paths, whether the account e-mail is set |
|
|
|
|
|
|
|
|
|
|
| Archive: takes the letter out of the inbox into that Storage folder. Not a delete. |
|
| Full-text search inside the letters. API only — the portal offers nothing like it. |
|
| One letter: sender description, document types, dates, read status |
| — |
|
|
| Mark letters read or unread. |
| — | Trash, with days remaining before permanent removal |
|
| Restore a deleted letter to the inbox |
|
| ⚠️ Moves a letter to the trash. Gated behind |
|
| Thumbnail image — eyeball a document without fetching the PDF |
| — |
|
|
|
|
| exactly one of | Opens one Storage document: real sender/subject, document type, date, amount, folder — and saves the PDF when |
|
|
|
|
| Files a Storage document into a folder. |
|
| Removes a folder membership. Only works while the document is in more than one folder (see below). |
Notes on the Storage tools
The Storage area (LetterStorage) has auto Companies folders (grouped by
sender, e.g. ePost / la Mobilière), your Custom folders, and the master
My Documents (N) list. epost_list_storage returns your Custom folders and
the My-Documents count; epost_list_storage_documents enumerates the individual
documents (each only exposes a date + a Stored in <folder> tag once filed).
Archiving a letter (epost_store_letter)
Store in the card menu is a two-step action: it opens a Select a folder
sheet carrying its own Store button, greyed out until a folder is ticked. That is
why a folder argument is required — without one the sheet cannot commit, and
stopping after the first click archives nothing while leaving an invisible
overlay that swallows every later click.
Three things inside that sheet make a folder that is plainly there look absent, all of them handled now but worth knowing if it ever regresses:
.brand-containeralso matches the letter cards behind the sheet, so the lookup has to be scoped to the sheet element.The tiles sit in a horizontally scrolling strip, so filtering on visibility drops every folder off to the right.
Folder names must be compared NFC-normalised: a name with an umlaut is NFC on one side and NFD on the other, and a byte-exact compare never matches.
ePost folders are additive labels, not physical locations. My Documents
always lists every document; filing simply adds a folder membership (a document
can belong to several folders at once). epost_move_to_folder reflects this: it
opens the document's … menu → Move → ticks the target folder → confirms, and
is idempotent — a no-op if the document is already in that folder, and it
never removes an existing membership. Two consequences worth knowing:
Filing bumps a document to the top of the Last used order, so re-list before addressing the next document by
index(indices shift after a move).The portal's Move sheet will not commit an empty folder set, so a document that is in exactly one folder cannot be returned to "unfiled" through this flow — you can only re-file it elsewhere (or delete it) in the ePost UI. In other words, filing is effectively one-way; get the target right first.
Environment variables
Variable | Default | Purpose |
|
|
|
| keychain | Password for the password grant. Secret. |
| keychain | Sent as |
| keychain | Account e-mail: the API username, and what the login fills in |
| first signed system browser found |
|
|
| Cached session (storageState). Secret, written |
|
| Persistent browser profile. It holds the same session in Chromium's own store, so it is just as secret; created |
|
| API host — overridable for tests |
|
| Portal base — overridable for tests |
| unset |
|
|
| Scales the fixed pauses that let the portal repaint, and nothing else — no timeout is derived from it. Only shortens: anything that is not a finite number in |
The browser is resolved in that order deliberately: an installed, signed browser
first, because only that one can reach the platform authenticator, and only then
Playwright's own Chromium and the Playwright cache
(~/Library/Caches/ms-playwright/chromium-*). epost_settings reports which was
chosen and why.
Troubleshooting
Tools return
login_required. The cached session expired (SwissID sessions are short-lived). Runepost_loginand complete SwissID; the session is re-cached. If it keeps happening immediately, delete~/.epost-mcp/state.jsonand log in fresh.epost_loginwindow never appears.epost_loginis the only tool that runs headed. If nothing opens, another instance may hold the browser — stop other MCP clients using this server, then retry.Chromium not found. Run
npx playwright install chromium, or setEPOST_CHROMIUMto the executable path, e.g. on macOS:~/Library/Caches/ms-playwright/chromium-<build>/chrome-mac/Chromium.app/Contents/MacOS/Chromium.Download does not trigger. The server clicks the portal's Download File button and captures the browser download event. If it times out, ePost likely changed the letter-detail layout; the button is matched by its visible text and
[aria-label="Download File"]. Re-runepost_list_lettersfirst to confirm the letterbox is reachable.Selectors broke after an ePost release. This automation tracks the live DOM (
div.letter-wrapper, Digital Letterbox, Go to Storage, Download File). Portal changes can break it; open an issue.
How it works (internals)
chromium.launchPersistentContext(EPOST_PROFILE, …)withacceptDownloads: trueandlocale: 'de-CH', and the cookies fromstate.jsonare then added on top. Both halves are needed: the profile keeps what a fresh context throws away, notably SwissID's "this device is known" state, whilestate.jsonkeeps the session cookies, which a persistent profile drops when the browser closes. Running onstorageStatealone is what made every expiry cost the full two-factor dance again.Navigating to
app.epost.chfollows the KLARA/SwissID SSO redirect chain onto the dashboard (or a visible login form if the session died).The letterbox is opened by clicking the Digital Letterbox label (URL then contains
DigitalLetterboxOverview).Letters are
div.letter-wrapperelements. Downloads iterate on the same page: click letter → Download File → save → Escape back to the list.
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 | epost-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/epost-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 # lint + smoke + hygiene + tests with coverage enforced
npm test # just the testsRuns exactly what CI runs, offline and without credentials: a syntax check, the protocol smoke test and the hygiene scan.
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.
License
MIT © sapn95 — see LICENSE.
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
- AlicenseAquality-maintenanceEnables browser automation through Playwright using accessibility tree snapshots instead of screenshots. Supports web scraping, form interactions, testing, and connecting to existing browser sessions with logged-in accounts.Last updated2310,7515
- FlicenseCqualityDmaintenanceEnables browser automation, including navigation, form filling, login with CAPTCHA handling, and element manipulation, using a Chrome-based MCP server.Last updated364
- FlicenseBqualityCmaintenanceEnables browsing and collecting course materials from Brightspace through Chrome DevTools Protocol, allowing snapshotting, downloading media, and automating page navigation.Last updated22
- Alicense-qualityDmaintenanceEnables local browser automation using Stagehand without cloud services. Supports navigation, data extraction, and autonomous agent tasks through natural language.Last updatedApache 2.0
Related MCP Connectors
Manage Gmail end-to-end: search, read, send, draft, label, and organize threads. Automate workflow…
Browser MCP for logged-in tasks. Uses your Chrome — credentials stay local. Zero-token replay.
Hosted real Google Chrome MCP with per-user persistent state. Navigate, click, type, screenshot.
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/epost-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server