In my last post about the MCP mail server I ended up with four Keycloak client registrations for one working demo. That raised an obvious follow-up question: is four the number, or just the number I ended up with? Short answer: the minimum is one, the common setups need two, and the mail server may add one more. This post walks through the scenarios and what each one costs.
DRAFT. This post was drafted by Claude and still needs review.
The two legs
Every MCP server that talks to an OAuth-protected mailbox sits between two separate OAuth relationships:
MCP client ──(leg 1: token for the MCP server)──► MCP server ──(leg 2: XOAUTH2/OAUTHBEARER token)──► IMAP / SMTP
- Leg 1 answers “may this user call these MCP tools?”. Per the MCP authorization spec, the MCP server is an OAuth 2.1 resource server, and the token it receives must be issued for it (audience binding via RFC 8707 resource indicators).
- Leg 2 answers “may this process read and send mail as this user?”. The mail server is the resource server here. Dovecot checks the bearer token from the SASL
XOAUTH2orOAUTHBEARER(RFC 7628) exchange.
Every OAuth token is requested by some client, so each leg needs at least one client identity. The question is whether those identities can be the same and where they get registered.
In the flow diagrams below, the shading shows how each leg gets its token: blue for leg 1, orange for leg 2, and green where a single login yields the tokens for both legs.
One thing that does not add a client: IMAP vs. SMTP. Postfix usually hands SASL to Dovecot (smtpd_sasl_type = dovecot), so both protocols validate tokens through the same code path. One token, or one audience covering both, is enough.
How MCP clients get a client_id at all
Before counting, one detail that changes the count: MCP clients (Claude, Open WebUI, IDEs, …) are not known to your IdP in advance. The spec offers three ways for them to obtain a client_id:
- Pre-registration: you create a client by hand and configure its ID in the MCP client. Fine for one or two known clients, but it doesn’t scale.
- Dynamic Client Registration (DCR, RFC 7591): the MCP client registers itself at runtime. Zero admin effort, but every client instance creates a registration, and the authorization server has to allow anonymous registration in some form.
- Client ID Metadata Documents (CIMD, added in the 2025-11 spec revision): the client_id is an HTTPS URL pointing to a JSON document describing the client. Nothing is stored at the authorization server, but the authorization server has to support it.
The important question is which authorization server MCP clients register with: your IdP, or the MCP server itself. That decides most of the count.
Scenario 0: Token passthrough (1 client, not allowed)
The tempting shortcut: the MCP client gets a token from the IdP whose audience covers both the MCP server and the mail server. The MCP server validates it and forwards the same token to Dovecot. One client, done.
The MCP spec explicitly forbids this (“token passthrough”). The MCP server must not accept tokens that weren’t issued for it, and must not forward the token it received to upstream APIs. The reasons are real, not just formal:
- Any token that is valid for IMAP is also valid for your MCP tools, and the other way round. A token leaked from either side unlocks both.
- The mail server can’t tell whether it is talking to the user’s own mail client or to an LLM-driven tool, so audit logs and rate limits lose that information.
- The MCP server becomes a confused deputy: it acts with whatever the token carries, not with what it was meant to do.
Pros: one client, no tokens stored in the MCP server, no second authorization step, and nothing to configure for token exchange.
Cons: besides the security problems above, it also breaks in practice. A spec-compliant MCP client requests its token with an RFC 8707 resource parameter for the MCP server, so the token is bound to the MCP server and Dovecot shouldn’t accept it.
So the theoretical minimum of one client is off the table if you follow the spec. Every scenario below keeps the two legs apart.
Scenario 1: Single-client broker (1 IdP client)
The MCP server acts as an authorization server of its own towards MCP clients and as an ordinary OIDC client towards the IdP. FastMCP’s OAuthProxy/OIDCProxy works this way:
- The MCP client discovers the MCP server’s authorization metadata and registers via DCR at the MCP server. The IdP never sees these registrations.
- The MCP server redirects the user to the IdP with its own confidential client, requesting the mail scopes plus
offline_access. - The MCP server keeps the upstream tokens (per user, keyed by
sub) and issues its own token to the MCP client. That token is valid only for the MCP server, which takes care of leg 1. - For leg 2, the tool uses the stored upstream access token, refreshed as needed, as the XOAUTH2 credential.
The complete flow, step by step:
IdP clients: 1 (plus the mail server’s introspection client, see below).
Pros: one login, one consent, no DCR at the IdP, and it works with any IdP that does plain authorization code flow, including Google and Microsoft.
Cons: you run a small authorization server and you store refresh tokens for mailboxes, so the MCP server becomes a valuable target. The upstream token carries both “user logged in to the MCP server” and “may access the mailbox”. That is still spec-compliant, because the MCP client never sees it, but there is no privilege separation on the IdP side.
Scenario 2: Split broker (2 IdP clients)
This is what I built with Claude in the previous post. Same broker as in scenario 1, but the mailbox token comes from a second IdP client:
mcp-mail: the broker’s upstream client. It authenticates the user and carries the MCP tool scopes (mcp:mail/read,mcp:mail/send).mcp-mail-backend: requests the mailbox token in a separate authorization code flow. Its audience is set up for Dovecot.
The complete flow, step by step:
IdP clients: 2 (plus introspection).
Pros: clean least privilege. A leaked mailbox token carries no tool scopes and vice versa, and you can revoke mailbox access without touching the MCP session.
Cons: a second authorization flow. A generic MCP client only knows about leg 1, so the second flow needs an out-of-band step (my authorize_mail tool plus a link to click). A silent prompt=none request right after the first login can hide that step when the IdP session is still alive.
Scenario 3: IdP as authorization server + token exchange (2 clients, or 1 + n with DCR)
The “textbook” layout: the MCP server does not issue tokens at all. Its protected resource metadata (RFC 9728) points MCP clients straight to the IdP.
- The MCP client registers at the IdP, either as one pre-registered public client (PKCE, loopback redirect URIs) or as many clients via DCR/CIMD.
- The MCP client gets a token with
aud= the MCP server and calls the tools. - The MCP server, using its own confidential client, exchanges that token at the IdP for a token with the mail audience. This is OAuth 2.0 Token Exchange (RFC 8693); Microsoft calls its variant “on-behalf-of”. Keycloak has supported standard token exchange since 26.2.
- The exchanged token goes to Dovecot via XOAUTH2.
The complete flow, step by step:
IdP clients: 1 for the MCP server + 1 pre-registered client for MCP clients, or n dynamic ones (plus introspection). If the MCP server validates incoming tokens by introspection rather than locally via JWKS, it can use its own confidential client for that. Same aud rule as in the last post: it must be in the token’s audience.
Pros: no token storage in the MCP server and no second user-facing flow. The exchanged token is short-lived and derived from the user’s current session. It is spec-compliant and the IdP keeps full control: policies, consent, and revocation all happen in one place.
Cons: the IdP must support token exchange, and MCP clients must be able to register there. With DCR, Keycloak collects a registration per client instance and needs client registration policies (trusted hosts, allowed scopes) so anonymous registration doesn’t become a free-for-all. Exchange permissions also need careful setup: which client may exchange which audience.
A variant without token exchange: IdP as authorization server for leg 1, plus a separate backend client for leg 2 with its own authorization code flow. The client count matches scenario 3 and the UX matches scenario 2, the extra click included. That makes it the worst combination of both, unless your IdP lacks token exchange.
Scenario 4: Hosted mail (Microsoft 365 / Google)
With hosted mail you don’t control the authorization server, which limits the options:
- Google: one OAuth client in the Cloud Console with scope
https://mail.google.com/. No DCR for third-party clients and no token exchange, so you are in scenario 1 with 1 client. - Microsoft Entra ID: one app registration with
IMAP.AccessAsUser.All,SMTP.Sendandoffline_accessgives you scenario 1. Entra has no DCR, but it does offer on-behalf-of. So with a second app registration for the MCP server’s API and a pre-registered client for MCP clients, scenario 3 is possible too (to be verified for the Outlook IMAP/SMTP scopes).
Both variants, step by step:
No introspection client is needed here. The provider’s mail servers validate their own tokens.
Pros: no mail server to run and no token validation, introspection client or audience mapper to configure. Consent and admin controls (Entra admin consent, Google Workspace app access control) come with the provider.
Cons: the provider decides which scenario you can use. With Google that is always the broker, so you store refresh tokens. The scopes are coarse: there is no read-only IMAP scope, so IMAP.AccessAsUser.All and https://mail.google.com/ both grant full mailbox access. On top of that, Gmail’s scope is a restricted scope that needs Google’s app verification for apps used outside your own organization, and Entra tenants often need an admin to consent.
The extra one: mail server introspection
On a self-hosted Dovecot/Postfix setup, add one more client in every scenario if Dovecot validates tokens via RFC 7662 introspection: introspection is an authenticated call, so Dovecot needs its own confidential client. As described last time, that client’s ID must also be in the token’s aud, or Keycloak answers {"active": false}.
The alternative is local validation: Dovecot checks the JWT signature against the IdP’s keys (local_validation_key_dict in the oauth2 config). No client is needed and there is no round-trip per login. The price is that a revoked token stays valid until it expires, so keep access token lifetimes short.
Both paths, and how IMAP and SMTP share them:
Why the last post needed four clients
With the scenarios laid out, the four Keycloak clients from the previous post are easy to place. Only one of them is strictly needed. The other three each come from a separate decision:
| Client | Why it was there | Role | Could it be dropped? |
|---|---|---|---|
openwebui | Open WebUI has its own SSO login for its users. Keycloak needs a client for that whether MCP is involved or not. | Chat UI login, not part of the MCP/mail chain | Only by using an MCP client without a login of its own (Claude, an IDE, a CLI) |
mcp-mail | Upstream client of FastMCP’s OIDCProxy. The proxy acts as the authorization server for Open WebUI and logs the user in at Keycloak with this client. Its token carries the tool scopes. | Leg 1, broker | No. Leg 1 always needs a client. |
mcp-mail-backend | Claude’s design kept mailbox tokens apart from tool tokens, so a leaked token of one kind doesn’t grant the other, and mailbox access can be revoked on its own. | Leg 2, which makes it scenario 2 | Yes: request the mail audience on mcp-mail‘s token (scenario 1), or use token exchange (scenario 3) |
dovecot-introspect | Claude’s design had Dovecot check tokens via introspection, and nobody weighed that against local validation. Introspection requires authentication, which was overlooked at first. | Mail server | Yes: local JWT validation in Dovecot |
Open WebUI looks like it should count twice, but it doesn’t. It shows up once as a Keycloak client (openwebui, for user login) and once as an MCP client. The MCP client registration happens via DCR at the FastMCP proxy, not at Keycloak, so it doesn’t add a Keycloak client. That registration is the one that got lost on every container rebuild until I gave FASTMCP_HOME a volume.
So, using the same software, the demo could have run with a single Keycloak client: scenario 1, local token validation in Dovecot, and an MCP client without its own login. None of the extra clients was a conscious decision on my part. The whole design came from Claude, and the client count simply grew as pieces were added. Looking back, each extra client does buy something:
openwebui: real user accounts in the chat UI.mcp-mail-backend: separation between tool permissions and mailbox access.dovecot-introspect: revocation that takes effect immediately instead of at token expiry.
Four clients isn’t a requirement. It is the sum of three trade-offs that were made along the way without anyone weighing them. That is my main lesson from this: when an AI designs your auth setup, ask it to justify every client registration and every validation method, not just to make it work.
Summary
| Scenario | Who registers MCP clients | Clients at the IdP | + Dovecot introspection | Notes | Pros (+) / Cons (−) |
|---|---|---|---|---|---|
| 0: Token passthrough | IdP | 1 | +1 | Forbidden by the MCP spec | + one client, nothing stored − a leaked token opens both tools and mailbox; confused deputy; breaks with audience-bound tokens |
| 1: Single-client broker | MCP server | 1 | +1 | Simplest; works with any identity provider, including Google/M365 | + one login, no DCR at the identity provider − you run an authorization server, store refresh tokens, no privilege separation |
| 2: Split broker | MCP server | 2 | +1 | The setup from the previous post | + least privilege, mailbox access revocable on its own − second authorization step, stores refresh tokens |
| 3: IdP as AS + token exchange | IdP (pre-registered, DCR or CIMD) | 1 + 1 (or 1 + n with DCR) | +1 | Textbook; needs RFC 8693 support at the IdP | + no stored tokens, no extra click, central control at the IdP − DCR clutter and registration policies, exchange permissions to configure |
| 4: Hosted mail | MCP server (or pre-registered) | 1 (Entra OBO: 2) | – | Provider validates tokens itself | + no mail server or token validation to run, provider handles consent − provider dictates the scenario, coarse full-mailbox scopes, app verification or admin consent |
Which one would I pick?
- Hosted mail or a quick start: scenario 1. It is the only option that works everywhere.
- Own Keycloak, few known MCP clients: scenario 3 with one pre-registered public client and token exchange. No stored refresh tokens, no extra click.
- Own Keycloak, arbitrary MCP clients via DCR: a broker (scenario 1 or 2) keeps the DCR churn out of your IdP. Pick 2 if mailbox access should be separately consented to and revocable.
- Introspection vs. local validation in Dovecot: introspection if you need immediate revocation, local validation if you’d rather save a client and a round-trip.
