pingen-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., "@pingen-mcpSend a registered letter from /documents/letter.pdf as a draft."
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.
pingen-mcp
Send physical letters — A-Post, B-Post, registered — straight from a PDF via the Pingen v2 API.
MCP server for Pingen v2 — send real, physical letters (A-Post / B-Post / registered / Einschreiben) from a PDF and track them, straight from any MCP client (Claude Code, Claude Desktop, …). It talks to the official Pingen REST API — no browser automation. Pingen prints and mails the letter for you; data is hosted in Switzerland.
Credentials are never stored in this repo. They are read at runtime from the macOS login keychain (or from environment variables). Nothing secret is committed.
Prerequisites
Node.js ≥ 20.19 (
node --version)A Pingen account with an OAuth client (
client_credentialsgrant) — see belowmacOS (the credential lookup uses the
securitykeychain tool; on other platforms use the environment-variable alternative instead)
git clone https://github.com/sapn95/pingen-mcp.git
cd pingen-mcp
npm installRelated MCP server: MCP Email Service
Credential setup (step by step)
This is the part that must be right, or nothing works. You need three values from Pingen and you store them in the macOS keychain.
1. Create a Pingen account
Sign up at https://app.pingen.com. A free account is enough to create drafts and test; you only pay when you actually mail a letter.
2. Create an OAuth client (to get Client-ID + Client-Secret)
In the Pingen dashboard open Settings → API / Developer (also reachable at https://app.pingen.com/organisation → API).
Create a new OAuth client / application.
Choose the
client_credentialsgrant (machine-to-machine; no redirect URL, no user login at request time).Copy the Client-ID and the Client-Secret. The secret is shown only once — copy it now.
3. Find your Organisation UUID
Each Pingen organisation has a UUID. It appears in the dashboard URL when the
organisation is selected (https://app.pingen.com/.../organisations/<UUID>/...),
or under Settings → Organisation. It is optional only when your account has exactly one
organisation: the server then calls GET /organisations and uses it. With
several, it refuses and lists them rather than deciding — silently — which
account pays for and franks the letter.
4. Store all three in the macOS keychain
Run these exactly (the service names must match what index.js reads). Replace
the angle-bracket placeholders with your real values:
-w without a value prompts instead of taking the secret from the command
line, so it never reaches your shell history or a process listing — where a
credential that can print and post mail at your expense has no business being.
security add-generic-password -a pingen -s pingen-mcp-client-id -w -U # prompts
security add-generic-password -a pingen -s pingen-mcp-client-secret -w -U # prompts
# Optional — auto-detected from /organisations if you skip it, and only when
# the account has exactly one:
security add-generic-password -a pingen -s pingen-mcp-org-uuid -w -U # promptsThe exact service names read by index.js are:
Value | Keychain service name | Env-var alternative |
Client-ID |
|
|
Client-Secret |
|
|
Organisation UUID (optional) |
|
|
(PINGEN_API_BASE overrides the API base URL, default https://api.pingen.com.)
The organisation UUID is optional only where there is nothing to choose: it is discovered automatically when the account has exactly one organisation, and asked for otherwise. "Exactly one" means one in the account, not one on the page that came back — an organisation list long enough to paginate is a question too, because picking the first entry off it would decide, silently, which account pays for and franks the letter.
An environment variable that is set always wins — even when it is empty. A
blank PINGEN_CLIENT_SECRET means no secret, not go and look in the
keychain; a blank PINGEN_API_BASE means no endpoint, not use production.
Only an entirely absent variable falls through to the keychain, so blanking one
is a reliable way to make sure a run cannot reach your real account.
Verify a value is stored (prints the value to your terminal — run only when you're OK seeing it):
security find-generic-password -a pingen -s pingen-mcp-client-id -wThe first time the server reads the keychain, macOS may pop up "node wants to use your confidential information". Click Always Allow so it doesn't prompt on every start.
Never commit credentials. The .gitignore already excludes .env files;
the keychain path keeps secrets out of the filesystem entirely.
Register in Claude Code
claude mcp add pingen --scope user -- node /absolute/path/to/pingen-mcp/index.jsThat writes an entry into ~/.claude.json. Equivalent manual snippet:
{
"mcpServers": {
"pingen": {
"command": "node",
"args": ["/absolute/path/to/pingen-mcp/index.js"]
}
}
}Then, in a Claude Code session, run pingen_status — it should print your
organisation. That confirms the credentials and registration are correct.
For Claude Desktop, add the same mcpServers block to
~/Library/Application Support/Claude/claude_desktop_config.json.
Draft-vs-send safety model (read this)
Sending a letter is two explicit steps, so you never mail something by accident:
pingen_send_letteruploads the PDF and creates a DRAFT (auto_send = false). Nothing is mailed. You can review it in the Pingen dashboard.pingen_submit_lettertakes an existing draft and physically mails it. This is the step that costs money and puts paper in the post, so it needsconfirm: true.pingen_delete_letterneeds the same, because a deleted draft does not come back.pingen_cancel_letterdoes not: stopping a letter is the safe direction.
flowchart TD
PDF["your PDF"] --> SEND["pingen_send_letter"]
SEND -->|"default"| DRAFT["draft — nothing is mailed,<br/>review it in the dashboard"]
SEND -->|"auto_send: true<br/>(needs delivery_product)"| ST
DRAFT --> ST{"what Pingen says<br/>about the letter"}
ST -->|"valid — a draft it will take"| SUB["pingen_submit_letter<br/>confirm: true"]
ST -->|"processing, sent"| POST
ST -->|"validating"| WAIT["still being checked —<br/>ask again in a moment"]
ST -->|"action_required, invalid"| STOP["Pingen will not take it.<br/>Sending it again does not help;<br/>a corrected PDF does"]
WAIT -.-> ST
SUB --> POST[("mailed — paper in the post,<br/>and it costs money")]
SUB -.->|"no answer at all: timeout,<br/>dropped line, 5xx"| MAYBE["it says it cannot know.<br/>The letter may already be printing,<br/>so check before sending a second one"]
SEND -.->|"same, on the auto_send half"| MAYBE
STOP -.->|"the letter carries the status,<br/>the trail carries the reason"| EV["pingen_letter_events"]
MAYBE -.-> EV
DRAFT -.->|"confirm: true —<br/>a deleted draft does not come back"| DEL["pingen_delete_letter"]
POST -.->|"no confirm needed —<br/>stopping is the safe direction"| CAN["pingen_cancel_letter"]
classDef gate fill:#fff4e5,stroke:#d9822b
classDef bad fill:#fdecea,stroke:#c0392b
classDef done fill:#eafaf1,stroke:#27ae60
class SUB,DEL gate
class STOP,MAYBE bad
class POST doneThe only shortcut is passing auto_send: true to pingen_send_letter, which
mails immediately without a review step — use deliberately. It also requires
delivery_product: the product is optional on a draft only because
pingen_submit_letter asks for one later, and auto_send: true is the single
route that never reaches that call.
A draft must reach status valid before it can be submitted. If Pingen
still needs something (e.g. the address couldn't be read), the draft is
action_required and submit will fail — see Troubleshooting. pingen_send_letter
says so on the spot: a letter that comes back action_required or invalid is
reported as a draft Pingen will not take, with the status and where to look,
and not with the usual "now submit it" — on both of its halves, the draft one
and auto_send: true, because a refused PDF is refused either way and the half
that was told to mail it is the half most likely to be told to try again.
pingen_submit_letter says the same thing about the same two statuses rather
than "try again": a letter Pingen has refused does not become sendable by being
sent a second time, and the note names the one step that helps — a corrected PDF.
All three of those notes point at pingen_letter_events for the reason,
because that is where Pingen keeps it: the letter itself carries the status and
nothing about why, while the trail carries a bare code (layout_unsupported_format
and the like). pingen_get_letter cannot answer that question — it returns the
letter row — so being sent there is being told to look somewhere the answer has
never been.
All of that reads an answer. The two calls that put paper in the post also say
what they do not know when there is no answer to read: a timeout, a dropped
connection, a body that stopped halfway. Pingen may well have taken the letter
before the line went dead, so a bare "the request failed" — which is what a
retry gets triggered by — would be a guess in the direction of a second letter,
printed and charged. Instead the error says the letter may be on its way and
names the tool that can settle it: pingen_get_letter after a submit, where the
id is in hand, and pingen_list_letters after an auto_send: true create, where
it is not, because the id was in the answer that went missing.
Two things — and only two — take that warning off again, because a warning worth
ignoring is worth nothing. Pingen answering for itself takes it off: a 404, a
conflict_state, anything below 500 says the request was stopped and no paper
moved. Never having sent the request takes it off too, in the same direction:
a missing client secret or a refused token grant is a failure this server
reaches on its own, with the line to Pingen never opened, and "Keine
Pingen-Credentials" used to come back with a paragraph about a letter that
might be printing. Everything else is genuinely unknown and keeps the warning —
including a 502, 503 or 504, which look like an answer and are not:
those are written by whatever sits in front of the API saying it could not get
one back, which covers the letter already on the press exactly as well as the
letter that never existed.
Tool reference
Tool | Parameters | What it does / returns |
| — | Verifies credentials; returns your organisations ( |
|
| Lists recent letters, newest first: |
|
| Uploads the PDF and creates a letter. DRAFT by default — nothing is mailed. Returns the created letter row plus a note. Set |
|
| Physically mails an existing draft, at your cost, with no undo. Requires the letter to be |
|
| Status/tracking of one letter (single letter row). |
|
| Cancels an already-submitted/sent letter where Pingen still allows it. Returns |
|
| Deletes a draft / not-yet-sent letter for good. To stop a letter already on its way use |
|
| Tracking/status history (created → submitted → sent → delivered → undeliverable …): |
|
| Downloads the final letter PDF to |
Example
// 1) create a DRAFT (nothing mailed yet)
pingen_send_letter { "file_path": "/Users/me/Einsprache_2024.pdf", "delivery_product": "registered", "address_position": "left" }
// → { created: { id: "<letter_id>", status: "draft", … },
// note: "DRAFT erstellt (nichts versandt). Zum Senden: pingen_submit_letter." }
// 2) review in the Pingen dashboard, then physically mail it
pingen_submit_letter { "letter_id": "<letter_id>", "delivery_product": "registered", "print_mode": "duplex", "print_spectrum": "grayscale", "confirm": true }Delivery products & print options (Switzerland)
Pass delivery_product to pingen_send_letter / pingen_submit_letter:
Value | Swiss product | Notes |
| B-Post | economy, slower |
| A-Post | priority, next-day where available |
| Einschreiben | tracked + signed-for delivery |
| priority/premium | availability depends on plan |
Print options on pingen_submit_letter:
print_mode:simplex(single-sided, default) orduplex(double-sided)print_spectrum:color(default) orgrayscale
Exact product availability depends on your organisation/plan — check the Pingen dashboard.
PDF layout gotcha (make your letters pass validation)
Pingen reads the recipient address optically from the first page and reserves
a franking zone. If your PDF doesn't respect the Swiss letter window, Pingen
returns action_required with protected_stamp_area and the letter can't be
submitted. To pass validation:
Recipient address inside the address window. For
address_position: leftthe window sits roughly from 60 mm down from the top, left column starting ~22 mm from the left edge. Put the full recipient block there.Keep the franking zone (top ~40–60 mm) blank. No logo, no text, no line in the top strip — that area is reserved for the stamp/frank.
Do not put a sender return line inside the window. A return address in the same window confuses address recognition — keep only the recipient in the window (a sender line, if any, belongs above/outside it).
If in doubt, create a draft with pingen_send_letter, open it in the dashboard,
and check the address preview before submitting.
Troubleshooting
Symptom | Cause | Fix |
| Address outside the window, or something in the franking zone / a sender line inside the window | Reposition the recipient into the address window and clear the top ~40–60 mm (see PDF layout gotcha), re-upload. Which of them it was is on the letter's trail, not on the letter: |
| The letter isn't | Fix the address issue so the draft reaches |
| The | Re-run the |
Token error (401/400 on | Wrong Client-ID/Secret, or the OAuth client isn't a | Recreate the OAuth client with the |
| The request left, a usable answer did not — Pingen may have taken the letter anyway. A gateway status is not Pingen's answer, it is the box in front saying it could not get one | Do not repeat the call. Check first: |
Keychain prompt on every start | macOS didn't remember the access grant | On the popup click Always Allow for |
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 | pingen-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/pingen-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
npm run mutate # mutation-test the lines this branch changedRuns exactly what CI runs, offline and without credentials: a syntax check,
ESLint, the protocol smoke test, the hygiene scan, and the test suite under
coverage. npm test runs just the suite, npm run lint just the linter.
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.
The suite in test/ drives the server over stdio exactly as a real MCP client
does, against a local stand-in for api.pingen.com (test/mock-pingen.mjs) that
listens on an ephemeral port. Credentials are fake, PINGEN_API_BASE points at
the mock, and a security stub that finds nothing goes first on PATH: no
test can reach the real API, the real login keychain, or the post. Alongside
the happy paths it pins the properties that matter — that pingen_send_letter
creates a draft and submits nothing, that a non-boolean auto_send still yields
a draft, that neither of the two calls that reach the post will do so without a
delivery_product, that neither of them reports a send Pingen did not confirm —
in the note or in the key the letter is filed under — that a draft Pingen has
flagged is never announced as ready to post and never answered with an
instruction to send it again, by any of the three branches that see that status
— the two halves of pingen_send_letter and pingen_submit_letter — that each
of them names a tool that can actually say why the letter was refused, that
neither of the two calls that mail reports a request Pingen never answered as a
letter that stayed put — whether the answer was silence or a gateway's 502 —
while the two failures that do settle the question are left alone, a status
Pingen wrote itself and a request that was never sent, that
submitting is a PATCH, and that no token or client secret can
appear in a tool result or on stderr even when the upstream error body quotes it
back. The gate fails below 90% line, 90% function and 80% branch coverage of
index.js.
test/hygiene.test.mjs points the hygiene scan at throwaway git repositories
instead, because run over this repository — where everything is clean — a scan
that silently skipped half the files would look exactly like one that worked.
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.
That found eleven real gaps here after the model review rounds had stopped turning anything up — among them a path that walked out of the letters collection, a warning that went missing at exactly one HTTP status, and a page of one organisation being read as an account with one organisation. Most of the fixes were to the fixture rather than the code: a stand-in that accepts more than the real server does is a stand-in that hides the difference.
There is no browser in this suite, so npm run mutate:all over the whole file
is about forty minutes and worth running. stryker.config.json explains every
setting that is not a default — including why incremental mode is off, and why
the number to watch when tuning it is the timeout count rather than the score.
License
MIT — 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
- AlicenseAqualityDmaintenanceEnables AI agents to send physical letters and postcards to over 200 countries using Solana cryptocurrency for payment. It provides tools for generating mail quotes, managing wallet balances, and automating physical correspondence directly through the Model Context Protocol.Last updated4401MIT
- Flicense-qualityDmaintenanceEnables sending emails (including mass emailing), querying, updating, and canceling delayed emails via the Resend API.Last updated
- Flicense-qualityDmaintenanceEnables sending letters and MICR-encoded checks, managing contacts and templates, and verifying US/Canadian addresses via the PostGrid Print & Mail and Address Verification APIs from Claude.Last updated1
- Alicense-qualityBmaintenanceLocal MCP server that generates print-ready PDF letters with DIN 5008 compliant address positioning for window envelopes, handling structured content offline without external APIs.Last updatedMIT
Related MCP Connectors
Let AI agents prepare, review, pay for, and send real letters and postcards.
Document API for AI-native software: render PDFs, e-sign, PAdES-seal, and verify.
Send transactional email over a verified domain — templates, attachments, custom headers.
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/pingen-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server