Skip to content

CTX7-1886: require OAuth on /mcp at connect time - #2959

Draft
fahreddinozcan wants to merge 3 commits into
masterfrom
ctx7-1886-lazy-auth-support
Draft

CTX7-1886: require OAuth on /mcp at connect time#2959
fahreddinozcan wants to merge 3 commits into
masterfrom
ctx7-1886-lazy-auth-support

Conversation

@fahreddinozcan

@fahreddinozcan fahreddinozcan commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

/mcp now 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:

  • Codex starts the OAuth flow the moment it discovers the resource metadata, before sending a single JSON-RPC message. It never reaches a gate.
  • Claude Code exposes its authorize helpers only for servers already flagged when the session started. A challenge mid-conversation fails the turn, and the recovery path appears on the next launch.
  • Zed handles the 401 during server startup, not mid-session.

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=lazy restores 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

  • /mcp challenges on the first request; CONTEXT7_MCP_AUTH_MODE=lazy opts back out
  • In lazy mode the quota trigger defers to the backend, which already meters anonymous requests per client IP and reports the balance on every response (Context7-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 call
  • Challenge shape per client: HTTP 401 + WWW-Authenticate for spec-compliant clients, _meta["mcp/www_authenticate"] on a CallToolResult for ChatGPT, which ignores a bare 401
  • Tools advertise securitySchemes via the tool _meta that registerTool forwards — no SDK internals touched
  • /mcp and /mcp/oauth share one resolveAuthState, so they cannot drift on what counts as signed in
  • WWW-Authenticate added to Access-Control-Expose-Headers; without it a browser client sees the 401 but not the challenge
  • POST under a media type express.json() does not parse is a 415, rather than reaching the handler with a body the gate could not inspect
  • RFC 9728 path-suffixed PRM variants, plus resource_documentation
  • The anonymous sign-in elicitation is removed

Testing

115 tests. test/auth-mode.test.ts drives the built binary and asserts the new default: 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. The integration suite runs with CONTEXT7_MCP_AUTH_MODE=lazy, since it exercises anonymous protocol behaviour.

For the lazy path end to end:

cd packages/mcp && pnpm install && pnpm run build

# a backend reporting two free requests, then refusing — proxies the real API
FREE_CALLS=2 node scripts/quota-stub-backend.mjs

CONTEXT7_MCP_AUTH_MODE=lazy CONTEXT7_API_URL=http://localhost:3099/api \
  node dist/index.js --transport http --port 3000

node scripts/lazy-auth-probe.mjs

Verified against that harness and against production context7.com: the quota headers parse from a real response, RateLimit-Reset resolves to the first of next month, and refused calls never reach the backend.

@linear-code

linear-code Bot commented Jul 29, 2026

Copy link
Copy Markdown

CTX7-1886

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
fahreddinozcan force-pushed the ctx7-1886-lazy-auth-support branch from 89a1455 to 103a068 Compare August 7, 2026 08:39
@mintlify

mintlify Bot commented Aug 7, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
context7 🟢 Ready View Preview Aug 7, 2026, 8:40 AM

💡 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.
@fahreddinozcan fahreddinozcan changed the title CTX7-1886: lazy authentication on /mcp CTX7-1886: require OAuth on /mcp at connect time Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant