CTX7-1886: require OAuth on /mcp at connect time - #2959
Draft
fahreddinozcan wants to merge 3 commits into
Draft
Conversation
Anonymous callers keep connecting, listing and calling tools exactly as before. The server now answers with an OAuth challenge, rather than a rate-limit error, once a caller has spent the free monthly requests for their machine or invokes a tool named in CONTEXT7_PROTECTED_TOOLS. The quota trigger defers to the backend rather than counting here. It already meters anonymous requests per client IP and reports the balance on every /api/v2 response via Context7-Quota-Tier and RateLimit-Remaining, and both sides key on the same identity because this server forwards the caller's IP as mcp-client-ip. quota-state.ts mirrors that verdict; a rival counter would drift from the real quota and challenge users who still had requests left. The verdict lands one request ahead of the wall, since the backend reports zero remaining on the last allowed call. So the challenge is issued before the next call is proxied: users see a sign-in prompt instead of a 429, and the refused call costs no quota. The mirror is an in-process TTL cache, not a shared store — the v2 server is stateless and the package has no Redis client. Exhaustion is monotonic until the quota resets, so instances that learn it independently cannot disagree in a way that matters; the cost is at most one extra proxied call per instance per client. Challenge shape is chosen per client, because the two families disagree on what one looks like. Spec-compliant clients (Claude, VS Code, Cursor, Cline, Zed, Codex CLI) get HTTP 401 + WWW-Authenticate. ChatGPT gets a CallToolResult carrying _meta["mcp/www_authenticate"], which is what raises its link-account UI; a bare 401 does not. Both carry the same RFC 6750 string with error_description, which the OpenAI clients use to tell an auth failure from a tool bug. Tools advertise securitySchemes through the tool _meta that registerTool already forwards. Also drops the anonymous sign-in elicitation: it interrupted the turn to ask the user to run `ctx7 setup` in a terminal instead of driving the client's own OAuth flow. The backend half is removed in upstash/context7app#822. Verified end to end against scripts/quota-stub-backend.mjs, which emits the real header shape: with two requests left, calls 1-2 pass, call 3 is refused without reaching the backend, a ChatGPT User-Agent gets the _meta form instead, and the same client with a key gets 200.
fahreddinozcan
force-pushed
the
ctx7-1886-lazy-auth-support
branch
from
August 7, 2026 08:39
89a1455 to
103a068
Compare
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Testing the handover against canned responses proves the mechanism but makes a poor demo: the tools answer with placeholder libraries, so it is hard to tell a working gate from a broken server. The stub now forwards to https://context7.com and rewrites only RateLimit-Remaining, so tools return genuine documentation and the only thing under your control is when the free requests run out. UPSTREAM=none restores the canned mode for offline runs. Strips the upstream's own quota headers before merging the rewritten ones. Keeping both meant the client received two RateLimit-Remaining values under different casing, read the upstream's, and never saw the quota reach zero.
BREAKING CHANGE: an anonymous client on /mcp is now challenged on its first request, including initialize. Set CONTEXT7_MCP_AUTH_MODE=lazy to restore the previous behaviour, where anonymous callers connect and spend their free monthly requests before being asked to sign in. Testing the lazy gate against real clients showed the challenge lands in the wrong place. Every client we tried runs OAuth natively at connect time: Codex starts the flow the moment it discovers the resource metadata, without sending a single JSON-RPC message; Claude Code exposes its authorize helpers only for servers already flagged when the session started; Zed handles the 401 during server startup. The same challenge raised mid-conversation is handled far worse — it fails the turn in progress, and on Claude Code the recovery path does not appear until the next session, so the user loses their turn and has to know to run /mcp. Challenging on connect trades the anonymous trial for a prompt the client knows how to show. Deployments that would rather keep the trial can set the flag; the gate, the backend-driven quota mirror and the per-client challenge shapes all still work in that mode and are unchanged. The integration suite runs with the flag set, since it exercises anonymous protocol behaviour. test/auth-mode.test.ts covers the new default against the built binary: anonymous initialize and tools/list are refused with a challenge carrying resource_metadata and scope, a credential gets through, and the discovery document stays public.
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.
/mcpnow asks clients to authenticate when they connect, instead of after a number of free requests. Pairs with upstash/context7app#822, which removes the sign-in prompt header this replaces.Rebuilt on top of MCP v2 (#2843).
Why connect-time, not lazy
Lazy auth was built and tested first. Against real clients the challenge lands in the wrong place:
All verified live through a tunnel. So the free-requests-then-challenge design produces a broken turn on exactly the clients it was meant to serve, while a connect-time challenge gets each client's native sign-in prompt.
This is a breaking change for anonymous users — that is the trade.
CONTEXT7_MCP_AUTH_MODE=lazyrestores the old behaviour, and the whole lazy path is still tested and supported for deployments that would rather keep the anonymous trial.What is in here
/mcpchallenges on the first request;CONTEXT7_MCP_AUTH_MODE=lazyopts back outContext7-Quota-Tier,RateLimit-Remaining). The MCP mirrors that verdict rather than counting separately, and the balance is known one request ahead, so the challenge is issued before the call is proxied — a sign-in prompt instead of a 429, and no quota spent on the refused callWWW-Authenticatefor spec-compliant clients,_meta["mcp/www_authenticate"]on aCallToolResultfor ChatGPT, which ignores a bare 401securitySchemesvia the tool_metathatregisterToolforwards — no SDK internals touched/mcpand/mcp/oauthshare oneresolveAuthState, so they cannot drift on what counts as signed inWWW-Authenticateadded toAccess-Control-Expose-Headers; without it a browser client sees the 401 but not the challengeexpress.json()does not parse is a 415, rather than reaching the handler with a body the gate could not inspectresource_documentationTesting
115 tests.
test/auth-mode.test.tsdrives the built binary and asserts the new default: anonymousinitializeandtools/listare refused with a challenge carryingresource_metadataandscope, a credential gets through, and the discovery document stays public. The integration suite runs withCONTEXT7_MCP_AUTH_MODE=lazy, since it exercises anonymous protocol behaviour.For the lazy path end to end:
Verified against that harness and against production
context7.com: the quota headers parse from a real response,RateLimit-Resetresolves to the first of next month, and refused calls never reach the backend.