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 needs | API key | OAuth connection grant |
|---|---|---|
| An authorising human | Yes, authorizing_identity_id | Yes, owner_identity_id |
| A scope set | Any subset of the creator’s, chosen once | One of three presets, fixed by the OAuth scope |
| A project | Optional | Exactly one, stored on the row |
| An inbox list | Optional | Ticked in a browser, rows in connection_inboxes |
| A resource it was minted for | None | resource_uri, compared on every call |
| The client holding it | Unknown | oauth_client_id, recorded at consent |
| An expiry | Only if someone set one | now() + interval '30 days', always |
| What decides the tool list | Nothing; a key has no tool list | The 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.
| Order | Gate | Failure |
|---|---|---|
| 1 | Per-IP edge budget | 429 rate_limited, retry-after: 60 |
| 2 | Request body size, 512 KiB at /mcp | 413 invalid_request, before any credential is read |
| 3 | Bearer header present | 401, empty body, WWW-Authenticate challenge |
| 4 | Token resolves to a stored grant | 401 invalid_token. Every API key stops here |
| 5 | Token audience equals the resource URI | 401 invalid_token, “Invalid audience” |
| 6 | Connection still live, in SQL | 401, “Credential or its authorizer is no longer active.” |
| 7 | HTTP method | 405 with Allow: POST |
| 8 | JSON-RPC envelope | JSON-RPC error -32600, HTTP 200 |
| 9 | Origin, when the header is present | 403, the bare object {"error":"forbidden_origin"} |
| 10 | Tool visible on this grant | 403 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 scope | Preset | Tools in the session |
|---|---|---|
email.read | read | list_inboxes, list_threads, list_messages, get_message |
email.draft | read_draft | the four above plus create_draft |
email.send | read_draft_send | all 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:
- Load the connection row. If its status is not
active, orrevoked_atis set, orexpires_athas passed, refuse with “Credential or its authorizer is no longer active.” - Recompute the owner’s human grant from their role, project assignments and inbox assignments as they stand now.
- Intersect three times. Projects narrow to the connection’s single project inside the owner’s current project list. Inboxes narrow to the ticked set inside the owner’s current inbox set. Scopes narrow to the owner’s current scopes, then the connection’s stored preset, then the scopes stamped on this access token.
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:
- Registration at
/oauth/register, capped at 20 per hour per IP./authorizeand/oauth/tokenallow 120 per 60 seconds. A registered client record itself expires after 30 days. - Redirect URIs must be HTTPS, or
httponlocalhost,127.0.0.1or[::1]. No fragment, no userinfo, at most ten per client. code_verifiermust be 43 to 128 characters from the unreserved set, checked before anything is looked up. A bad shape is 400invalid_grant.redirect_uriat the token endpoint must match the authorization request exactly.- S256 only. Plain PKCE, the implicit flow and the token-exchange grant are all switched off.
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
MCP or API Key for Agent Email? How to Choose
Remote MCP over OAuth or a project-scoped REST key: what each can and cannot do, who authorises it, how revocation differs, and why teams use both.
MCP Email Server: Give Your Agent Client a Real Inbox
How a remote MCP email server works, what the consent screen must show, how to scope a grant to one inbox, and how to verify it before allowing sends.
The MCP Tool Surface Is the Permission Boundary
How the MCP tool surface permission boundary is computed per request, what a filtered tools/call returns, and three ways the tool list shrinks under you.