Proposal 81 — MCP API Key Validation
Status
Implemented (2026-03-05)
Date
2026-03-05
Problem
The MCP server (mcp_cloud) accepts junk API keys and lets callers create plans,
retry plans, and consume resources without ever verifying the key is real.
Today's auth logic is a binary toggle controlled by PLANEXE_MCP_REQUIRE_AUTH:
| Value | Behavior |
|---|---|
true |
Reject missing/invalid keys with 401/403. |
false |
Accept anything — junk keys, empty keys, no key at all. |
The problem is that false is the default for local Docker deployments, where
there is only an admin user and no OAuth. In that mode, if someone supplies
X-API-Key: garbage, the server silently accepts it, creates plans attributed
to no real user, and provides no feedback that the key is wrong.
This leads to confusion: a user can copy-paste a stale or mistyped key into their MCP client config, believe everything is fine, and later discover that billing, per-key stats, and "Last Used" timestamps never worked.
Current code (http_server.py, _validate_api_key)
if not AUTH_REQUIRED:
# Auth disabled — still resolve the key for attribution but never reject.
if provided_key:
user = await asyncio.to_thread(_resolve_user_from_api_key, provided_key)
if user:
_authenticated_user_api_key_ctx.set(provided_key)
return None # ← always allows the request, even with junk key
Goals
- Reject invalid keys — when a caller explicitly provides an
X-API-Keythat does not match any active key in the database, return an error with a clear message, regardless of deployment mode. - Allow keyless access on localhost — when no key is provided and the server is in local/admin-only mode, continue allowing requests (current behavior).
- Clear error messages — tell the user exactly what went wrong and where to get a valid key.
Non-Goals
- Implementing OAuth on the MCP server (see Proposal 52).
- Rate limiting or abuse prevention (separate concern).
- Changing the
PLANEXE_MCP_REQUIRE_AUTH=truecode path (already correct).
Design
New behavior matrix
REQUIRE_AUTH |
Key provided? | Key valid? | Result |
|---|---|---|---|
true |
No | — | 401 Missing API key |
true |
Yes | No | 403 Invalid API key |
true |
Yes | Yes | Allow (authenticated) |
false |
No | — | Allow (anonymous/admin) |
false |
Yes | No | 403 Invalid API key |
false |
Yes | Yes | Allow (authenticated + attribution) |
The only change from today is row 3 of the false block: when auth is
disabled but a key is provided and does not resolve, the server now
rejects instead of silently ignoring.
The reasoning: if the caller went through the trouble of setting X-API-Key,
they clearly intend to authenticate. Silently accepting a bad key is worse than
telling them it's wrong.
Error response
{
"jsonrpc": "2.0",
"error": {
"code": -32001,
"message": "Invalid API key. Check your key or create a new one at https://home.planexe.org/"
}
}
HTTP status: 403 Forbidden.
For the REQUIRE_AUTH=false case, the error message should also hint that
running without a key is fine for local use:
{
"jsonrpc": "2.0",
"error": {
"code": -32001,
"message": "Invalid API key. Remove the X-API-Key header for local access, or get a valid key at https://home.planexe.org/"
}
}
Code changes
Two changes in mcp_cloud/http_server.py:
1. _validate_api_key — reject invalid keys in local mode
Previously the AUTH_REQUIRED=false branch silently accepted any key.
Now, if a key is provided but does not resolve to a real user, the request
is rejected with 403:
if not AUTH_REQUIRED:
if provided_key:
user = await asyncio.to_thread(_resolve_user_from_api_key, provided_key)
if user:
_authenticated_user_api_key_ctx.set(provided_key)
else:
await _log_auth_rejection(request, reason="invalid_api_key_local")
return JSONResponse(
status_code=403,
content={
"detail": (
"Invalid API key. "
"Remove the X-API-Key header for local access, "
"or get a valid key at https://home.planexe.org/"
)
},
)
return None # No key provided, allow anonymous/admin access
2. enforce_api_key middleware — validate at connection time
Previously, the initialize and other handshake/discovery JSON-RPC methods
were in PUBLIC_JSONRPC_METHODS_NO_AUTH and skipped validation entirely.
A junk key sailed through the MCP handshake unchallenged; the user only
discovered the problem on the first paid tool call.
Now, even for public methods, if a key is provided, it is validated
immediately. This means a bad key is rejected on the very first
initialize request — the MCP connection fails before the client sees any
tools:
if request.method != "OPTIONS" and (
request.url.path.startswith("/mcp") or request.url.path.startswith("/download")
):
is_public = await _is_public_mcp_request_without_auth(request)
# Even for public/discovery methods (initialize, tools/list, etc.),
# validate the API key if one was provided.
if is_public and _extract_api_key(request):
error_response = await _validate_api_key(request)
if error_response:
return _append_cors_headers(request, error_response)
if not is_public:
is_tokenized_download = (
request.url.path.startswith("/download")
and _has_valid_download_token(request)
)
if not is_tokenized_download:
error_response = await _validate_api_key(request)
if error_response:
return _append_cors_headers(request, error_response)
Keyless requests still pass through to public methods as before.
3. enforce_api_key middleware — JSON-RPC error wrapping
When the middleware returns a plain HTTP 401/403 on the /mcp/ Streamable
HTTP endpoint, the MCP SDK interprets it as an OAuth challenge and tries
/.well-known/oauth-authorization-server discovery, which fails with 404
and produces a confusing "Invalid OAuth error response" message.
A new helper _make_jsonrpc_auth_error() wraps auth errors as JSON-RPC
error envelopes with HTTP 200:
async def _make_jsonrpc_auth_error(request, detail):
# Extract JSON-RPC request id from body
request_id = ...
return JSONResponse(
status_code=200,
content={
"jsonrpc": "2.0",
"error": {"code": -32001, "message": detail},
"id": request_id,
},
)
The middleware uses an inner _check_auth() helper that calls
_validate_api_key() and, for Streamable HTTP paths (/mcp, /mcp/),
wraps any error response via _make_jsonrpc_auth_error(). REST endpoints
(/mcp/tools/call, /download) keep plain HTTP status codes.
Interaction with PLANEXE_MCP_API_KEY (shared secret)
The shared-secret check (REQUIRED_API_KEY) only runs when AUTH_REQUIRED=true.
Local mode (AUTH_REQUIRED=false) does not use shared secrets, so no change
is needed there.
Backward Compatibility
- Local users who never set
X-API-Key: no change, requests still allowed. - Local users with a valid key: no change, key resolves and is attributed.
- Local users with a junk key: breaking change — previously silently accepted, now rejected with 403. This is intentional and desirable. The fix is to either remove the key or replace it with a valid one.
- Production (
REQUIRE_AUTH=true): no change at all. - MCP SDK users without a key: keyless connections now succeed for discovery. Paid tools return a clear JSON-RPC error instead of a confusing OAuth/404 message.
Verification
- Start MCP server with
PLANEXE_MCP_REQUIRE_AUTH=false. - Connect via MCP Inspector without
X-API-Key→ should work (anonymous). - Connect with a valid
pex_...key → should work (authenticated, stats tracked). - Connect with
X-API-Key: junk→ connection should fail immediately during theinitializehandshake with a clear JSON-RPC error message. The client should never see the tool list. - Start MCP server with
PLANEXE_MCP_REQUIRE_AUTH=true. - Connect without key → succeeds (initialize is public).
- Call a discovery tool (
example_plans) without key → works. - Call a paid tool (
plan_create) without key → JSON-RPC error: "Missing API key. Create an API key at https://home.planexe.org/" - Connect with junk key → JSON-RPC error at connection time.
- Connect with valid key → everything works.