Skip to content

plugin-mcp 3.84.1: docs/code mismatch on /api/mcp Authorization header — code accepts only Bearer X, Payload convention would be payload-mcp-api-keys API-Key X #16572

@AdrianRusan-Bono

Description

@AdrianRusan-Bono

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)

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions