Skip to content

CTX7-1764: lazy authentication on /mcp - #2958

Closed
fahreddinozcan wants to merge 2 commits into
masterfrom
ctx7-1764-lazy-auth
Closed

CTX7-1764: lazy authentication on /mcp#2958
fahreddinozcan wants to merge 2 commits into
masterfrom
ctx7-1764-lazy-auth

Conversation

@fahreddinozcan

@fahreddinozcan fahreddinozcan commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Anonymous clients keep working; the server issues an OAuth challenge only when an unauthenticated caller hits a protected tool or spends its anonymous allowance. Needs upstash/context7app#821 (removes the sign-in prompt header this replaces).

  • Gate tools/call at the HTTP layer, before the transport streams a 200 — initialize and tools/list always pass
  • Triggers: CONTEXT7_PROTECTED_TOOLS (by name) and a per-client Redis quota, CONTEXT7_ANON_FREE_CALLS, default 5, fail-open
  • Challenge shape per client family: HTTP 401 + WWW-Authenticate by default, _meta["mcp/www_authenticate"] on a failed CallToolResult for ChatGPT/Codex, which ignore a bare 401
  • Advertise per-tool securitySchemes in tools/list; injected by wrapping the SDK handler, since registerTool drops unknown config keys
  • Serve the RFC 9728 path-suffixed PRM variants and resource_documentation
  • Drop the anonymous sign-in elicitation and the X-Context7-Auth-Prompt reader

Testing

scripts/lazy-auth-probe.mjs drives both client families over raw HTTP and prints the status, header and _meta each one sees:

docker compose -f scripts/docker-compose.redis.yml up -d
UPSTASH_REDIS_REST_URL=http://localhost:8079 UPSTASH_REDIS_REST_TOKEN=local_token \
  CONTEXT7_ANON_FREE_CALLS=3 node dist/index.js --transport http --port 3000
node scripts/lazy-auth-probe.mjs

Ran against a local server: spec client gets 401 + Bearer error="invalid_token", error_description=…, resource_metadata=…, scope="profile email" on call 4; Codex UA gets 200 carrying the same challenge in _meta; both PRM paths resolve. 71 unit tests pass.

Adds a lazy-auth gate to /mcp so anonymous clients can connect, list
tools, and call public tools without credentials, but receive an HTTP
401 + WWW-Authenticate challenge when they hit a protected tool or
exhaust the anonymous call quota.

- New `src/lib/auth/lazy-auth.ts`: evaluates auth state before the
  transport streams a 200. Gates only `tools/call`; initialize and
  tools/list always pass. Two configurable triggers: PROTECTED_TOOLS
  (by name, via CONTEXT7_PROTECTED_TOOLS env) and per-client anonymous
  quota (CONTEXT7_ANON_FREE_CALLS, default 5, Redis-backed, fail-open).
- `src/index.ts`: handleMcpRequest takes mode "lazy"|"required";
  /mcp → lazy, /mcp/oauth → required (unchanged eager behavior).
  buildWwwAuthenticate emits RFC 6750 header pointing at the PRM doc.
- `test/lazy-auth.test.ts`: 11 unit tests covering pass-through,
  authenticated bypass, protected-tool gate, quota allowance + overflow,
  per-client isolation, quota disable (=0), and header format.
- `scripts/repro-quota.mjs`: end-to-end repro script.
- `scripts/docker-compose.redis.yml`: local Redis for development.
Lazy auth only helps if the calling client recognises the challenge, and
the two client families disagree on what one looks like. The gate now
picks the shape per caller:

- HTTP 401 + `WWW-Authenticate` for spec-compliant clients (Claude, VS
  Code, Cursor, Cline, Zed). Unchanged, and still the default for any
  client we don't recognise.
- HTTP 200 wrapping a failed `CallToolResult` with the same challenge in
  `_meta["mcp/www_authenticate"]` for ChatGPT and Codex, which do not
  raise their link-account UI from a bare 401. Selected by User-Agent,
  overridable via `CONTEXT7_TOOL_RESULT_CHALLENGE_CLIENTS`.

Both carry `error_description`, which the OpenAI clients use to tell an
auth failure from a tool bug. Tools now also advertise `securitySchemes`
in `tools/list` (`noauth` + `oauth2` for public tools, `oauth2` alone for
protected ones) so those clients know a tool is callable before an
account is linked. The SDK's `registerTool` drops config keys it doesn't
know, so that field is injected by wrapping the handler it installs —
`test/tool-security.test.ts` asserts it on the wire so an SDK change
fails the build rather than silently reaching clients.

Discovery: serve the RFC 9728 path-suffixed metadata variants
(`/.well-known/oauth-protected-resource/mcp`) that clients try first for
an endpoint with a path, and add `resource_documentation`.

Also removes the anonymous sign-in elicitation. Lazy auth drives the
client's own OAuth flow, so nudging the user to run `ctx7 setup` in a
terminal is a second, worse path to the same place: it interrupts the
turn, needs the client to advertise `elicitation`, and leaves the caller
anonymous either way. Drops `auth-prompt.ts`, the
`X-Context7-Auth-Prompt` reader in api.ts and the `shouldPrompt` flag it
set; the backend half is removed in upstash/context7app.

`scripts/lazy-auth-probe.mjs` (was repro-quota.mjs) drives both families
over raw HTTP and prints the status, header and `_meta` each one sees.
@linear-code

linear-code Bot commented Jul 29, 2026

Copy link
Copy Markdown

CTX7-1764

@fahreddinozcan

Copy link
Copy Markdown
Collaborator Author

Moved to CTX7-1886: #2959

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