From 103a0683f4495025764788853a303497843f3f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Fri, 7 Aug 2026 11:39:13 +0300 Subject: [PATCH 1/3] feat(mcp): lazy authentication on the public /mcp endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/lazy-auth-mcp-endpoint.md | 13 + docs/howto/oauth.mdx | 31 +- packages/mcp/mcpb/.mcpbignore | 3 + packages/mcp/scripts/lazy-auth-probe.mjs | 155 ++++++++ packages/mcp/scripts/quota-stub-backend.mjs | 51 +++ packages/mcp/src/index.ts | 138 ++++--- packages/mcp/src/lib/api.ts | 23 +- packages/mcp/src/lib/auth/auth-prompt.ts | 101 ----- packages/mcp/src/lib/auth/lazy-auth.ts | 282 ++++++++++++++ packages/mcp/src/lib/auth/quota-state.ts | 146 +++++++ packages/mcp/src/lib/types.ts | 3 - packages/mcp/test/lazy-auth.test.ts | 407 ++++++++++++++++++++ 12 files changed, 1192 insertions(+), 161 deletions(-) create mode 100644 .changeset/lazy-auth-mcp-endpoint.md create mode 100644 packages/mcp/scripts/lazy-auth-probe.mjs create mode 100644 packages/mcp/scripts/quota-stub-backend.mjs delete mode 100644 packages/mcp/src/lib/auth/auth-prompt.ts create mode 100644 packages/mcp/src/lib/auth/lazy-auth.ts create mode 100644 packages/mcp/src/lib/auth/quota-state.ts create mode 100644 packages/mcp/test/lazy-auth.test.ts diff --git a/.changeset/lazy-auth-mcp-endpoint.md b/.changeset/lazy-auth-mcp-endpoint.md new file mode 100644 index 000000000..f044b7035 --- /dev/null +++ b/.changeset/lazy-auth-mcp-endpoint.md @@ -0,0 +1,13 @@ +--- +"@upstash/context7-mcp": minor +--- + +Lazy authentication on the public `/mcp` endpoint. Anonymous clients still connect, list tools and call 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 listed in `CONTEXT7_PROTECTED_TOOLS`. + +The quota trigger defers to the Context7 backend, which already counts anonymous requests per client IP and reports the balance on every response (`Context7-Quota-Tier`, `RateLimit-Remaining`). The MCP server mirrors that verdict instead of counting separately, so the challenge fires exactly when the real quota runs out. Because the balance is known one request ahead, the challenge is issued before the call is proxied: users get a sign-in prompt instead of a 429, and the refused call costs no quota. + +The challenge is delivered in whichever shape the calling client acts on: an HTTP 401 with `WWW-Authenticate` for spec-compliant clients (Claude, VS Code, Cursor, Cline, Zed, Codex CLI), or a `CallToolResult` carrying `_meta["mcp/www_authenticate"]` for ChatGPT, which does not raise its link-account UI from a bare 401. Tools also advertise `securitySchemes` in their `_meta` so clients know they are callable before an account is linked. + +Note that whether the sign-in prompt opens by itself is up to the client. Claude, Claude Desktop and ChatGPT show an inline connect card and retry the call automatically; terminal clients flag the server and expect the user to start the flow (`/mcp` in Claude Code, `codex mcp login` in Codex CLI). + +This replaces the anonymous sign-in elicitation, which interrupted the turn to ask the user to run `ctx7 setup` in a terminal instead of driving the client's own OAuth flow. diff --git a/docs/howto/oauth.mdx b/docs/howto/oauth.mdx index 1fa6ce552..f594348c3 100644 --- a/docs/howto/oauth.mdx +++ b/docs/howto/oauth.mdx @@ -18,9 +18,19 @@ Context7 MCP server supports OAuth 2.0 authentication for MCP clients that imple | Automatic token refresh | ✅ | ❌ | | Works with stdio transport | ❌ | ✅ | -## Configuration +## Two Endpoints -To use OAuth, change the endpoint from `/mcp` to `/mcp/oauth` in your client configuration: +| Endpoint | Behaviour | +| ------------ | -------------------------------------------------------------------------------------------- | +| `/mcp` | Sign-in is requested only once you have used your free monthly requests, or call a protected tool | +| `/mcp/oauth` | Sign-in is required before the client can connect at all | + +Most clients should use `/mcp`, the default in every Context7 install flow. You can browse +documentation immediately, and the server asks you to sign in only when you reach the free +monthly limit for your machine. Signing in raises that limit substantially. + +Use `/mcp/oauth` when you want authentication enforced up front, for example when every +request must be attributable to a user. ```diff - "url": "https://mcp.context7.com/mcp" @@ -29,15 +39,20 @@ To use OAuth, change the endpoint from `/mcp` to `/mcp/oauth` in your client con ## How It Works -1. Your MCP client connects to the OAuth endpoint -2. You're redirected to Context7 to sign in -3. After signing in, you're redirected back to your client -4. Your client automatically handles token refresh +1. Your MCP client connects and can list and call tools straight away on `/mcp` +2. When you cross the free limit, the server answers the tool call with an OAuth challenge + rather than an error, pointing your client at Context7 +3. You're redirected to Context7 to sign in +4. After signing in, you're redirected back and your client retries the same call +5. Your client automatically handles token refresh from then on + +On `/mcp/oauth` the challenge comes on the first request instead, so steps 3 to 5 happen +before you can use any tool. -**Authentication required after setup.** Most clients won't authenticate automatically. After adding the OAuth endpoint, you'll need to explicitly authenticate through your client's MCP settings. For example, in Claude Code run `/mcp`, select the server, and choose "Authenticate". +**Some clients need you to start the sign-in yourself.** Whether the OAuth flow opens on its own depends on the client, not on Context7. Claude, Claude Desktop and ChatGPT show an inline connect prompt and retry the call once you finish. Terminal clients generally do not: in Claude Code run `/mcp`, select the server and choose "Authenticate"; in Codex CLI run `codex mcp login `. ## Client Support -OAuth authentication requires your MCP client to support the [MCP OAuth specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). If your client doesn't support OAuth, use [API key authentication](/howto/api-keys) instead. +OAuth authentication requires your MCP client to support the [MCP OAuth specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). If your client doesn't support OAuth, use [API key authentication](/howto/api-keys) instead — an API key raises your limit the same way signing in does, and works on both endpoints. diff --git a/packages/mcp/mcpb/.mcpbignore b/packages/mcp/mcpb/.mcpbignore index febd03e07..f93c57e08 100644 --- a/packages/mcp/mcpb/.mcpbignore +++ b/packages/mcp/mcpb/.mcpbignore @@ -2,6 +2,9 @@ src/ *.ts +# Dev tooling +scripts/ + # Config files eslint.config.js prettier.config.mjs diff --git a/packages/mcp/scripts/lazy-auth-probe.mjs b/packages/mcp/scripts/lazy-auth-probe.mjs new file mode 100644 index 000000000..9559ffa4d --- /dev/null +++ b/packages/mcp/scripts/lazy-auth-probe.mjs @@ -0,0 +1,155 @@ +// Drives the lazy-auth gate end to end over raw HTTP and prints what each +// client family actually sees. Raw fetch rather than an MCP client on purpose: +// the status code, the WWW-Authenticate header and the `_meta` challenge are +// the whole contract, and a client library hides all three. +// +// # terminal 1 — a backend that reports two free requests, then refuses +// STUB_REMAINING=2 node scripts/quota-stub-backend.mjs +// +// # terminal 2 +// CONTEXT7_API_URL=http://localhost:3099/api node dist/index.js --transport http --port 3000 +// +// # terminal 3 +// node scripts/lazy-auth-probe.mjs +// +// Env: MCP_URL (default http://localhost:3000/mcp), MAX_CALLS (default 8). +const url = process.env.MCP_URL ?? "http://localhost:3000/mcp"; +const MAX_CALLS = Number(process.env.MAX_CALLS ?? 8); + +// User-Agents that select each challenge shape. See challengeTransportFor(). +const CLIENTS = [ + { label: "spec client (Claude, VS Code, Cursor, Codex CLI, …)", ua: "claude-code/2.1.0" }, + { label: "ChatGPT", ua: "ChatGPT/1.2025.0" }, +]; + +async function rpc(method, params, ua) { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "User-Agent": ua, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + + const raw = await res.text(); + // Responses stream as SSE so headers flush before the tool finishes. + const payload = /^(event|id|data|retry):/m.test(raw) ? sseData(raw) : raw; + let body = null; + let unparsed = null; + try { + body = JSON.parse(payload); + } catch { + // Something other than the server answered: a tunnel interstitial, a proxy + // error page. Keep it so the caller can say so rather than reporting an + // empty result, which reads like the server returned nothing. + unparsed = payload; + } + return { + status: res.status, + wwwAuthenticate: res.headers.get("www-authenticate"), + body, + unparsed, + }; +} + +/** Last SSE event's data payload; a stream may carry a priming event first. */ +function sseData(raw) { + const events = raw.split(/\r?\n\r?\n/).filter((e) => e.includes("data:")); + const last = events[events.length - 1] ?? ""; + return last + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trim()) + .join(""); +} + +function challengeOf(res) { + if (res.status === 401 || res.status === 403) { + return { shape: `HTTP ${res.status}`, header: res.wwwAuthenticate }; + } + const meta = res.body?.result?._meta?.["mcp/www_authenticate"]; + if (Array.isArray(meta) && meta.length > 0) { + return { shape: "200 + _meta[mcp/www_authenticate]", header: meta[0] }; + } + return null; +} + +async function probe({ label, ua }) { + console.log(`\n=== ${label}`); + console.log(` User-Agent: ${ua}`); + + const list = await rpc("tools/list", {}, ua); + if (list.unparsed !== null) { + console.log(` tools/list: ${list.status}, but the body is not JSON. Something in front of`); + console.log(` the server answered: ${list.unparsed.slice(0, 120)}…`); + return false; + } + if (list.body?.error) { + console.log(` tools/list: ${list.status} ${JSON.stringify(list.body.error)}`); + return false; + } + const tools = list.body?.result?.tools ?? []; + console.log(` tools/list: ${list.status} anonymously, ${tools.length} tool(s)`); + for (const tool of tools) { + const schemes = (tool._meta?.securitySchemes ?? []) + .map((s) => (s.type === "oauth2" ? `oauth2(${s.scopes?.join(" ")})` : s.type)) + .join(" + "); + console.log(` - ${tool.name}: ${schemes || "no securitySchemes advertised!"}`); + } + + for (let i = 1; i <= MAX_CALLS; i++) { + const res = await rpc( + "tools/call", + { name: "resolve-library-id", arguments: { query: "routing", libraryName: "react" } }, + ua + ); + const challenge = challengeOf(res); + if (!challenge) { + console.log(` call ${i}: allowed`); + continue; + } + console.log(` call ${i}: CHALLENGED as ${challenge.shape}`); + console.log(` ${challenge.header}`); + console.log(" A real client now runs OAuth and retries this same call."); + return true; + } + + console.log(` no challenge in ${MAX_CALLS} calls. Is the backend reporting quota spent?`); + return false; +} + +async function checkDiscovery() { + const origin = new URL(url).origin; + console.log("\n=== OAuth discovery documents"); + for (const path of [ + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp", + ]) { + const res = await fetch(origin + path); + let detail = ""; + if (res.ok) { + try { + const doc = await res.json(); + detail = ` -> authorization_servers=${JSON.stringify(doc.authorization_servers)}, resource=${doc.resource}`; + } catch { + detail = " -> not JSON (a proxy or tunnel answered, not the server)"; + } + } + console.log(` ${path}: ${res.status}${detail}`); + } +} + +let ok = true; +for (const client of CLIENTS) { + ok = (await probe(client)) && ok; +} +await checkDiscovery(); + +console.log( + ok + ? "\nBoth client families were challenged in the shape they understand." + : "\nAt least one probe did not reach a challenge. See above." +); +process.exit(ok ? 0 : 1); diff --git a/packages/mcp/scripts/quota-stub-backend.mjs b/packages/mcp/scripts/quota-stub-backend.mjs new file mode 100644 index 000000000..6193b7b95 --- /dev/null +++ b/packages/mcp/scripts/quota-stub-backend.mjs @@ -0,0 +1,51 @@ +// Stub of context7app's /api/v2 surface, emitting the exact quota headers the +// real middleware attaches (createQuotaHeaders / createMonthlyQuota429Response). +// REMAINING is decremented per request so we can watch the handover. +import { createServer } from "node:http"; + +let remaining = Number(process.env.STUB_REMAINING ?? 2); +const LIMIT = Number(process.env.STUB_LIMIT ?? 200); +const RESET = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; + +createServer((req, res) => { + const url = new URL(req.url, "http://localhost"); + const quotaHeaders = { + "RateLimit-Limit": String(LIMIT), + "RateLimit-Remaining": String(Math.max(0, remaining)), + "RateLimit-Reset": String(RESET), + "Context7-Quota-Tier": "anonymous", + }; + + if (remaining <= 0) { + console.log(`${url.pathname} -> 429 (quota spent)`); + res.writeHead(429, { "Content-Type": "application/json", ...quotaHeaders }); + res.end(JSON.stringify({ error: "Quota Exceeded", message: "Monthly quota exceeded." })); + return; + } + + remaining -= 1; + quotaHeaders["RateLimit-Remaining"] = String(Math.max(0, remaining)); + console.log(`${url.pathname} -> 200 (remaining now ${quotaHeaders["RateLimit-Remaining"]})`); + + res.writeHead(200, { "Content-Type": "application/json", ...quotaHeaders }); + if (url.pathname.endsWith("/v2/libs/search")) { + res.end( + JSON.stringify({ + results: [ + { + id: "/vercel/next.js", + title: "Next.js", + description: "stub", + branch: "main", + lastUpdateDate: "2026-01-01", + state: "finalized", + totalTokens: 1, + totalSnippets: 1, + }, + ], + }) + ); + } else { + res.end(JSON.stringify({ data: "stub docs" })); + } +}).listen(3099, () => console.log("stub backend on :3099")); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index b63ff3657..dbcf6a465 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -22,8 +22,17 @@ import { AUTH_SERVER_URL, OPENAI_APPS_CHALLENGE_TOKEN, } from "./lib/constants.js"; -import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js"; import { getClientIp } from "./lib/client-ip.js"; +import { + AUTH_SCOPES, + buildHttpChallenge, + buildToolResultChallenge, + buildWwwAuthenticate, + challengeTransportFor, + evaluateLazyAuth, + resolveAuthState, + toolAuthMeta, +} from "./lib/auth/lazy-auth.js"; /** Default HTTP server port */ const DEFAULT_PORT = 3000; @@ -229,6 +238,7 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f openWorldHint: true, idempotentHint: true, }, + _meta: toolAuthMeta("resolve-library-id"), }, async ({ query, libraryName }: { query: string; libraryName: string }, toolCtx) => { const ctx = getClientContext(toolCtx); @@ -236,7 +246,6 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f if (!searchResponse.results || searchResponse.results.length === 0) { const text = searchResponse.error ?? "No libraries found matching the provided name."; - maybeElicitAuthSignIn(server, ctx); return { content: [ { @@ -249,7 +258,6 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f const resultsText = formatSearchResults(searchResponse); const responseText = `Available Libraries:\n\n${resultsText}`; - maybeElicitAuthSignIn(server, ctx); return { content: [ { @@ -291,11 +299,11 @@ Do not call this tool more than 3 times per question.`, openWorldHint: true, idempotentHint: true, }, + _meta: toolAuthMeta("query-docs"), }, async ({ query, libraryId }: { query: string; libraryId: string }, toolCtx) => { const ctx = getClientContext(toolCtx); const response = await fetchLibraryContext({ query, libraryId }, ctx); - maybeElicitAuthSignIn(server, ctx); return { content: [ { @@ -328,6 +336,10 @@ async function main() { "Access-Control-Allow-Headers", "Content-Type, MCP-Session-Id, MCP-Protocol-Version, Mcp-Method, Mcp-Name, X-Context7-API-Key, Context7-API-Key, X-API-Key, Authorization" ); + // WWW-Authenticate is not CORS-safelisted, so without this a browser + // client sees the 401 but not the challenge that tells it where to + // authenticate. + res.setHeader("Access-Control-Expose-Headers", "MCP-Session-Id, WWW-Authenticate"); if (req.method === "OPTIONS") { res.sendStatus(200); return; @@ -381,54 +393,88 @@ async function main() { onerror: (error) => console.error("MCP node adapter error:", error), }); + // Invariant for the process, so it is built once rather than per request. + const baseUrl = new URL(RESOURCE_URL).origin; + const advisoryWwwAuthenticate = buildWwwAuthenticate(baseUrl); + const handleMcpRequest = async ( req: express.Request, res: express.Response, - requireAuth: boolean + mode: "lazy" | "required" ) => { try { const apiKey = extractApiKey(req); - const baseUrl = new URL(RESOURCE_URL).origin; + const clientIp = getClientIp(req); - // OAuth discovery info header, used by MCP clients to discover the authorization server + // OAuth discovery hint, advisory on ordinary responses. // TODO: @modelcontextprotocol/server now ships canonical OAuth helpers // (bearerAuthChallengeResponse, buildOAuthProtectedResourceMetadata, // oauthMetadataResponse) — replace this hand-rolled header and the // /.well-known/oauth-protected-resource route with them. - res.set( - "WWW-Authenticate", - `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` - ); + res.set("WWW-Authenticate", advisoryWwwAuthenticate); + + // express.json() only populates req.body for application/json, while the + // handler falls back to re-reading the raw stream when it gets no parsed + // body. Without this, a POST under any other media type would reach the + // tool with req.body undefined — invisible to the gate below, which + // reads that body to find the tool call. + if (req.method === "POST" && req.body === undefined) { + return res.status(415).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "Content-Type must be application/json." }, + id: null, + }); + } - if (requireAuth) { - if (!apiKey) { + // One definition of "signed in" for both endpoints. Deferred so the + // lazy path only pays for JWT verification once a request is gateable. + const authState = () => resolveAuthState(apiKey, validateJWT, isJWT); + + if (mode === "required") { + // Eager auth: challenge every request, including initialize/tools/list. + const auth = await authState(); + if (!auth.authenticated) { + const message = apiKey + ? (auth.error ?? "Invalid token. Please re-authenticate.") + : "Authentication required. Please authenticate to use this MCP server."; + res.set("WWW-Authenticate", buildWwwAuthenticate(baseUrl, "invalid_token", message)); return res.status(401).json({ jsonrpc: "2.0", - error: { - code: -32001, - message: "Authentication required. Please authenticate to use this MCP server.", - }, + error: { code: -32001, message }, id: null, }); } - - if (isJWT(apiKey)) { - const validationResult = await validateJWT(apiKey); - if (!validationResult.valid) { - return res.status(401).json({ - jsonrpc: "2.0", - error: { - code: -32001, - message: validationResult.error || "Invalid token. Please re-authenticate.", - }, - id: null, - }); + } else { + // Lazy auth: connect and list anonymously; challenge only once an + // unauthenticated caller reaches a protected tool or spends its free + // monthly requests. Decided here, before the handler streams a 200. + const challenge = await evaluateLazyAuth({ + body: req.body, + resolveAuth: authState, + clientIp, + sessionId: extractHeaderValue(req.headers["mcp-session-id"]), + }); + if (challenge) { + res.set( + "WWW-Authenticate", + buildWwwAuthenticate(baseUrl, challenge.error, challenge.message) + ); + // ChatGPT only raises its link-account UI for a challenge carried in + // the tool result; everyone else needs the transport-level 401. A + // batch always takes the 401, since one result cannot refuse many + // messages. Same challenge string either way. + const transport = Array.isArray(req.body) + ? "http-401" + : challengeTransportFor(extractHeaderValue(req.headers["user-agent"])); + if (transport === "tool-result") { + return res.status(200).json(buildToolResultChallenge(challenge, baseUrl)); } + return res.status(401).json(buildHttpChallenge(challenge)); } } const context: ClientContext = { - clientIp: getClientIp(req), + clientIp, apiKey: apiKey, clientInfo: extractClientInfoFromUserAgent(req.headers["user-agent"]), transport: "http", @@ -451,12 +497,12 @@ async function main() { // Anonymous access endpoint - no authentication required app.all("/mcp", async (req, res) => { - await handleMcpRequest(req, res, false); + await handleMcpRequest(req, res, "lazy"); }); // OAuth-protected endpoint - requires authentication app.all("/mcp/oauth", async (req, res) => { - await handleMcpRequest(req, res, true); + await handleMcpRequest(req, res, "required"); }); app.get("/ping", (_req: express.Request, res: express.Response) => { @@ -465,17 +511,23 @@ async function main() { // OAuth 2.0 Protected Resource Metadata (RFC 9728) // Used by MCP clients to discover the authorization server - app.get( - "/.well-known/oauth-protected-resource", - (_req: express.Request, res: express.Response) => { - res.json({ - resource: RESOURCE_URL, - authorization_servers: [AUTH_SERVER_URL], - scopes_supported: ["profile", "email"], - bearer_methods_supported: ["header"], - }); - } - ); + const protectedResourceMetadata = (_req: express.Request, res: express.Response) => { + res.json({ + resource: RESOURCE_URL, + authorization_servers: [AUTH_SERVER_URL], + scopes_supported: [...AUTH_SCOPES], + bearer_methods_supported: ["header"], + resource_documentation: `${AUTH_SERVER_URL}/docs/howto/oauth`, + }); + }; + + app.get("/.well-known/oauth-protected-resource", protectedResourceMetadata); + + // Path-suffixed variants (RFC 9728 section 3.1). A client connecting to + // /mcp or /mcp/oauth tries these before falling back to the root document, + // so serving them saves a 404 round trip on every OAuth discovery. + app.get("/.well-known/oauth-protected-resource/mcp", protectedResourceMetadata); + app.get("/.well-known/oauth-protected-resource/mcp/oauth", protectedResourceMetadata); app.get( "/.well-known/oauth-authorization-server", diff --git a/packages/mcp/src/lib/api.ts b/packages/mcp/src/lib/api.ts index 6bcd4502d..af8f53fda 100644 --- a/packages/mcp/src/lib/api.ts +++ b/packages/mcp/src/lib/api.ts @@ -2,6 +2,8 @@ import { SearchResponse, ContextRequest, ContextResponse, ClientContext } from " import { generateHeaders } from "./encryption.js"; import { Agent, ProxyAgent, setGlobalDispatcher } from "undici"; import { CONTEXT7_API_BASE_URL } from "./constants.js"; +import { readQuotaSignal, recordQuotaSignal } from "./auth/quota-state.js"; +import { quotaFingerprint } from "./auth/lazy-auth.js"; import { readFileSync } from "fs"; import tls from "tls"; @@ -94,10 +96,19 @@ if (PROXY_URL && !PROXY_URL.startsWith("$") && /^(http|https):\/\//i.test(PROXY_ } } -function readPromptSignal(response: Response, context: ClientContext): void { - if (response.headers.get("X-Context7-Auth-Prompt") === "1") { - context.shouldPrompt = true; - } +/** + * Mirror the backend's quota verdict for this caller so the lazy-auth gate can + * challenge on their next request. HTTP only: the verdict is keyed on the + * client IP and consumed by the HTTP gate, so recording it under a stdio + * process's session id would write state nothing can read. + * + * Whether the caller counts as anonymous comes from the backend's own + * `Context7-Quota-Tier`, not from whether a credential was presented — only the + * backend can tell a real key from a string shaped like one. + */ +function recordQuota(response: Response, context: ClientContext): void { + if (context.transport !== "http") return; + recordQuotaSignal(quotaFingerprint(context), readQuotaSignal(response)); } /** @@ -120,7 +131,7 @@ export async function searchLibraries( const headers = generateHeaders(context); const response = await fetch(url, { headers }); - readPromptSignal(response, context); + recordQuota(response, context); if (!response.ok) { const errorMessage = await parseErrorResponse(response, context.apiKey); console.error(errorMessage); @@ -153,7 +164,7 @@ export async function fetchLibraryContext( const headers = generateHeaders(context); const response = await fetch(url, { headers }); - readPromptSignal(response, context); + recordQuota(response, context); if (!response.ok) { const errorMessage = await parseErrorResponse(response, context.apiKey); console.error(errorMessage); diff --git a/packages/mcp/src/lib/auth/auth-prompt.ts b/packages/mcp/src/lib/auth/auth-prompt.ts deleted file mode 100644 index 419a07e5f..000000000 --- a/packages/mcp/src/lib/auth/auth-prompt.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/server"; -import type { ClientContext } from "../types.js"; - -function clientFlagForCli(ide: string | undefined): string { - if (!ide) return ""; - const lower = ide.toLowerCase(); - if (lower.includes("cursor")) return "--cursor"; - if (lower.includes("claude")) return "--claude"; - if (lower.includes("codex")) return "--codex"; - if (lower.includes("opencode")) return "--opencode"; - if (lower.includes("gemini")) return "--gemini"; - return ""; -} - -function buildAuthCommand( - clientIde: string | undefined, - transport: "stdio" | "http" | undefined -): string { - const flag = clientFlagForCli(clientIde); - const transportFlag = transport === "stdio" ? " --stdio" : ""; - return flag - ? `npx ctx7 setup ${flag} --mcp${transportFlag} -y` - : `npx ctx7 setup --mcp${transportFlag}`; -} - -function buildElicitMessage( - clientIde: string | undefined, - transport: "stdio" | "http" | undefined -): string { - const command = buildAuthCommand(clientIde, transport); - return [ - "You're using Context7 anonymously. To unlock free higher rate limits, run this in your terminal:", - "", - ` ${command}`, - "", - "It opens your browser, signs you in, and writes credentials into your MCP client config.", - "After it finishes, disable then re-enable the Context7 MCP server in your editor so the new credentials take effect.", - ].join("\n"); -} - -// User-facing strings double as enum const values: keeps the schema in the -// simpler `enum: [...]` shape, which clients render more reliably than -// `oneOf` with separate `const`/`title`. -const CHOICE_RUN_SETUP = "I'll run the command to sign in"; -const CHOICE_STAY_ANON = "Continue anonymously with smaller limits"; - -/** - * Fires a form-mode elicitation that surfaces a sign-in nudge in the client UI - * when the backend has signaled (via `X-Context7-Auth-Prompt: 1`, captured on - * `ctx.shouldPrompt` in api.ts) that the anonymous caller should be prompted - * to authenticate. - * - * The message is delivered out-of-band to the human via the client, not into - * the tool result the LLM reads, so it does not trip prompt-injection guards. - * - * The backend owns how often this fires: it sets the header at most once per - * MCP session, so the server holds no suppression state — it simply shows the - * dialog whenever the header is present. The command itself is shown in the - * dialog message for the user to copy; the server does not attempt to drive - * the client to run it. - * - * No-op for authenticated callers, when the signal wasn't set, or when the - * client did not advertise the `elicitation` capability. Fire-and-forget: - * never blocks or fails the surrounding tool response. - * - * 2025-era stdio only, by design. The 2026-07-28 protocol revision removed - * the push-style server-to-client request channel, and its replacement — - * returning `inputRequired(...)` from the tool handler — would replace the - * tool result and force a client retry, i.e. gate doc delivery behind the - * nudge. That trade is wrong for a soft hint, so modern-era connections get - * no nudge: `getClientCapabilities()` is undefined there (no initialize - * handshake), so the capability guard below short-circuits. On HTTP the guard - * short-circuits for the same reason — each stateless request runs on a fresh - * server that never saw an initialize. - */ -export function maybeElicitAuthSignIn(server: McpServer, ctx: ClientContext): void { - if (ctx.apiKey || !ctx.shouldPrompt) return; - if (!server.server.getClientCapabilities()?.elicitation) return; - - void server.server - .elicitInput({ - message: buildElicitMessage(ctx.clientInfo?.ide, ctx.transport), - requestedSchema: { - type: "object", - properties: { - choice: { - type: "string", - title: "How would you like to continue?", - enum: [CHOICE_RUN_SETUP, CHOICE_STAY_ANON], - default: CHOICE_RUN_SETUP, - }, - }, - required: ["choice"], - }, - }) - .catch(() => { - // Client may not support elicitation despite the capability flag, or - // the session may have closed before the user responded. Either way, - // a missed nudge should never affect the tool result. - }); -} diff --git a/packages/mcp/src/lib/auth/lazy-auth.ts b/packages/mcp/src/lib/auth/lazy-auth.ts new file mode 100644 index 000000000..144e1e397 --- /dev/null +++ b/packages/mcp/src/lib/auth/lazy-auth.ts @@ -0,0 +1,282 @@ +import { isQuotaExhausted } from "./quota-state.js"; + +/** + * Lazy ("mixed") authentication for the public `/mcp` endpoint. + * + * Anonymous clients can connect, run `initialize`, list tools, and call public + * tools. The server only challenges when an unauthenticated caller crosses one + * of two lines: + * 1. it calls a tool listed in {@link PROTECTED_TOOLS}, or + * 2. the backend reports its free monthly requests spent (see + * `quota-state.ts`, which owns that verdict). + * + * The decision MUST be made at the HTTP layer before the JSON-RPC message + * reaches the MCP handler: with `responseMode: "sse"` the handler flushes a 200 + * as soon as it parses the request, so a tool-level error can no longer become + * a 401 and the client never sees the auth challenge. + * + * The challenge is delivered in one of two shapes, because the two client + * families disagree on what one looks like — see {@link challengeTransportFor}: + * + * - `http-401` (default): HTTP 401 + `WWW-Authenticate`, per the MCP + * authorization spec. Claude, VS Code, Cursor, Cline, Zed and every other + * spec-compliant client pause the call, run OAuth, and retry. + * - `tool-result`: HTTP 200 wrapping a `CallToolResult` with `isError: true` + * and the challenge under `_meta["mcp/www_authenticate"]`. This is what + * ChatGPT reads; a bare 401 does not raise its link-account UI. + * + * Both carry the same RFC 6750 challenge string, so a client that understands + * either one gets the same discovery chain. + */ + +function csvEnv(name: string, fallback = ""): string[] { + return (process.env[name] ?? fallback) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Tools that always require authentication. `tools/list` still advertises them + * to anonymous clients — the challenge fires only on `tools/call`. Set the + * comma-separated `CONTEXT7_PROTECTED_TOOLS` to gate a tool by name. + * + * Caveat: this server cannot validate an opaque Context7 key locally (only the + * backend can), so {@link resolveAuthState} accepts any non-JWT credential. + * Gating a tool here is therefore only meaningful where callers authenticate + * with JWTs, which the server does verify. + */ +export const PROTECTED_TOOLS = new Set(csvEnv("CONTEXT7_PROTECTED_TOOLS")); + +/** Scope advertised in the challenge; mirrors the PRM document's `scopes_supported`. */ +export const AUTH_SCOPES = ["profile", "email"] as const; +const CHALLENGE_SCOPE = AUTH_SCOPES.join(" "); + +/** + * Per-tool authentication requirements, advertised on the tool descriptor. + * OpenAI clients read this to decide whether a tool can run before the user has + * linked an account: `noauth` alone runs immediately, `oauth2` alone forces + * linking first, and both together mean "runs anonymously, links when the + * server asks" — the lazy-auth contract. + * + * The Apps SDK documents this as a top-level field on the tool descriptor, but + * `securitySchemes` is not part of the MCP schema (SEP-1488 is still draft) and + * `registerTool` has no such option, so it is carried in the tool's `_meta`, + * which the SDK does forward. It is advisory either way: the runtime + * `_meta["mcp/www_authenticate"]` challenge is what actually raises the + * link-account UI. + */ +export type SecurityScheme = { type: "noauth" } | { type: "oauth2"; scopes: string[] }; + +export function securitySchemesFor(toolName: string): SecurityScheme[] { + const oauth2: SecurityScheme = { type: "oauth2", scopes: [...AUTH_SCOPES] }; + return PROTECTED_TOOLS.has(toolName) ? [oauth2] : [{ type: "noauth" }, oauth2]; +} + +/** `_meta` block for a tool descriptor, ready to pass to `registerTool`. */ +export function toolAuthMeta(toolName: string): Record { + return { securitySchemes: securitySchemesFor(toolName) }; +} + +export interface AuthState { + /** A credential was presented and accepted (opaque key present, or JWT valid). */ + authenticated: boolean; + /** Why a JWT was rejected, for the eager endpoint's error message. */ + error?: string; +} + +/** + * Resolve whether the caller is authenticated. Opaque Context7 keys are taken + * at face value — the backend is the authority on their validity — while JWTs + * are cryptographically verified here. Both `/mcp` and `/mcp/oauth` decide + * through this function so the two endpoints cannot drift on what counts as + * signed in. + */ +export async function resolveAuthState( + apiKey: string | undefined, + verifyJwt: (token: string) => Promise<{ valid: boolean; error?: string }>, + isJwt: (token: string) => boolean +): Promise { + if (!apiKey) return { authenticated: false }; + if (!isJwt(apiKey)) return { authenticated: true }; + const result = await verifyJwt(apiKey); + return { authenticated: result.valid, error: result.error }; +} + +interface JsonRpcMessage { + method?: string; + id?: unknown; + params?: { name?: string }; +} + +function asMessages(body: unknown): JsonRpcMessage[] { + if (Array.isArray(body)) return body as JsonRpcMessage[]; + if (body && typeof body === "object") return [body as JsonRpcMessage]; + return []; +} + +/** Tool names invoked by `tools/call` in this request (single message or batch). */ +export function toolCallsIn(body: unknown): string[] { + return asMessages(body) + .filter((m) => m.method === "tools/call") + .map((m) => m.params?.name) + .filter((n): n is string => typeof n === "string"); +} + +/** + * The id of the `tools/call` being refused, so the challenge correlates with the + * message that triggered it rather than with whatever came first in a batch. + */ +function challengedId(body: unknown): unknown { + const call = asMessages(body).find((m) => m.method === "tools/call" && m.id !== undefined); + return call?.id ?? null; +} + +/** + * Per-client key for the quota verdict: the same identity the backend keys its + * anonymous quota on, since this server forwards it as `mcp-client-ip`. + * + * The value ultimately comes from `X-Forwarded-For` when present, so it is only + * as trustworthy as the ingress in front of this server. That is the same + * exposure the backend's own IP-keyed quota already has — this gate mirrors that + * decision rather than adding a new trust boundary — but a deployment that + * terminates untrusted traffic directly should strip or pin the header at the + * proxy. + */ +export function quotaFingerprint(opts: { + clientIp?: string; + sessionId?: string; +}): string | undefined { + return opts.clientIp || opts.sessionId || undefined; +} + +export interface Challenge { + /** RFC 6750 error code, echoed in the `WWW-Authenticate` header. */ + error: "invalid_token"; + message: string; + /** Echoes the refused request's id so clients can correlate the rejection. */ + id: unknown; +} + +/** How the challenge must be delivered for the calling client to act on it. */ +export type ChallengeTransport = "http-401" | "tool-result"; + +/** + * User-Agent product tokens for clients that surface an auth prompt from a + * `CallToolResult` rather than an HTTP 401. Override with a comma-separated + * `CONTEXT7_TOOL_RESULT_CHALLENGE_CLIENTS` when a client changes its UA. + */ +const TOOL_RESULT_CHALLENGE_CLIENTS = csvEnv( + "CONTEXT7_TOOL_RESULT_CHALLENGE_CLIENTS", + "openai-mcp,chatgpt" +).map((s) => s.toLowerCase()); + +/** + * Pick the challenge shape for a caller. Defaults to the spec-compliant HTTP + * 401: an unrecognised client is far better served by the standard, and the + * `_meta` form would read to it as an ordinary tool failure. + * + * The match is against the User-Agent's product token only (the `name` in + * `name/version (comment)`), not the whole string, so a client that merely + * mentions one of these names in a comment is not misrouted. + */ +export function challengeTransportFor(userAgent: string | undefined): ChallengeTransport { + if (!userAgent) return "http-401"; + const product = userAgent.split("/")[0].trim().toLowerCase(); + return TOOL_RESULT_CHALLENGE_CLIENTS.includes(product) ? "tool-result" : "http-401"; +} + +/** Strip characters RFC 6750 does not allow inside a quoted parameter value. */ +function sanitizeAuthParam(value: string): string { + return value.replace(/[\\"]/g, "").replace(/\s+/g, " ").trim(); +} + +/** + * Build the RFC 6750 `WWW-Authenticate` value, pointing clients at the Protected + * Resource Metadata document for OAuth discovery. Pass an `error` on a challenge + * response; omit it where the header is purely advisory. + * + * `scope` tells the client which scopes to request, so it does not fall back to + * asking for everything the PRM advertises. + */ +export function buildWwwAuthenticate( + baseUrl: string, + error?: Challenge["error"], + description?: string +): string { + const parts = [ + error ? `error="${error}"` : null, + error && description ? `error_description="${sanitizeAuthParam(description)}"` : null, + `resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`, + `scope="${CHALLENGE_SCOPE}"`, + ].filter(Boolean); + return `Bearer ${parts.join(", ")}`; +} + +/** JSON-RPC body for an `http-401` challenge. */ +export function buildHttpChallenge(challenge: Challenge) { + return { + jsonrpc: "2.0" as const, + error: { code: -32001, message: challenge.message }, + id: challenge.id ?? null, + }; +} + +/** + * JSON-RPC body for a `tool-result` challenge: a successful HTTP response + * carrying a failed tool call, with the RFC 6750 challenge in + * `_meta["mcp/www_authenticate"]`. `error_description` is required — the OpenAI + * clients use its presence to tell an auth failure from a tool bug. + */ +export function buildToolResultChallenge(challenge: Challenge, baseUrl: string) { + return { + jsonrpc: "2.0" as const, + id: challenge.id ?? null, + result: { + isError: true, + content: [{ type: "text" as const, text: challenge.message }], + _meta: { + "mcp/www_authenticate": [buildWwwAuthenticate(baseUrl, challenge.error, challenge.message)], + }, + }, + }; +} + +const PROTECTED_TOOL_MESSAGE = "This tool requires authentication. Please sign in to continue."; +const QUOTA_MESSAGE = + "You have used the free monthly Context7 requests for this machine. " + + "Sign in to continue with a much higher limit."; + +/** + * Decide whether a request to the lazy `/mcp` endpoint is allowed or must be + * challenged. Only `tools/call` is gated; `initialize`, `tools/list`, + * notifications and every other method pass through so anonymous clients can + * connect and discover tools. + * + * `resolveAuth` is a thunk so the caller's credential is only verified once a + * request is actually gateable — most traffic on this endpoint is `initialize` + * and `tools/list`, and verifying a JWT for those means a discarded round trip + * to a remote JWKS on the hottest path. + */ +export async function evaluateLazyAuth(opts: { + body: unknown; + resolveAuth: () => Promise; + clientIp?: string; + sessionId?: string; +}): Promise { + const tools = toolCallsIn(opts.body); + if (tools.length === 0) return undefined; + + const id = challengedId(opts.body); + const protectedTool = tools.some((name) => PROTECTED_TOOLS.has(name)); + const overQuota = isQuotaExhausted(quotaFingerprint(opts)); + if (!protectedTool && !overQuota) return undefined; + + if ((await opts.resolveAuth()).authenticated) return undefined; + + return { + error: "invalid_token", + message: protectedTool ? PROTECTED_TOOL_MESSAGE : QUOTA_MESSAGE, + id, + }; +} diff --git a/packages/mcp/src/lib/auth/quota-state.ts b/packages/mcp/src/lib/auth/quota-state.ts new file mode 100644 index 000000000..70c431c4f --- /dev/null +++ b/packages/mcp/src/lib/auth/quota-state.ts @@ -0,0 +1,146 @@ +/** + * Mirrors, per anonymous client, the Context7 backend's verdict on whether that + * client's free monthly requests are spent. + * + * The backend is the authority. It counts billable requests against + * `ANONYMOUS_MONTHLY_QUOTA_LIMIT` keyed by the caller's IP — which this server + * forwards as `mcp-client-ip` — and reports the balance on every `/api/v2` + * response via `Context7-Quota-Tier` and `RateLimit-Remaining`. This module only + * caches that answer so the lazy-auth gate can act on it before the next tool + * call is proxied. It never counts requests itself: a second counter would + * drift from the real quota and challenge users who still have requests left. + * + * The signal arrives on a response, which is too late to turn *that* request + * into a 401 — the transport has already streamed a 200. So the verdict is + * recorded on the response that reports it and consumed by the next request, at + * the HTTP layer. In practice the handover is invisible, because the backend + * reports `RateLimit-Remaining: 0` on the last allowed call: the following call + * is challenged before the user ever sees a 429. + * + * The cache is in-process, deliberately. The MCP server is stateless (see + * `createMcpHandler` in index.ts) and the package has no Redis client. Across + * several instances each one learns the verdict independently, from the first + * response it sees for that client, so the worst case is one extra proxied call + * per instance per client — bounded, self-correcting, and cheaper than a + * network round trip on every tool call. Because exhaustion is monotonic until + * the quota resets, instances never disagree in a way that matters. + */ + +/** Fallback lifetime when the backend does not say when the quota resets. */ +const DEFAULT_TTL_MS = 60 * 60 * 1000; + +/** Upper bound on a cached verdict, so a bad `RateLimit-Reset` can't pin a client out. */ +const MAX_TTL_MS = 35 * 24 * 60 * 60 * 1000; + +/** + * Cap on tracked clients. Only exhausted clients are stored, so this stays far + * below the number of callers; the bound exists so a burst of distinct + * fingerprints cannot grow the map without limit. + */ +const MAX_ENTRIES = 50_000; + +/** fingerprint -> epoch ms at which the recorded verdict stops applying. */ +const exhaustedUntil = new Map(); + +export interface QuotaSignal { + /** `Context7-Quota-Tier`: the tier the backend actually billed this call to. */ + tier: string | null; + /** `RateLimit-Remaining`: a count, or "unlimited". */ + remaining: string | null; + /** `RateLimit-Reset`: absolute UTC epoch seconds (context7app sends the first of next month). */ + reset: string | null; + /** The backend refused the call outright. */ + rejected: boolean; +} + +export function readQuotaSignal(response: { + status: number; + headers: { get(name: string): string | null }; +}): QuotaSignal { + return { + tier: response.headers.get("Context7-Quota-Tier"), + remaining: response.headers.get("RateLimit-Remaining"), + reset: response.headers.get("RateLimit-Reset"), + rejected: response.status === 429, + }; +} + +/** + * Whether the backend billed this call to the anonymous tier. This — not the + * presence of an `Authorization` header — is what decides whether a caller is + * anonymous, because only the backend can tell a real credential from a string + * that looks like one. + */ +export function isAnonymousTier(signal: QuotaSignal): boolean { + return signal.tier === "anonymous"; +} + +/** Whether this response says the caller has nothing left to spend. */ +export function signalsExhaustion(signal: QuotaSignal): boolean { + // A 429 is checked first and independently of the tier: an edge rate limiter + // or CDN can refuse the call before the backend attaches its quota headers, + // and that refusal still means "stop sending anonymous traffic". + if (signal.rejected) return true; + if (!isAnonymousTier(signal)) return false; + if (signal.remaining === null || signal.remaining === "unlimited") return false; + const remaining = Number(signal.remaining); + return Number.isInteger(remaining) && remaining <= 0; +} + +function ttlMsFrom(resetHeader: string | null): number { + const reset = Number(resetHeader); + if (!Number.isFinite(reset) || reset <= 0) return DEFAULT_TTL_MS; + const ms = reset * 1000 - Date.now(); + if (ms <= 0) return DEFAULT_TTL_MS; + return Math.min(ms, MAX_TTL_MS); +} + +/** Drop expired entries, then oldest-first if still over the cap. */ +function evict(now: number): void { + for (const [key, expiresAt] of exhaustedUntil) { + if (expiresAt <= now) exhaustedUntil.delete(key); + } + // Map preserves insertion order, so the head is the least recently recorded. + while (exhaustedUntil.size > MAX_ENTRIES) { + const oldest = exhaustedUntil.keys().next(); + if (oldest.done) break; + exhaustedUntil.delete(oldest.value); + } +} + +/** Record the backend's verdict for this client, or clear it if they are not anonymous. */ +export function recordQuotaSignal(fingerprint: string | undefined, signal: QuotaSignal): void { + if (!fingerprint) return; + const now = Date.now(); + + // A response the backend billed to a real plan proves this caller is not the + // anonymous one we flagged, so any stale verdict for them is wrong. Note this + // is keyed on what the backend reports, never on whether a credential was + // presented — otherwise one signed-in user could clear the flag for every + // anonymous caller sharing their NAT egress IP. + if (signal.tier !== null && !isAnonymousTier(signal)) { + exhaustedUntil.delete(fingerprint); + return; + } + + if (!signalsExhaustion(signal)) return; + exhaustedUntil.set(fingerprint, now + ttlMsFrom(signal.reset)); + evict(now); +} + +/** Whether the backend has reported this client's free requests spent. */ +export function isQuotaExhausted(fingerprint: string | undefined): boolean { + if (!fingerprint) return false; + const expiresAt = exhaustedUntil.get(fingerprint); + if (expiresAt === undefined) return false; + if (expiresAt <= Date.now()) { + exhaustedUntil.delete(fingerprint); + return false; + } + return true; +} + +/** Test seam. */ +export function resetQuotaState(): void { + exhaustedUntil.clear(); +} diff --git a/packages/mcp/src/lib/types.ts b/packages/mcp/src/lib/types.ts index 3b5a024f0..bbc1ba804 100644 --- a/packages/mcp/src/lib/types.ts +++ b/packages/mcp/src/lib/types.ts @@ -41,7 +41,4 @@ export interface ClientContext { }; transport?: "stdio" | "http"; sessionId?: string; - /** Mutable: set by the upstream API layer when the backend signals the - * client should be prompted to sign in. Read by the auth-prompt wrapper. */ - shouldPrompt?: boolean; } diff --git a/packages/mcp/test/lazy-auth.test.ts b/packages/mcp/test/lazy-auth.test.ts new file mode 100644 index 000000000..96710f1bd --- /dev/null +++ b/packages/mcp/test/lazy-auth.test.ts @@ -0,0 +1,407 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// These env vars change module-level constants, so each case re-imports the +// module. `delete` rather than assignment: `process.env.X = undefined` stores +// the string "undefined", which would leave a bogus entry in PROTECTED_TOOLS. +const ENV_KEYS = ["CONTEXT7_PROTECTED_TOOLS", "CONTEXT7_TOOL_RESULT_CHALLENGE_CLIENTS"] as const; + +function clearEnv() { + for (const key of ENV_KEYS) delete process.env[key]; +} + +async function loadLazyAuth(env: Partial> = {}) { + vi.resetModules(); + clearEnv(); + Object.assign(process.env, env); + const quota = await import("../src/lib/auth/quota-state.js"); + quota.resetQuotaState(); + return { ...(await import("../src/lib/auth/lazy-auth.js")), quota }; +} + +beforeEach(clearEnv); +afterEach(clearEnv); + +function toolCall(name: string, id: unknown = 1) { + return { jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: {} } }; +} + +const anonymous = async () => ({ authenticated: false }); +const signedIn = async () => ({ authenticated: true }); + +/** A response shaped like context7app's, which attaches these on every /api/v2 reply. */ +function backendResponse(headers: Record, status = 200) { + return { status, headers: { get: (name: string) => headers[name] ?? null } }; +} + +const ANON_SPENT = { "Context7-Quota-Tier": "anonymous", "RateLimit-Remaining": "0" }; +const ANON_LEFT = { "Context7-Quota-Tier": "anonymous", "RateLimit-Remaining": "37" }; + +describe("toolCallsIn", () => { + test("extracts tool names from a single message and a batch", async () => { + const { toolCallsIn } = await loadLazyAuth(); + expect(toolCallsIn(toolCall("query-docs"))).toEqual(["query-docs"]); + expect(toolCallsIn([toolCall("a"), { method: "tools/list" }, toolCall("b")])).toEqual([ + "a", + "b", + ]); + }); + + test("ignores methods that are not tools/call", async () => { + const { toolCallsIn } = await loadLazyAuth(); + expect(toolCallsIn({ method: "initialize" })).toEqual([]); + expect(toolCallsIn({ method: "tools/list" })).toEqual([]); + expect(toolCallsIn(undefined)).toEqual([]); + }); +}); + +describe("evaluateLazyAuth — pass-through", () => { + test("initialize and tools/list are never gated", async () => { + const { evaluateLazyAuth } = await loadLazyAuth(); + for (const method of ["initialize", "tools/list", "notifications/initialized"]) { + const challenge = await evaluateLazyAuth({ + body: { method }, + resolveAuth: anonymous, + clientIp: "1.1.1.1", + }); + expect(challenge, method).toBeUndefined(); + } + }); + + test("a public tool passes while the backend still reports requests left", async () => { + const { evaluateLazyAuth, quota } = await loadLazyAuth(); + quota.recordQuotaSignal("1.1.1.1", quota.readQuotaSignal(backendResponse(ANON_LEFT))); + expect( + await evaluateLazyAuth({ + body: toolCall("query-docs"), + resolveAuth: anonymous, + clientIp: "1.1.1.1", + }) + ).toBeUndefined(); + }); + + test("credentials are not verified for requests that cannot be gated", async () => { + const { evaluateLazyAuth } = await loadLazyAuth(); + const resolveAuth = vi.fn(anonymous); + await evaluateLazyAuth({ body: { method: "tools/list" }, resolveAuth, clientIp: "1.1.1.1" }); + await evaluateLazyAuth({ + body: toolCall("query-docs"), + resolveAuth, + clientIp: "1.1.1.1", + }); + // Neither request is gateable, so neither should cost a JWT verification. + expect(resolveAuth).not.toHaveBeenCalled(); + }); +}); + +describe("evaluateLazyAuth — protected tools", () => { + test("an anonymous call to a protected tool is challenged", async () => { + const { evaluateLazyAuth } = await loadLazyAuth({ + CONTEXT7_PROTECTED_TOOLS: "secret-tool", + }); + const challenge = await evaluateLazyAuth({ + body: toolCall("secret-tool", 42), + resolveAuth: anonymous, + clientIp: "1.1.1.1", + }); + expect(challenge).toMatchObject({ error: "invalid_token", id: 42 }); + expect(challenge?.message).toContain("requires authentication"); + }); + + test("a signed-in caller reaches a protected tool", async () => { + const { evaluateLazyAuth } = await loadLazyAuth({ + CONTEXT7_PROTECTED_TOOLS: "secret-tool", + }); + expect( + await evaluateLazyAuth({ + body: toolCall("secret-tool"), + resolveAuth: signedIn, + clientIp: "1.1.1.1", + }) + ).toBeUndefined(); + }); + + test("no tool is protected unless configured", async () => { + const { PROTECTED_TOOLS, evaluateLazyAuth } = await loadLazyAuth(); + expect(PROTECTED_TOOLS.size).toBe(0); + expect( + await evaluateLazyAuth({ + body: toolCall("query-docs"), + resolveAuth: anonymous, + clientIp: "1.1.1.1", + }) + ).toBeUndefined(); + }); +}); + +describe("evaluateLazyAuth — backend monthly quota", () => { + test("challenges once the backend reports the free requests spent", async () => { + const { evaluateLazyAuth, quota } = await loadLazyAuth(); + quota.recordQuotaSignal("8.8.8.8", quota.readQuotaSignal(backendResponse(ANON_SPENT))); + const challenge = await evaluateLazyAuth({ + body: toolCall("query-docs", 7), + resolveAuth: anonymous, + clientIp: "8.8.8.8", + }); + expect(challenge).toMatchObject({ error: "invalid_token", id: 7 }); + expect(challenge?.message).toContain("free monthly Context7 requests"); + }); + + test("a signed-in caller is never gated on quota", async () => { + const { evaluateLazyAuth, quota } = await loadLazyAuth(); + quota.recordQuotaSignal("8.8.8.8", quota.readQuotaSignal(backendResponse(ANON_SPENT))); + expect( + await evaluateLazyAuth({ + body: toolCall("query-docs"), + resolveAuth: signedIn, + clientIp: "8.8.8.8", + }) + ).toBeUndefined(); + }); + + test("the verdict is per client", async () => { + const { evaluateLazyAuth, quota } = await loadLazyAuth(); + quota.recordQuotaSignal("8.8.8.8", quota.readQuotaSignal(backendResponse(ANON_SPENT))); + expect( + await evaluateLazyAuth({ + body: toolCall("query-docs"), + resolveAuth: anonymous, + clientIp: "8.8.8.8", + }) + ).toBeDefined(); + expect( + await evaluateLazyAuth({ + body: toolCall("query-docs"), + resolveAuth: anonymous, + clientIp: "1.2.3.4", + }) + ).toBeUndefined(); + }); + + test("the challenge echoes the tools/call id, not the first id in a batch", async () => { + const { evaluateLazyAuth, quota } = await loadLazyAuth(); + quota.recordQuotaSignal("8.8.8.8", quota.readQuotaSignal(backendResponse(ANON_SPENT))); + const challenge = await evaluateLazyAuth({ + body: [{ jsonrpc: "2.0", id: "list", method: "tools/list" }, toolCall("query-docs", "call")], + resolveAuth: anonymous, + clientIp: "8.8.8.8", + }); + expect(challenge?.id).toBe("call"); + }); +}); + +describe("quota-state — mirroring the backend verdict", () => { + test("a 429 arms the challenge even without the tier header", async () => { + const { quota } = await loadLazyAuth(); + // Edge rate limiters refuse before the backend attaches quota headers. + quota.recordQuotaSignal("9.9.9.9", quota.readQuotaSignal(backendResponse({}, 429))); + expect(quota.isQuotaExhausted("9.9.9.9")).toBe(true); + }); + + test("an authenticated tier clears a verdict recorded while anonymous", async () => { + const { quota } = await loadLazyAuth(); + quota.recordQuotaSignal("9.9.9.9", quota.readQuotaSignal(backendResponse(ANON_SPENT))); + expect(quota.isQuotaExhausted("9.9.9.9")).toBe(true); + quota.recordQuotaSignal( + "9.9.9.9", + quota.readQuotaSignal( + backendResponse({ "Context7-Quota-Tier": "free", "RateLimit-Remaining": "900" }) + ) + ); + expect(quota.isQuotaExhausted("9.9.9.9")).toBe(false); + }); + + test("a response with no tier header never clears an existing verdict", async () => { + const { quota } = await loadLazyAuth(); + quota.recordQuotaSignal("9.9.9.9", quota.readQuotaSignal(backendResponse(ANON_SPENT))); + // A 500 from a proxy says nothing about who the caller is; the verdict stands. + quota.recordQuotaSignal("9.9.9.9", quota.readQuotaSignal(backendResponse({}, 500))); + expect(quota.isQuotaExhausted("9.9.9.9")).toBe(true); + }); + + test("an unlimited tier is never exhausted", async () => { + const { quota } = await loadLazyAuth(); + expect( + quota.signalsExhaustion( + quota.readQuotaSignal( + backendResponse({ + "Context7-Quota-Tier": "anonymous", + "RateLimit-Remaining": "unlimited", + }) + ) + ) + ).toBe(false); + }); + + test("the verdict expires when the backend says the quota resets", async () => { + vi.useFakeTimers(); + try { + const { quota } = await loadLazyAuth(); + const inTwoHours = Math.floor(Date.now() / 1000) + 7200; + quota.recordQuotaSignal( + "9.9.9.9", + quota.readQuotaSignal( + backendResponse({ ...ANON_SPENT, "RateLimit-Reset": String(inTwoHours) }) + ) + ); + expect(quota.isQuotaExhausted("9.9.9.9")).toBe(true); + vi.advanceTimersByTime(2 * 60 * 60 * 1000 + 1000); + expect(quota.isQuotaExhausted("9.9.9.9")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + test("a reset already in the past falls back to a bounded window", async () => { + vi.useFakeTimers(); + try { + const { quota } = await loadLazyAuth(); + quota.recordQuotaSignal( + "9.9.9.9", + quota.readQuotaSignal(backendResponse({ ...ANON_SPENT, "RateLimit-Reset": "1" })) + ); + expect(quota.isQuotaExhausted("9.9.9.9")).toBe(true); + vi.advanceTimersByTime(60 * 60 * 1000 + 1000); + expect(quota.isQuotaExhausted("9.9.9.9")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + test("no fingerprint is a no-op rather than a shared bucket", async () => { + const { quota } = await loadLazyAuth(); + quota.recordQuotaSignal(undefined, quota.readQuotaSignal(backendResponse(ANON_SPENT))); + expect(quota.isQuotaExhausted(undefined)).toBe(false); + }); +}); + +describe("challengeTransportFor", () => { + test("defaults to the spec-compliant 401 for unknown and missing clients", async () => { + const { challengeTransportFor } = await loadLazyAuth(); + for (const ua of [ + undefined, + "claude-code/2.1.0", + "Visual Studio Code/1.108.0", + "Cursor/1.9.2", + "node", + "codex_cli_rs/0.104.0", // Codex CLI needs `codex mcp login`, not the _meta form + ]) { + expect(challengeTransportFor(ua), String(ua)).toBe("http-401"); + } + }); + + test("selects the tool-result form for ChatGPT, case-insensitively", async () => { + const { challengeTransportFor } = await loadLazyAuth(); + expect(challengeTransportFor("openai-mcp/1.0")).toBe("tool-result"); + expect(challengeTransportFor("ChatGPT/1.2025.0")).toBe("tool-result"); + }); + + test("matches the product token, not a mention inside a comment", async () => { + const { challengeTransportFor } = await loadLazyAuth(); + expect(challengeTransportFor("Cursor/1.9.2 (chatgpt-extension)")).toBe("http-401"); + }); + + test("the client list is overridable", async () => { + const { challengeTransportFor } = await loadLazyAuth({ + CONTEXT7_TOOL_RESULT_CHALLENGE_CLIENTS: "some-new-client", + }); + expect(challengeTransportFor("some-new-client/1.0")).toBe("tool-result"); + expect(challengeTransportFor("chatgpt/1.0")).toBe("http-401"); + }); +}); + +describe("challenge payloads", () => { + const base = "https://mcp.context7.com"; + const challenge = { + error: "invalid_token" as const, + message: "Sign in to continue.", + id: 7, + }; + + test("the WWW-Authenticate header carries discovery, scope and a description", async () => { + const { buildWwwAuthenticate } = await loadLazyAuth(); + expect(buildWwwAuthenticate(base)).toBe( + `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource", scope="profile email"` + ); + expect(buildWwwAuthenticate(base, "invalid_token", "Sign in")).toBe( + `Bearer error="invalid_token", error_description="Sign in", ` + + `resource_metadata="${base}/.well-known/oauth-protected-resource", scope="profile email"` + ); + }); + + test("quotes and newlines cannot break header parsing", async () => { + const { buildWwwAuthenticate } = await loadLazyAuth(); + expect(buildWwwAuthenticate(base, "invalid_token", 'say "hi"\nthen bye')).toContain( + 'error_description="say hi then bye"' + ); + }); + + test("the http form is a JSON-RPC error echoing the request id", async () => { + const { buildHttpChallenge } = await loadLazyAuth(); + expect(buildHttpChallenge(challenge)).toEqual({ + jsonrpc: "2.0", + error: { code: -32001, message: "Sign in to continue." }, + id: 7, + }); + }); + + test("the tool-result form carries the challenge in _meta", async () => { + const { buildToolResultChallenge } = await loadLazyAuth(); + const body = buildToolResultChallenge(challenge, base); + expect(body.result.isError).toBe(true); + expect(body.result.content).toEqual([{ type: "text", text: challenge.message }]); + const header = body.result._meta["mcp/www_authenticate"][0]; + expect(header).toContain('error="invalid_token"'); + expect(header).toContain(`error_description="${challenge.message}"`); + expect(header).toContain(`resource_metadata="${base}/.well-known/oauth-protected-resource"`); + }); +}); + +describe("securitySchemesFor", () => { + test("public tools advertise mixed auth so clients can call them anonymously", async () => { + const { securitySchemesFor, toolAuthMeta } = await loadLazyAuth(); + expect(securitySchemesFor("query-docs")).toEqual([ + { type: "noauth" }, + { type: "oauth2", scopes: ["profile", "email"] }, + ]); + expect(toolAuthMeta("query-docs")).toEqual({ + securitySchemes: securitySchemesFor("query-docs"), + }); + }); + + test("protected tools advertise oauth2 only", async () => { + const { securitySchemesFor } = await loadLazyAuth({ + CONTEXT7_PROTECTED_TOOLS: "secret-tool", + }); + expect(securitySchemesFor("secret-tool")).toEqual([ + { type: "oauth2", scopes: ["profile", "email"] }, + ]); + }); +}); + +describe("resolveAuthState", () => { + const isJwt = (t: string) => t.split(".").length === 3; + + test("no credential is anonymous", async () => { + const { resolveAuthState } = await loadLazyAuth(); + const verify = vi.fn(async () => ({ valid: true })); + expect(await resolveAuthState(undefined, verify, isJwt)).toEqual({ authenticated: false }); + expect(verify).not.toHaveBeenCalled(); + }); + + test("a valid JWT authenticates and an invalid one does not", async () => { + const { resolveAuthState } = await loadLazyAuth(); + expect(await resolveAuthState("a.b.c", async () => ({ valid: true }), isJwt)).toMatchObject({ + authenticated: true, + }); + expect( + await resolveAuthState("a.b.c", async () => ({ valid: false, error: "Token expired" }), isJwt) + ).toEqual({ authenticated: false, error: "Token expired" }); + }); + + test("an opaque key is taken at face value, since only the backend can judge it", async () => { + const { resolveAuthState } = await loadLazyAuth(); + const verify = vi.fn(async () => ({ valid: true })); + expect(await resolveAuthState("ctx7sk-abc", verify, isJwt)).toEqual({ authenticated: true }); + expect(verify).not.toHaveBeenCalled(); + }); +}); From c375c8d02d40d623abfddf47b16d06b56a41ed31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Fri, 7 Aug 2026 14:03:16 +0300 Subject: [PATCH 2/3] chore(mcp): make the quota stub proxy the real API by default 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. --- packages/mcp/scripts/quota-stub-backend.mjs | 133 +++++++++++++++----- 1 file changed, 100 insertions(+), 33 deletions(-) diff --git a/packages/mcp/scripts/quota-stub-backend.mjs b/packages/mcp/scripts/quota-stub-backend.mjs index 6193b7b95..5ad6fa0a7 100644 --- a/packages/mcp/scripts/quota-stub-backend.mjs +++ b/packages/mcp/scripts/quota-stub-backend.mjs @@ -1,51 +1,118 @@ -// Stub of context7app's /api/v2 surface, emitting the exact quota headers the -// real middleware attaches (createQuotaHeaders / createMonthlyQuota429Response). -// REMAINING is decremented per request so we can watch the handover. +// Stands in for context7app's /api/v2 surface so the lazy-auth handover can be +// exercised without spending a real monthly quota. +// +// By default it proxies to the real Context7 API and rewrites only +// `RateLimit-Remaining`, so tools return genuine documentation and the only +// thing under your control is when the free requests run out. That is what you +// want for a demo. Set UPSTREAM=none to serve canned responses instead, for +// running offline. +// +// FREE_CALLS=3 node scripts/quota-stub-backend.mjs +// CONTEXT7_API_URL=http://localhost:3099/api node dist/index.js --transport http --port 3000 +// +// Env: PORT (3099), FREE_CALLS (2), UPSTREAM (https://context7.com, or "none"). import { createServer } from "node:http"; -let remaining = Number(process.env.STUB_REMAINING ?? 2); +const PORT = Number(process.env.PORT ?? 3099); +const UPSTREAM = process.env.UPSTREAM ?? "https://context7.com"; const LIMIT = Number(process.env.STUB_LIMIT ?? 200); const RESET = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; -createServer((req, res) => { - const url = new URL(req.url, "http://localhost"); - const quotaHeaders = { +let remaining = Number(process.env.FREE_CALLS ?? process.env.STUB_REMAINING ?? 2); + +/** The headers context7app's middleware attaches to every quota-counted response. */ +function quotaHeaders() { + return { "RateLimit-Limit": String(LIMIT), "RateLimit-Remaining": String(Math.max(0, remaining)), "RateLimit-Reset": String(RESET), "Context7-Quota-Tier": "anonymous", }; +} - if (remaining <= 0) { - console.log(`${url.pathname} -> 429 (quota spent)`); - res.writeHead(429, { "Content-Type": "application/json", ...quotaHeaders }); - res.end(JSON.stringify({ error: "Quota Exceeded", message: "Monthly quota exceeded." })); - return; - } +function spend() { + if (remaining > 0) remaining -= 1; + return remaining; +} - remaining -= 1; - quotaHeaders["RateLimit-Remaining"] = String(Math.max(0, remaining)); - console.log(`${url.pathname} -> 200 (remaining now ${quotaHeaders["RateLimit-Remaining"]})`); +const CANNED = { + search: { + results: [ + { + id: "/vercel/next.js", + title: "Next.js", + description: "canned result (UPSTREAM=none)", + branch: "main", + lastUpdateDate: "2026-01-01", + state: "finalized", + totalTokens: 1, + totalSnippets: 1, + }, + ], + }, + context: "canned documentation (UPSTREAM=none)", +}; - res.writeHead(200, { "Content-Type": "application/json", ...quotaHeaders }); - if (url.pathname.endsWith("/v2/libs/search")) { +createServer(async (req, res) => { + const path = req.url.split("?")[0]; + + // Out of free requests: answer exactly as the backend does once the monthly + // anonymous quota is spent. + if (remaining <= 0) { + console.log(`${path} -> 429 (free requests spent)`); + res.writeHead(429, { "Content-Type": "application/json", ...quotaHeaders() }); res.end( JSON.stringify({ - results: [ - { - id: "/vercel/next.js", - title: "Next.js", - description: "stub", - branch: "main", - lastUpdateDate: "2026-01-01", - state: "finalized", - totalTokens: 1, - totalSnippets: 1, - }, - ], + error: "Quota Exceeded", + message: + "Monthly quota exceeded. Create a free API key at https://context7.com/dashboard for more requests.", }) ); - } else { - res.end(JSON.stringify({ data: "stub docs" })); + return; + } + + if (UPSTREAM === "none") { + spend(); + console.log(`${path} -> 200 canned (remaining now ${remaining})`); + res.writeHead(200, { "Content-Type": "application/json", ...quotaHeaders() }); + res.end( + JSON.stringify(path.endsWith("/v2/libs/search") ? CANNED.search : { data: CANNED.context }) + ); + return; + } + + try { + const upstream = await fetch(UPSTREAM + req.url, { + headers: { "X-Context7-Source": "mcp-server" }, + }); + const body = await upstream.text(); + spend(); + + // Drop hop-by-hop and length headers (the body is re-sent decoded), and the + // upstream's own quota headers — otherwise they survive alongside the + // rewritten ones under different casing and the client reads both. + const DROP = new Set([ + "content-encoding", + "content-length", + "transfer-encoding", + "connection", + "ratelimit-limit", + "ratelimit-remaining", + "ratelimit-reset", + "context7-quota-tier", + ]); + const headers = Object.fromEntries( + [...upstream.headers].filter(([name]) => !DROP.has(name.toLowerCase())) + ); + console.log(`${path} -> ${upstream.status} proxied (remaining now ${remaining})`); + res.writeHead(upstream.status, { ...headers, ...quotaHeaders() }); + res.end(body); + } catch (error) { + console.error(`${path} -> upstream error:`, error.message); + res.writeHead(502, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "upstream_error", message: String(error) })); } -}).listen(3099, () => console.log("stub backend on :3099")); +}).listen(PORT, () => { + const mode = UPSTREAM === "none" ? "canned responses" : `proxying ${UPSTREAM}`; + console.log(`quota stub on :${PORT} — ${mode}, ${remaining} free request(s) then 429`); +}); From 48da517fe59cccdef5cd0d4d709b343089369b91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Fri, 7 Aug 2026 15:35:50 +0300 Subject: [PATCH 3/3] feat(mcp)!: challenge on connect by default, keep lazy behind a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/lazy-auth-mcp-endpoint.md | 14 ++-- docs/howto/oauth.mdx | 30 +++---- packages/mcp/src/index.ts | 12 ++- packages/mcp/src/lib/auth/lazy-auth.ts | 23 ++++++ packages/mcp/test/auth-mode.test.ts | 105 +++++++++++++++++++++++++ packages/mcp/test/integration.test.ts | 9 ++- packages/mcp/test/lazy-auth.test.ts | 26 +++++- 7 files changed, 193 insertions(+), 26 deletions(-) create mode 100644 packages/mcp/test/auth-mode.test.ts diff --git a/.changeset/lazy-auth-mcp-endpoint.md b/.changeset/lazy-auth-mcp-endpoint.md index f044b7035..4794e97fa 100644 --- a/.changeset/lazy-auth-mcp-endpoint.md +++ b/.changeset/lazy-auth-mcp-endpoint.md @@ -1,13 +1,15 @@ --- -"@upstash/context7-mcp": minor +"@upstash/context7-mcp": major --- -Lazy authentication on the public `/mcp` endpoint. Anonymous clients still connect, list tools and call 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 listed in `CONTEXT7_PROTECTED_TOOLS`. +The public `/mcp` endpoint now asks clients to authenticate when they connect. -The quota trigger defers to the Context7 backend, which already counts anonymous requests per client IP and reports the balance on every response (`Context7-Quota-Tier`, `RateLimit-Remaining`). The MCP server mirrors that verdict instead of counting separately, so the challenge fires exactly when the real quota runs out. Because the balance is known one request ahead, the challenge is issued before the call is proxied: users get a sign-in prompt instead of a 429, and the refused call costs no quota. +**This is a breaking change for anonymous users.** A client with no credentials that previously connected and called tools now receives a `401` with a `WWW-Authenticate` challenge on its first request. Set `CONTEXT7_MCP_AUTH_MODE=lazy` to restore the previous behaviour, with anonymous callers spending their free monthly requests before being challenged. -The challenge is delivered in whichever shape the calling client acts on: an HTTP 401 with `WWW-Authenticate` for spec-compliant clients (Claude, VS Code, Cursor, Cline, Zed, Codex CLI), or a `CallToolResult` carrying `_meta["mcp/www_authenticate"]` for ChatGPT, which does not raise its link-account UI from a bare 401. Tools also advertise `securitySchemes` in their `_meta` so clients know they are callable before an account is linked. +The default is `required` because that is when MCP clients actually run OAuth. Codex starts the flow as soon as it discovers the resource metadata, Claude Code exposes its authorize helpers for servers flagged at session start, and Zed raises its prompt on a startup 401. The same challenge raised mid-conversation is handled far worse: it fails the turn in progress, and the recovery path often only appears in the next session. Signing in at connect time means the user gets their client's native prompt instead of a broken request. -Note that whether the sign-in prompt opens by itself is up to the client. Claude, Claude Desktop and ChatGPT show an inline connect card and retry the call automatically; terminal clients flag the server and expect the user to start the flow (`/mcp` in Claude Code, `codex mcp login` in Codex CLI). +In `lazy` mode the quota trigger defers to the Context7 backend, which already counts anonymous requests per client IP and reports the balance on every response (`Context7-Quota-Tier`, `RateLimit-Remaining`). The MCP server mirrors that verdict rather than counting separately, and because the balance is known one request ahead the challenge is issued before the call is proxied — so users get a sign-in prompt instead of a 429, and the refused call costs no quota. -This replaces the anonymous sign-in elicitation, which interrupted the turn to ask the user to run `ctx7 setup` in a terminal instead of driving the client's own OAuth flow. +Either mode delivers the challenge in whichever shape the calling client acts on: an HTTP 401 with `WWW-Authenticate` for spec-compliant clients, or a `CallToolResult` carrying `_meta["mcp/www_authenticate"]` for ChatGPT, which does not raise its link-account UI from a bare 401. Tools also advertise `securitySchemes` in their `_meta`. + +Also removes the anonymous sign-in elicitation, which interrupted the turn to ask the user to run `ctx7 setup` in a terminal instead of driving the client's own OAuth flow. diff --git a/docs/howto/oauth.mdx b/docs/howto/oauth.mdx index f594348c3..94b563f15 100644 --- a/docs/howto/oauth.mdx +++ b/docs/howto/oauth.mdx @@ -20,17 +20,18 @@ Context7 MCP server supports OAuth 2.0 authentication for MCP clients that imple ## Two Endpoints -| Endpoint | Behaviour | -| ------------ | -------------------------------------------------------------------------------------------- | -| `/mcp` | Sign-in is requested only once you have used your free monthly requests, or call a protected tool | -| `/mcp/oauth` | Sign-in is required before the client can connect at all | +| Endpoint | Behaviour | +| ------------ | --------------------------------------------------------------- | +| `/mcp` | Asks you to sign in when your client first connects | +| `/mcp/oauth` | Same, and always enforced regardless of server configuration | -Most clients should use `/mcp`, the default in every Context7 install flow. You can browse -documentation immediately, and the server asks you to sign in only when you reach the free -monthly limit for your machine. Signing in raises that limit substantially. +Both endpoints ask for sign-in up front. That is deliberate: MCP clients run the OAuth +flow natively at connect time, so you get your editor's own sign-in prompt instead of a +failed request part-way through a conversation. -Use `/mcp/oauth` when you want authentication enforced up front, for example when every -request must be attributable to a user. +Self-hosted deployments can set `CONTEXT7_MCP_AUTH_MODE=lazy` on the MCP server to let +anonymous callers connect and spend their free monthly requests before being asked to +sign in. That trades a natively handled prompt for a frictionless trial. ```diff - "url": "https://mcp.context7.com/mcp" @@ -39,15 +40,14 @@ request must be attributable to a user. ## How It Works -1. Your MCP client connects and can list and call tools straight away on `/mcp` -2. When you cross the free limit, the server answers the tool call with an OAuth challenge - rather than an error, pointing your client at Context7 +1. Your MCP client connects and receives an OAuth challenge pointing at Context7 +2. Your client shows a sign-in prompt or an authorization link 3. You're redirected to Context7 to sign in -4. After signing in, you're redirected back and your client retries the same call +4. After signing in, your client stores the token and connects 5. Your client automatically handles token refresh from then on -On `/mcp/oauth` the challenge comes on the first request instead, so steps 3 to 5 happen -before you can use any tool. +In `lazy` mode the challenge arrives later — on the tool call that crosses your free +monthly limit — and steps 2 to 5 are otherwise identical. **Some clients need you to start the sign-in yourself.** Whether the OAuth flow opens on its own depends on the client, not on Context7. Claude, Claude Desktop and ChatGPT show an inline connect prompt and retry the call once you finish. Terminal clients generally do not: in Claude Code run `/mcp`, select the server and choose "Authenticate"; in Codex CLI run `codex mcp login `. diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index dbcf6a465..01b2cac1c 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -25,6 +25,7 @@ import { import { getClientIp } from "./lib/client-ip.js"; import { AUTH_SCOPES, + MCP_AUTH_MODE, buildHttpChallenge, buildToolResultChallenge, buildWwwAuthenticate, @@ -495,12 +496,17 @@ async function main() { } }; - // Anonymous access endpoint - no authentication required + // Public endpoint. Challenges on the first request by default, because + // that is when MCP clients actually run OAuth: every client we tested + // (Claude Code, Codex, Zed) surfaces a connect-time challenge natively, + // while a challenge raised mid-conversation either fails the turn or is + // ignored. Set CONTEXT7_MCP_AUTH_MODE=lazy to let anonymous callers + // connect, list, and use their free monthly requests before being asked. app.all("/mcp", async (req, res) => { - await handleMcpRequest(req, res, "lazy"); + await handleMcpRequest(req, res, MCP_AUTH_MODE); }); - // OAuth-protected endpoint - requires authentication + // OAuth-protected endpoint - always requires authentication app.all("/mcp/oauth", async (req, res) => { await handleMcpRequest(req, res, "required"); }); diff --git a/packages/mcp/src/lib/auth/lazy-auth.ts b/packages/mcp/src/lib/auth/lazy-auth.ts index 144e1e397..9a7f77234 100644 --- a/packages/mcp/src/lib/auth/lazy-auth.ts +++ b/packages/mcp/src/lib/auth/lazy-auth.ts @@ -36,6 +36,29 @@ function csvEnv(name: string, fallback = ""): string[] { .filter(Boolean); } +/** When the public `/mcp` endpoint asks an anonymous caller to authenticate. */ +export type AuthMode = "required" | "lazy"; + +/** + * How `/mcp` treats an anonymous caller. + * + * `required` (default) challenges on the very first request, including + * `initialize`. That is deliberately the default: MCP clients run OAuth + * natively at connect time — Codex starts the flow as soon as it discovers the + * resource metadata, Claude Code exposes its authorize helpers for servers + * flagged at session start, and Zed raises its prompt on a startup 401. The + * same challenge raised mid-conversation is handled far worse: it fails the + * turn in progress, and the recovery path often only appears in the next + * session. + * + * `lazy` keeps the gate described in this module: connect, list, and spend the + * free monthly requests anonymously, then challenge. It trades a natively + * handled prompt for a frictionless trial, so it is the right setting where + * anonymous use matters more than conversion. + */ +export const MCP_AUTH_MODE: AuthMode = + process.env.CONTEXT7_MCP_AUTH_MODE?.trim().toLowerCase() === "lazy" ? "lazy" : "required"; + /** * Tools that always require authentication. `tools/list` still advertises them * to anonymous clients — the challenge fires only on `tools/call`. Set the diff --git a/packages/mcp/test/auth-mode.test.ts b/packages/mcp/test/auth-mode.test.ts new file mode 100644 index 000000000..6bed20e94 --- /dev/null +++ b/packages/mcp/test/auth-mode.test.ts @@ -0,0 +1,105 @@ +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Covers the shipped default of the public `/mcp` endpoint: an anonymous client + * is challenged on its very first request, so its OAuth flow runs at connect + * time rather than failing a tool call mid-conversation. + * + * Drives the built binary over raw HTTP rather than an MCP client, because the + * contract under test is the transport-level status and the `WWW-Authenticate` + * header, both of which a client library hides. + */ + +const DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist", "index.js"); +// Distinct from integration.test.ts's range; the binary retries on EADDRINUSE +// and reports the port it settled on, which is what we parse below. +const BASE_PORT = 39217; + +let child: ChildProcess; +let url: string; + +function startServer(env: Record): Promise<{ child: ChildProcess; url: string }> { + return new Promise((resolve, reject) => { + const proc = spawn( + process.execPath, + [DIST, "--transport", "http", "--port", String(BASE_PORT)], + { + env: { ...process.env, ...env } as NodeJS.ProcessEnv, + stdio: ["ignore", "ignore", "pipe"], + } + ); + let stderr = ""; + proc.stderr!.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + const match = stderr.match(/running on HTTP at (http:\/\/localhost:\d+\/mcp)/); + if (match) resolve({ child: proc, url: match[1] }); + }); + proc.on("exit", (code) => reject(new Error(`server exited ${code}: ${stderr}`))); + setTimeout(() => reject(new Error(`server did not start: ${stderr}`)), 30_000); + }); +} + +async function post(body: unknown, headers: Record = {}) { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...headers, + }, + body: JSON.stringify(body), + }); + return { status: res.status, wwwAuthenticate: res.headers.get("www-authenticate") }; +} + +const INITIALIZE = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "t", version: "1" }, + }, +}; + +beforeAll(async () => { + // No CONTEXT7_MCP_AUTH_MODE: exercise the shipped default. + ({ child, url } = await startServer({ CONTEXT7_API_URL: "http://127.0.0.1:1/api" })); +}, 60_000); + +afterAll(() => child?.kill()); + +describe("/mcp default auth mode", () => { + test("challenges an anonymous initialize, so the client authenticates at connect time", async () => { + const res = await post(INITIALIZE); + expect(res.status).toBe(401); + expect(res.wwwAuthenticate).toContain('error="invalid_token"'); + expect(res.wwwAuthenticate).toContain("/.well-known/oauth-protected-resource"); + expect(res.wwwAuthenticate).toContain('scope="profile email"'); + }); + + test("challenges tools/list too, not just tool calls", async () => { + const res = await post({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + expect(res.status).toBe(401); + }); + + test("a credential gets past the gate", async () => { + // The upstream API is unreachable in this test, so the tool result is an + // error — but the request is no longer refused at the transport layer, + // which is what the gate controls. + const res = await post(INITIALIZE, { "CONTEXT7-API-KEY": "ctx7sk-test" }); + expect(res.status).toBe(200); + }); + + test("the discovery document is served without authentication", async () => { + const origin = new URL(url).origin; + const res = await fetch(`${origin}/.well-known/oauth-protected-resource`); + expect(res.status).toBe(200); + const doc = (await res.json()) as { authorization_servers: string[] }; + expect(doc.authorization_servers.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/mcp/test/integration.test.ts b/packages/mcp/test/integration.test.ts index 0c9d68401..a424906e4 100644 --- a/packages/mcp/test/integration.test.ts +++ b/packages/mcp/test/integration.test.ts @@ -96,7 +96,14 @@ beforeAll(async () => { const stubUrl = await startStubApi(); // getDefaultEnvironment() inherits only safe vars, so a real // CONTEXT7_API_KEY in the parent shell cannot leak into the children. - childEnv = { ...getDefaultEnvironment(), CONTEXT7_API_URL: stubUrl }; + // These cases exercise anonymous protocol behaviour, so the server runs in + // lazy mode. The shipped default (`required`) challenges on the first + // request, which is covered separately in test/auth-mode.test.ts. + childEnv = { + ...getDefaultEnvironment(), + CONTEXT7_API_URL: stubUrl, + CONTEXT7_MCP_AUTH_MODE: "lazy", + }; ({ child: httpChild, url: httpUrl } = await startHttpChild()); }, 120_000); diff --git a/packages/mcp/test/lazy-auth.test.ts b/packages/mcp/test/lazy-auth.test.ts index 96710f1bd..601df1a28 100644 --- a/packages/mcp/test/lazy-auth.test.ts +++ b/packages/mcp/test/lazy-auth.test.ts @@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; // These env vars change module-level constants, so each case re-imports the // module. `delete` rather than assignment: `process.env.X = undefined` stores // the string "undefined", which would leave a bogus entry in PROTECTED_TOOLS. -const ENV_KEYS = ["CONTEXT7_PROTECTED_TOOLS", "CONTEXT7_TOOL_RESULT_CHALLENGE_CLIENTS"] as const; +const ENV_KEYS = [ + "CONTEXT7_PROTECTED_TOOLS", + "CONTEXT7_TOOL_RESULT_CHALLENGE_CLIENTS", + "CONTEXT7_MCP_AUTH_MODE", +] as const; function clearEnv() { for (const key of ENV_KEYS) delete process.env[key]; @@ -405,3 +409,23 @@ describe("resolveAuthState", () => { expect(verify).not.toHaveBeenCalled(); }); }); + +describe("MCP_AUTH_MODE", () => { + test("defaults to required, so /mcp challenges on the first request", async () => { + const { MCP_AUTH_MODE } = await loadLazyAuth(); + expect(MCP_AUTH_MODE).toBe("required"); + }); + + test("CONTEXT7_MCP_AUTH_MODE=lazy restores the free-requests-first behaviour", async () => { + const { MCP_AUTH_MODE } = await loadLazyAuth({ CONTEXT7_MCP_AUTH_MODE: "lazy" }); + expect(MCP_AUTH_MODE).toBe("lazy"); + }); + + test("is case- and whitespace-insensitive, and rejects anything else", async () => { + expect((await loadLazyAuth({ CONTEXT7_MCP_AUTH_MODE: " LAZY " })).MCP_AUTH_MODE).toBe("lazy"); + expect((await loadLazyAuth({ CONTEXT7_MCP_AUTH_MODE: "anonymous" })).MCP_AUTH_MODE).toBe( + "required" + ); + expect((await loadLazyAuth({ CONTEXT7_MCP_AUTH_MODE: "" })).MCP_AUTH_MODE).toBe("required"); + }); +});