fix(mcp): stop rejecting valid API keys on non-200 upstream statuses - #35
Merged
Merged
Conversation
verify_late_api_key returned `response.status_code == 200`, so every non-200 collapsed into "invalid token", and `except Exception: return False` swallowed the 5s timeout. verify_token then returned None and fastmcp emitted a 401 invalid_token whose text tells users to clear their credentials and re-register. 402 and 429 prove the key is valid: the Zernio API emits them only after authenticate() has already resolved the credential. Classify the upstream status into VALID / INVALID / UNKNOWN instead of a bool, cache positives only (sha256 keys, 10k LRU cap, 60s fresh / 1h grace on UNKNOWN), and raise AuthenticationError (HTTP 400) when a token cannot be verified rather than reporting it as invalid. An unseen token is still refused during an outage. Aug 1-3 2026: 32,190 x 402, 7,034 x 429 and 878 x 500 false rejections, plus 2,638 x 504 in a single 30-minute window. The 429s were self-inflicted: verifying on every request burned the caller's own rate-limit bucket.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
invalid_token, telling users to clear their credentials and re-register, wheneverzernio.comanswered with anything other than a 200.authenticate()has already resolved the credential, so treating them as auth failures inverted a distinction the API was built to expose.The bug
src/late/mcp/auth.pydidreturn response.status_code == 200, collapsing every non-200 into "invalid token", andexcept Exception: return Falseswallowed the 5s timeout.verify_tokenthen returnedNone, and fastmcp turned that into the 401invalid_tokenchallenge.Measured on the MCP's own verification traffic (
GET /api/v1/accounts,python-httpx), Aug 1-3 2026:One customer's valid key was rejected for roughly 26 hours (Aug 1 00:16 to Aug 2 15:46). The 429s were self-inflicted: verifying on every single request burned the caller's own rate-limit bucket.
Changes
Verificationenum (VALID / INVALID / UNKNOWN) replaces the bool that was the bug._classify: 200, 402, 429 -> VALID; 401, 403 -> INVALID; everything else -> UNKNOWN.apikey:{keyHash}), 1h grace window used only on UNKNOWN.AuthenticationError, which surfaces as HTTP 400 with a truthful message, never the 401 that triggers credential-clearing.Safety
server.py:_get_clientre-presents the caller's own bearer to the Zernio API on every tool call, where it is rejected.Behaviour note
The auth middleware is app-level, not route-level, so it wraps
/healthand the.well-knowndiscovery routes too. During an upstream outage a request that carries a bearer to those routes now returns 400 where it previously returned 200. Railway's health check sends noAuthorizationheader and is unaffected.Testing
tests/test_mcp_auth_verification.py, 7 tests.httpx.MockTransportonly, no mocking of our own functions.auth.pypreviously had zero coverage.None), the security ruling (unknown token during an outage must raise, not grant), the grace window, INVALID evicting a cached entry, a genuinely bad key still being rejected, and the LRU bound.uv run pytest tests/ -q-> 202 passed, 14 skipped.uv run ruff check src tests-> clean.Follow-ups (not in this PR)
Zernio token verification inconclusive: HTTP %swarning. It is the only signal if/api/v1/accountsis ever renamed or starts redirecting, which would make every token UNKNOWN./healthnever exercises auth, which is why a 26h auth outage ran with a green container and no alert.Retry-Afterinstead of 400 on the UNKNOWN path. Achievable by overridingget_middleware(), but it re-implements a framework method body and needs its own canary test.scripts/smoketest_streamable_http.py. It has been dead since the FastMCP migration (it importslate.mcp.routes, which does not exist).Crisp
https://app.crisp.chat/website/20dea5d6-a684-4c80-b097-2258b0b41421/inbox/session_f3d1f686-cadd-4832-bae3-32fa54047715/