I’ve spent the last while building a MCP server that gives an LLM read/send access to a real mailbox, secured end-to-end with OAuth2/OIDC against Keycloak. The server is based on FastMCP and the hard part was to get it to actually work against a real Dovecot/Postfix mailbox – not just the throwaway Keycloak test realm. This post is the write-up of what actually broke, in the order I hit it.
This post was auto-created by Claude based on the tasks done during development. I just edited it in a few places, but it’s still 95% Claude’s work.
The setup: two Keycloak clients, one mailbox
The MCP server (mcp-mail) authenticates the chat client (Open WebUI in my case) via FastMCP’s OIDCProxy – that’s the “is this user allowed to use this MCP tool at all” question, and it’s what puts scopes like mcp:mail/read and mcp:mail/send onto the token FastMCP checks.
But that token has nothing to do with the mailbox itself. To actually call IMAP/SMTP as a specific user, the tool needs a second, per-user access token from a completely different Keycloak client (mcp-mail-backend), acquired via its own Authorization Code flow and cached keyed by the user’s sub claim – not by client_id, since client_id is the app’s identity, not the human’s. That token is then used directly as a SASL credential:
user=alice@example.com\x01auth=Bearer <access_token>\x01\x01
Two clients, deliberately kept apart: a leaked mailbox token should never carry MCP tool scopes, and vice versa. Simple enough on paper. Then I pointed it at a real mailbox.
Gotcha #1: tools that silently disappear
First symptom: Open WebUI only ever showed authorize_mail – none of the actual scoped tools (list_mails, read_mail, send_mail) showed up in the tool list at all, no error, nothing.
Turns out FastMCP hides any tool gated by require_scopes() from list_tools() if the token doesn’t carry that scope – and Keycloak only puts an Optional client scope into the token if the OAuth client’s /authorize request explicitly names it via scope=. Open WebUI’s request omits scope= entirely. So a scope that looked perfectly configured in Keycloak’s admin console just silently never made it into the token, and the only visible symptom was a tool that “doesn’t exist.”
Fix: assign mcp:mail/read / mcp:mail/send as Default client scopes, not Optional. Default scopes land in the token regardless of what the client’s /authorize request asks for.
A Keycloak client scope you can see in the admin console tells you nothing about whether it’ll actually reach the token – that depends entirely on Default vs. Optional, and on whether the OAuth client bothers to ask.
Gotcha #2: your mail server needs its own Keycloak client too
Once the tokens were flowing, Dovecot still rejected every login. I had assumed – wrongly – that the mail server just validates the bearer token locally somehow. It doesn’t: RFC 7662 introspection is itself an authenticated call, so Dovecot needs its own confidential Keycloak client purely to Basic-Auth against /protocol/openid-connect/token/introspect. No browser flow, no redirect URI, but a real client registration nonetheless. That brings the total to four separate Keycloak client registrations for one working mail demo:
| Client | Role | Flow |
|---|---|---|
| openwebui | Chat UI identity | Standard/Authorization Code |
| mcp-mail | MCP session (tool scopes) | OIDCProxy, Authorization Code + PKCE |
| mcp-mail-backend | Per-user mailbox access token | Authorization Code, no browser UI of its own |
| <mail server’s own client> | Dovecot/Postfix introspecting tokens | Basic-Auth to /introspect only, no user-facing flow |
I’d originally documented this demo as “the mail server never registers with Keycloak” – which is simply wrong, and cost some confusion until I corrected it.
Gotcha #3: active: false, and it’s not the claim you think is missing
This was the big one. With all four clients registered, Dovecot’s auth log showed introspection succeeding at the HTTP level, then immediately:
Processing field active
oauth2 active_attribute is not configured; skipping the check
sasl(xoauth2): Token verification failed with internal error
My first theory: Dovecot’s username_attribute (defaults to email) must be reading a claim that’s present in the userinfo response but excluded from the introspection response specifically – Keycloak toggles “add to token introspection response” separately per protocol mapper, so this seemed plausible. I spent a good while chasing that, including confirming Dovecot’s actual defaults against its source (db-oauth2.c) rather than trusting docs alone.
It was a dead end. I finally built a small side-by-side diagnostic script (more on that below) and pointed it at a real captured token. The result: introspection returned only {"active": false}, while userinfo on the exact same token, same moment, returned every claim fine including email. Per RFC 7662, an inactive token’s introspection response contains nothing but active: false – so the “missing claim” was never a claim-mapper problem. The token was being rejected outright, before any claims were even considered.
Root cause (confirmed against known upstream reports): recent Keycloak versions, hardened for a CVE around token confusion, require whichever client authenticates the /introspect call to appear in the token’s own aud claim. Keycloak auto-populates azp (authorized party) on every token, but never aud – so any client introspecting its own or someone else’s tokens fails this check by default, on a completely fresh setup, independent of any per-claim mapper toggle.
Fix: add an Audience protocol mapper (oidc-audience-mapper) to a client scope that’s Default on the client that issues the tokens (mcp-mail-backend), with included.client.audience naming the client that will introspect them. This is the genuinely counter-intuitive part: the mapper lives on the token issuer’s client scope, but its value names a totally different client. The introspecting client’s own configuration has nothing to set for this at all.
linux # kcadm.sh create client-scopes/<uuid>/protocol-mappers/models -r myrealm -s name=dovecot-audience -s protocol=openid-connect -s protocolMapper=oidc-audience-mapper -s 'config."included.client.audience"=dovecot-introspect' -s 'config."access.token.claim"=true'
And then a second layer to the same trap: fixing this for my own diagnostic client (mcp-mail-backend introspecting its own tokens) did not fix the real Dovecot path, because Dovecot authenticates /introspect with its own separate client credentials – a third identity, distinct from both MCP-facing clients. Its client_id was simply never in aud either. Keycloak happily supports multiple oidc-audience-mapper instances on one client scope, unioning into a JSON array – so the fix was a second mapper instance on the same scope, naming Dovecot’s client_id specifically. The rule that actually matters:
audmust contain whichever client_id actually authenticates each individual/introspectcall – not the issuer, not “one” client, but every distinct caller.
Building better diagnostics along the way
None of the above was findable from Dovecot’s logs alone – it never logs the raw introspection JSON body, so “which field is missing” is invisible from the log by design. Two small scripts made the rest of this tractable:
check_introspection.py– takes a live token and calls both the introspection endpoint and the userinfo endpoint, reporting side by side whether each claim is present in each. Supports authenticating to/introspectas a different client than the one the server itself uses, so I could reproduce exactly what Dovecot’s own credentials would see.decode_jwt.py– decodes a JWT’s header/payload directly, no signature check, for the moments introspection’sactive: falsehides literally everything else.
Along the way I also had to correct a claim in my own docs: I’d assumed LOG_LEVEL=DEBUG would surface httpx’s wire-level logging including headers, and therefore the bearer token itself. That’s false – I verified directly against the installed httpx/httpcore that their Request/Response __repr__ only ever shows the HTTP method, never headers or body, at any log level. Trust the library’s actual source over what you assume a debug flag does. The real mechanism I added instead is an explicit, separate opt-in – BACKEND_DEBUG_LOG_TOKENS=1 – that logs the raw access token specifically, kept apart from general log verbosity on purpose, since exposing a live credential is a materially different risk than connection tracing.
A smaller, unrelated Docker gotcha
Separately: after every container rebuild, the MCP client had to re-run Dynamic Client Registration from scratch. Reading FastMCP’s own oauth_proxy/proxy.py instead of guessing: its DCR registration store defaults to an encrypted file under <FASTMCP_HOME>/oauth-proxy/<fingerprint>/, and FASTMCP_HOME defaults to platformdirs.user_data_dir("fastmcp") – which in a container lives in the writable layer. Survives a restart, wiped by a rebuild. Fix was mounting a named volume at a fixed FASTMCP_HOME=/data, same idea as making sure a database’s data directory isn’t sitting in the container’s throwaway layer, just for OAuth client registrations instead.
Removing the click entirely
Even with everything above fixed, the UX still required a manual step: calling authorize_mail and clicking a link, because a generic MCP client only understands the plain MCP OAuth surface, not a second mailbox-specific login.
For a small AI-assisted webmail client I built alongside this (its own login, not an MCP server at all), I chained the mailbox-OAuth leg with prompt=none immediately after the primary login succeeds – invisible to the user whenever Keycloak can authenticate silently, falling back to the same one-time link only when it can’t.
Take-aways
- “Tool doesn’t show up” and “token rejected outright” both tend to look like something else is missing – a claim, a config value – when the actual cause is upstream of that (scope never granted, token never accepted in the first place).
- RFC 7662’s minimal failure response (
{"active": false}, nothing else) actively hides the real cause – don’t debug per-claim issues until you’ve confirmedactive: true. - An Audience mapper’s config lives on the token issuer’s client scope but names the client that will introspect it – completely counter-intuitive the first time, and worth writing down so it doesn’t cost a full round-trip again.
- Every distinct client that calls
/introspectneeds its own entry inaud– fixing it for one caller doesn’t fix it for another. - When a debug flag’s behavior matters, check the library’s actual source, not what the docs (or your own past self) assumed it does.
