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

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions