Summary
@payloadcms/plugin-mcp@3.84.1 accepts only Authorization: Bearer <KEY> on /api/mcp, which is inconsistent with Payload's standard <collection-slug> API-Key <KEY> pattern that's used for every other API-key-backed surface in Payload. The plugin README and the Plugins → MCP docs page do not document this — there's no explicit "Authorization must be Bearer only, not payload-mcp-api-keys API-Key X" note anywhere I can find. A consumer reasonably extrapolates from Payload's normal convention, hits a 401, and has to read the source to figure out what's actually expected.
Reproduction
Stack:
payload@3.84.1
@payloadcms/plugin-mcp@3.84.1
- Next.js 15.5.18
- Plugin registered exactly as described in the README (
mcpPlugin({ collections: {...} }))
- API key minted via admin MCP → API Keys → Create New
The Payload-convention form returns 401:
curl -sS -X POST https://<host>/api/mcp \
-H 'Authorization: payload-mcp-api-keys API-Key <KEY>' \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# → HTTP 401, UnauthorizedError
The Bearer form returns 200 with the expected SSE stream:
curl -sS -X POST https://<host>/api/mcp \
-H 'Authorization: Bearer <KEY>' \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# → HTTP 200
# event: message
# data: {"jsonrpc":"2.0","id":1,"result":{"tools":[…]}}
Source
packages/plugin-mcp/src/endpoints/mcp.ts line 13 (and the corresponding compiled dist/endpoints/mcp.js:13):
const apiKey = overrideApiKey ?? req.headers.get('Authorization')?.startsWith('Bearer ')
? req.headers.get('Authorization')?.replace('Bearer ', '').trim()
: null
The .startsWith('Bearer ') hard-codes the Bearer prefix as the only accepted form.
Suggested fix (pick one)
- Accept both forms (preferred — matches Payload convention). Check for
payload-mcp-api-keys API-Key <KEY> first, then fall back to Bearer <KEY>. Keeps the plugin consistent with the rest of Payload's API-key surfaces and removes the documentation footgun.
- Document Bearer-only explicitly. Add a "Calling the endpoint" subsection to the plugin README and the plugin-mcp docs page with a curl example showing
Authorization: Bearer <KEY> and a callout that the standard <collection-slug> API-Key <KEY> form does not authenticate against this endpoint.
The Bearer-only form is also non-trivial to discover because tools/list (the only safe smoke endpoint) returns 401 with no hint about which header format the plugin actually wants.
Latent precedence bug on the same line (separate, smaller, drive-by)
The expression on line 13 has a precedence ambiguity:
const apiKey = overrideApiKey ?? req.headers.get('Authorization')?.startsWith('Bearer ')
? req.headers.get('Authorization')?.replace('Bearer ', '').trim()
: null
?? binds tighter than ? :, so JavaScript parses this as:
const apiKey = (overrideApiKey ?? req.headers.get('Authorization')?.startsWith('Bearer '))
? req.headers.get('Authorization')?.replace('Bearer ', '').trim()
: null
Effect when overrideApiKey is a non-empty string AND the request lacks an Authorization: Bearer … header: the condition is truthy (any string is truthy), the body calls .replace('Bearer ', '') on a header that may be null, optional-chaining short-circuits to undefined, and apiKey === undefined. The intended fallback to overrideApiKey never fires.
The intended logic looks like:
const apiKey =
overrideApiKey ??
(req.headers.get('Authorization')?.startsWith('Bearer ')
? req.headers.get('Authorization')?.replace('Bearer ', '').trim()
: null)
i.e. wrap the ternary in parentheses so ?? is the outer operator.
Also worth noting (small): Accept requirement is undocumented
In the same testing, the tools/list call returns 406 if Accept is anything other than (or missing either side of) application/json, text/event-stream. That's the MCP Streamable HTTP transport spec talking, not the plugin per se, but it'd save the next adopter a debugging cycle to mention it in the plugin README's "calling the endpoint" section alongside the Authorization form.
Environment
- Node 22.22.0
- pnpm 10.14.0
- Next.js 15.5.18
payload@3.84.1
@payloadcms/plugin-mcp@3.84.1
- Tested against a
next dev instance on staging and against the production-shaped Docker image; same behaviour both.
Happy to send a PR for either the "accept both forms" path or the README documentation path — let me know which direction the team prefers.
Summary
@payloadcms/plugin-mcp@3.84.1accepts onlyAuthorization: Bearer <KEY>on/api/mcp, which is inconsistent with Payload's standard<collection-slug> API-Key <KEY>pattern that's used for every other API-key-backed surface in Payload. The plugin README and the Plugins → MCP docs page do not document this — there's no explicit "Authorization must beBeareronly, notpayload-mcp-api-keys API-Key X" note anywhere I can find. A consumer reasonably extrapolates from Payload's normal convention, hits a 401, and has to read the source to figure out what's actually expected.Reproduction
Stack:
payload@3.84.1@payloadcms/plugin-mcp@3.84.1mcpPlugin({ collections: {...} }))The Payload-convention form returns 401:
The Bearer form returns 200 with the expected SSE stream:
Source
packages/plugin-mcp/src/endpoints/mcp.tsline 13 (and the corresponding compileddist/endpoints/mcp.js:13):The
.startsWith('Bearer ')hard-codes the Bearer prefix as the only accepted form.Suggested fix (pick one)
payload-mcp-api-keys API-Key <KEY>first, then fall back toBearer <KEY>. Keeps the plugin consistent with the rest of Payload's API-key surfaces and removes the documentation footgun.Authorization: Bearer <KEY>and a callout that the standard<collection-slug> API-Key <KEY>form does not authenticate against this endpoint.The Bearer-only form is also non-trivial to discover because
tools/list(the only safe smoke endpoint) returns 401 with no hint about which header format the plugin actually wants.Latent precedence bug on the same line (separate, smaller, drive-by)
The expression on line 13 has a precedence ambiguity:
??binds tighter than? :, so JavaScript parses this as:Effect when
overrideApiKeyis a non-empty string AND the request lacks anAuthorization: Bearer …header: the condition is truthy (any string is truthy), the body calls.replace('Bearer ', '')on a header that may benull, optional-chaining short-circuits toundefined, andapiKey === undefined. The intended fallback tooverrideApiKeynever fires.The intended logic looks like:
i.e. wrap the ternary in parentheses so
??is the outer operator.Also worth noting (small):
Acceptrequirement is undocumentedIn the same testing, the
tools/listcall returns 406 ifAcceptis anything other than (or missing either side of)application/json, text/event-stream. That's the MCP Streamable HTTP transport spec talking, not the plugin per se, but it'd save the next adopter a debugging cycle to mention it in the plugin README's "calling the endpoint" section alongside the Authorization form.Environment
payload@3.84.1@payloadcms/plugin-mcp@3.84.1next devinstance on staging and against the production-shaped Docker image; same behaviour both.Happy to send a PR for either the "accept both forms" path or the README documentation path — let me know which direction the team prefers.