Developers · September 22, 2026

Email MCP Server OAuth vs API Key: the 401 Trap

Email MCP server OAuth vs API key: a valid key gets 401 invalid_token at /mcp, not 403, so rotation never helps. What each credential carries.

You pasted efa_live_... into your client’s MCP configuration, pointed it at https://mcp.emailforagents.ai/mcp, and the response said invalid_token. So you stripped the trailing newline, checked the variable reached the process, rotated the key, restarted the client, and got the same thing. Then you sent the same string to https://api.emailforagents.ai/v1/me and it returned 200 with your scopes in the body. The key is fine. It was always fine, and no amount of rotating it will change the answer at /mcp.

This page settles what that endpoint returns for a key, why the error text points you at the wrong problem, the order the checks actually run in, and the one discovery setting that quietly gives a correctly-connected client a read-only session. If you are still choosing between the two surfaces rather than debugging one, read MCP or API key for agent email instead.

What the endpoint really returns for an API key

Not 403. Here is the live response, taken from the production endpoint while writing this, with an efa_live_ shaped bearer token:

HTTP/2 401
www-authenticate: Bearer realm="OAuth",
  resource_metadata="https://mcp.emailforagents.ai/.well-known/oauth-protected-resource/mcp",
  error="invalid_token", scope="email.read"
content-type: application/json

{"error":"invalid_token","error_description":"Invalid access token"}

invalid_token here does not mean your key is malformed, expired or revoked. It means the endpoint never found it, because it never looked. Access tokens at /mcp are minted by the pinned @cloudflare/workers-oauth-provider build as three colon-separated parts, userId:grantId:secret, and the handler splits on : and requires exactly three pieces before it will touch storage. An API key is efa_live_ plus eight hex characters plus a 43-character body, with no colon in it, so it fails that shape test, the lookup is skipped, and the code falls straight through to the error above. Your workspace is never consulted. The key’s scopes are never read.

That is the whole reason rotation feels like it should work and never does. The one error the endpoint can produce for an unrecognised bearer string is the same error it produces for a genuinely bad token, so a credential that is perfectly valid elsewhere and a typo are indistinguishable on the wire.

Two related responses worth telling apart. Send no Authorization header at all and you get 401 with an empty body and the same challenge minus error="invalid_token". Send an expired OAuth access token and you also get invalid_token, but that one is fixed by refreshing, which is why the empty-body case is the only one where the challenge alone tells you what to do next.

There is a second, stricter refusal in the application layer, HTTP 403 permission_denied with “Only connection grants may invoke MCP tools.” It exists for deployments that mount the MCP app behind a different authenticator, and it is not what mcp.emailforagents.ai returns, because the OAuth provider in front of it rejects the credential before the application sees a principal at all.

Email MCP server OAuth vs API key: what each credential carries

They are not two spellings of the same thing. They are rows in different tables, and three of the fields the MCP endpoint reads exist on only one of them.

What the endpoint needsAPI keyOAuth connection grant
An authorising humanYes, authorizing_identity_idYes, owner_identity_id
A scope setAny subset of the creator’s, chosen onceOne of three presets, fixed by the OAuth scope
A projectOptionalExactly one, stored on the row
An inbox listOptionalTicked in a browser, rows in connection_inboxes
A resource it was minted forNoneresource_uri, compared on every call
The client holding itUnknownoauth_client_id, recorded at consent
An expiryOnly if someone set onenow() + interval '30 days', always
What decides the tool listNothing; a key has no tool listThe preset, through visibleTools()

The scope row is the one that actually blocks the shortcut people want. A key’s scopes are an arbitrary subset of the 32 the product defines, so a key can legitimately hold webhooks:manage, exports:read and keys:manage together. A connection’s scopes are one of three fixed presets, and the preset is what the tool list is derived from. There is no rule that maps an arbitrary scope subset onto six MCP tools, and inventing one at request time would mean a tool surface nobody consented to. Which tools a preset produces, and the three ways that list shrinks while an agent is running, are in the MCP tool surface as a permission boundary.

The expiry row cuts the other way and is worth saying plainly. A connection dies after thirty days whether or not anyone is watching, and somebody has to sit in front of a browser to renew it. A key does not. For an unattended runtime that is not a security property, it is an outage on a timer.

The order the checks run in, and where your request died

Guess this wrong and you spend an hour on the wrong layer. In the deployed worker the sequence is fixed, and several of the gates sit in front of authentication rather than behind it.

OrderGateFailure
1Per-IP edge budget429 rate_limited, retry-after: 60
2Request body size, 512 KiB at /mcp413 invalid_request, before any credential is read
3Bearer header present401, empty body, WWW-Authenticate challenge
4Token resolves to a stored grant401 invalid_token. Every API key stops here
5Token audience equals the resource URI401 invalid_token, “Invalid audience”
6Connection still live, in SQL401, “Credential or its authorizer is no longer active.”
7HTTP method405 with Allow: POST
8JSON-RPC envelopeJSON-RPC error -32600, HTTP 200
9Origin, when the header is present403, the bare object {"error":"forbidden_origin"}
10Tool visible on this grant403 permission_denied, “This connection cannot use that tool.”

Three of those surprise people. Gate 2 runs before gate 3, so an oversized request with no credential at all returns 413 and not 401. Gate 7 runs after gate 4, so GET /mcp without a token is 401, and only becomes 405 once the token is good. Gate 9 skips the standard error envelope entirely, so handler code reaching for error.code on a forbidden origin reads undefined.

The audience check at gate 5 is the one that catches a real client bug rather than a credential mix-up. The MCP authorization specification requires a server to accept only tokens minted for itself, and requires clients to bind each token to one resource with the RFC 8707 resource parameter. A client that talks to a dozen servers in a session and reuses a token across them is the confused deputy the rule exists to stop. An API key has no audience field at all, so at /mcp this is not a failed check, it is a missing one.

The discovery documents advertise a read-only scope

This is the failure that arrives after everything works. Your client completes the flow without an error, the agent can read mail, and then create_draft is not in the tool list. Nothing is broken. The client asked for exactly what the server advertised.

The protected resource document, live at both paths, publishes one scope:

{
  "resource": "https://mcp.emailforagents.ai/mcp",
  "authorization_servers": ["https://mcp.emailforagents.ai"],
  "scopes_supported": ["email.read"],
  "bearer_methods_supported": ["header"]
}

The WWW-Authenticate challenge repeats it as scope="email.read". The authorization server document is the one that lists all of them, email.read, email.draft, email.send and offline_access. A client that takes its scope from the resource metadata or from the challenge, which is the behaviour the specification steers it toward, requests read access and gets it.

Scope maps to preset by looking for email.send first, then email.draft, then falling through. Preset maps to tools by requiring every scope a tool declares:

Requested scopePresetTools in the session
email.readreadlist_inboxes, list_threads, list_messages, get_message
email.draftread_draftthe four above plus create_draft
email.sendread_draft_sendall six, including send_message

If the agent needs to draft or send, the scope has to be set by hand in the client configuration. An empty scope string, an unrecognised one, or one copied from the resource document all produce the same four-tool read-only session against a connection nobody misconfigured. create_draft needs drafts:write and send_message needs messages:send, and neither sits in the read preset, so the tools are absent rather than present and refusing. Walking the consent screen and confirming what you got is covered in setting up an MCP email server.

Three ceilings, recomputed on every request

Neither credential carries its permissions. Both resolve through the same function, which reads current SQL state per request, and the comment above it says token snapshots cannot restore revoked access. For a connection that means one request does this:

Revoke a connection in the console and the next tools/list is 401, with no cached-token window to wait out. The integration suite asserts exactly that: it revokes a live grant mid-test and expects 401 on the following call.

It is tempting to say a key is refused because there is no human behind it. That is wrong, and worth correcting because it sounds right. A key has an authorising identity too, and the same resolver walks the key’s parent chain and intersects every link against that person’s current grant. Demote them and every key they minted narrows on the next request. The delegation chain is real on both sides. What a key is missing is narrower: an audience, a named client, an inbox selection a person made in a browser, and a preset that a tool list can be computed from.

On the named client, one honest detail. The trust class written on a connection row is unverified for every real client, and local only for a client id beginning local or the literal string test-client. Nothing here is ever recorded as recognised, because there is no verification programme to record.

Connecting a client that does speak OAuth

Give it the URL and nothing else. There is no key field and no header to paste. The first call gets the challenge, and a client that handles it reads the two discovery documents and registers itself:

curl -i -X POST https://mcp.emailforagents.ai/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

curl -s https://mcp.emailforagents.ai/.well-known/oauth-protected-resource/mcp
curl -s https://mcp.emailforagents.ai/.well-known/oauth-authorization-server

The constraints your client has to satisfy, all enforced server-side:

Then the browser step, which is the part a key cannot substitute for. You sign in, pick a project, tick the inboxes and choose the preset. The connection row is written pending with an expiry thirty days out and flips to active on the first successful token exchange. Access tokens last 15 minutes. Refresh tokens last 30 days and are single use, and replaying one does not merely fail: the reuse is detected on an insert conflict and the connection is revoked on the spot, which is the correct response to a credential appearing twice. That behaviour is asserted in the OAuth provider integration test, where the second refresh returns 400 and the connection’s status reads revoked.

The consent screen also refuses to widen. Selecting a preset above the one the client requested is a 403 with “Selected access exceeds the authorization request,” and granting a preset containing a scope you do not hold yourself is “You cannot grant permissions you do not have.”

When the key was the right credential all along

If the thing you are connecting is CI, a cron job, a queue worker or your own server code, the key was correct and the MCP endpoint was the wrong door. There is no browser in a cron job, and a flow whose security rests on a human reading a consent screen has nothing to offer one. Nor does a credential that expires every thirty days.

The fastest way to confirm a key is healthy is to ask what it resolves to against the REST surface an agent can drive:

curl -s https://api.emailforagents.ai/v1/me \
  -H "Authorization: Bearer $EFA_API_KEY"

The response echoes the computed grant back, including workspace_id, project_ids, inbox_ids, scopes and environment. If that returns 200 while /mcp returns 401, the two results are consistent rather than contradictory and you have the situation this page is about. If it returns 401 as well, now you have a key problem.

A key can be narrowed as tightly as any grant: pinned to one project, pinned to specific inbox IDs, and issued without messages:send so the agent composes and the send path lands in the approval queue. Scoping an API key for an agent covers the scope strings and the creation-time refusal when you ask for more than your own role holds.

Say the cost out loud, because it is real. Several hosted email MCP servers accept a dashboard key as a bearer token at their MCP endpoint precisely so headless setups can carry one credential everywhere. That is a genuine convenience and it is not on offer here. If a single credential across both surfaces is what you need, this is the worse product for you today.

Before you grant anything a send preset, list what already exists. The connections endpoint on a project returns each grant’s display name, status, client ID, scopes, inbox addresses, last_used_at and expires_at, and revoking one is a POST to /v1/projects/{project_id}/connections/{connection_id}/revoke. Read what the approval gate holds back first, then decide whether the agent gets email.draft or email.send.


Keep reading

All guides · Documentation · Developers