diff --git a/.changeset/lazy-auth-mcp-endpoint.md b/.changeset/lazy-auth-mcp-endpoint.md new file mode 100644 index 000000000..eaeb32310 --- /dev/null +++ b/.changeset/lazy-auth-mcp-endpoint.md @@ -0,0 +1,9 @@ +--- +"@upstash/context7-mcp": minor +--- + +Lazy authentication on the public `/mcp` endpoint. Anonymous clients can still connect, list tools and call public tools; the server now issues an OAuth challenge only when an unauthenticated caller invokes a protected tool or spends its anonymous allowance (`CONTEXT7_ANON_FREE_CALLS`, default 5). + +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), or a `CallToolResult` carrying `_meta["mcp/www_authenticate"]` for ChatGPT and Codex, which do not raise their link-account UI from a bare 401. Tools also advertise `securitySchemes` in `tools/list` so OpenAI clients know they are callable before an account is linked. + +This replaces the anonymous sign-in elicitation, which nudged the user with a `ctx7 setup` command instead of driving the client's own OAuth flow. diff --git a/packages/mcp/scripts/docker-compose.redis.yml b/packages/mcp/scripts/docker-compose.redis.yml new file mode 100644 index 000000000..9ecbfceb2 --- /dev/null +++ b/packages/mcp/scripts/docker-compose.redis.yml @@ -0,0 +1,22 @@ +# Local Upstash-compatible Redis for testing the MCP HTTP server. +# `@upstash/redis` speaks an HTTP REST protocol; serverless-redis-http (srh) +# exposes a plain Redis over that protocol so getRedis() works locally. +# +# docker compose -f scripts/docker-compose.redis.yml up -d +# +# Then point the server at it: +# UPSTASH_REDIS_REST_URL=http://localhost:8079 +# UPSTASH_REDIS_REST_TOKEN=local_token +services: + redis: + image: redis:7-alpine + srh: + image: hiett/serverless-redis-http:latest + environment: + SRH_MODE: env + SRH_TOKEN: local_token + SRH_CONNECTION_STRING: redis://redis:6379 + ports: + - "8079:80" + depends_on: + - redis diff --git a/packages/mcp/scripts/lazy-auth-probe.mjs b/packages/mcp/scripts/lazy-auth-probe.mjs new file mode 100644 index 000000000..7aec02266 --- /dev/null +++ b/packages/mcp/scripts/lazy-auth-probe.mjs @@ -0,0 +1,151 @@ +// Drives the lazy-auth gate end-to-end over raw HTTP and prints what each +// client family actually sees. Raw fetch rather than the MCP SDK client on +// purpose: the status code, the WWW-Authenticate header and the `_meta` +// challenge are the whole contract, and an SDK client hides all three. +// +// # terminal 1 — small allowance so the challenge fires quickly +// CONTEXT7_ANON_FREE_CALLS=3 node dist/index.js --transport http --port 3000 +// +// # terminal 2 +// 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, …)", ua: "claude-code/2.1.0" }, + { label: "OpenAI client (ChatGPT, Codex)", ua: "codex_cli_rs/0.104.0" }, +]; + +async function rpc(method, params, { ua, sessionId }) { + const headers = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "User-Agent": ua, + }; + if (sessionId) headers["mcp-session-id"] = sessionId; + + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + + const raw = await res.text(); + // Tool calls come back as SSE so headers flush before the tool finishes. + const payload = raw.startsWith("event:") || raw.startsWith("data:") ? sseData(raw) : raw; + let body; + try { + body = JSON.parse(payload); + } catch { + body = payload; + } + return { + status: res.status, + wwwAuthenticate: res.headers.get("www-authenticate"), + sessionId: res.headers.get("mcp-session-id"), + body, + }; +} + +function sseData(raw) { + return raw + .split("\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 init = await rpc( + "initialize", + { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "lazy-auth-probe", version: "0.0.0" }, + }, + { ua } + ); + if (init.status !== 200) { + console.log(` initialize FAILED with ${init.status} — lazy auth must let this through`); + return false; + } + const sessionId = init.sessionId; + console.log(` initialize: 200 anonymously (session ${sessionId ?? "none"})`); + + const list = await rpc("tools/list", {}, { ua, sessionId }); + const tools = list.body?.result?.tools ?? []; + console.log(` tools/list: 200 anonymously, ${tools.length} tool(s)`); + for (const tool of tools) { + const schemes = (tool.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, sessionId } + ); + 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 CONTEXT7_ANON_FREE_CALLS low enough?`); + return false; +} + +async function checkDiscovery() { + const origin = new URL(url).origin; + const paths = [ + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp", + ]; + console.log("\n=== OAuth discovery documents"); + for (const path of paths) { + const res = await fetch(origin + path); + const doc = res.ok ? await res.json() : null; + const servers = doc + ? ` -> authorization_servers=${JSON.stringify(doc.authorization_servers)}` + : ""; + console.log(` ${path}: ${res.status}${servers}`); + } +} + +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/src/index.ts b/packages/mcp/src/index.ts index 78cc7c978..e52d098ac 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -26,8 +26,16 @@ 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 { + resolveAuthState, + evaluateLazyAuth, + buildWwwAuthenticate, + buildHttpChallenge, + buildToolResultChallenge, + challengeTransportFor, +} from "./lib/auth/lazy-auth.js"; +import { advertiseToolSecuritySchemes } from "./lib/auth/tool-security.js"; /** Default HTTP server port */ const DEFAULT_PORT = 3000; @@ -193,7 +201,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: [ { @@ -206,7 +213,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: [ { @@ -249,7 +255,6 @@ Do not call this tool more than 3 times per question.`, async ({ query, libraryId }: { query: string; libraryId: string }) => { const ctx = getClientContext(); const response = await fetchLibraryContext({ query, libraryId }, ctx); - maybeElicitAuthSignIn(server, ctx); return { content: [ { @@ -261,6 +266,8 @@ Do not call this tool more than 3 times per question.`, } ); + advertiseToolSecuritySchemes(server); + server.server.registerCapabilities({ prompts: {}, resources: {} }); server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] })); server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ @@ -383,7 +390,7 @@ async function main() { const handleMcpRequest = async ( req: express.Request, res: express.Response, - requireAuth: boolean + mode: "lazy" | "required" ) => { // Reject GET requests — sessions are tracked in Redis, but this server does not send // server-initiated notifications, so SSE streams serve no purpose and cause mass NGINX @@ -402,19 +409,16 @@ async function main() { const baseUrl = new URL(resourceUrl).origin; // OAuth discovery info header, used by MCP clients to discover the authorization server - res.set( - "WWW-Authenticate", - `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` - ); + res.set("WWW-Authenticate", buildWwwAuthenticate(baseUrl)); - if (requireAuth) { + if (mode === "required") { + // Eager auth: challenge on every request, including initialize/tools/list. if (!apiKey) { + const message = "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, }); } @@ -422,16 +426,44 @@ async function main() { if (isJWT(apiKey)) { const validationResult = await validateJWT(apiKey); if (!validationResult.valid) { + const message = validationResult.error || "Invalid token. Please re-authenticate."; + res.set("WWW-Authenticate", buildWwwAuthenticate(baseUrl, "invalid_token", message)); return res.status(401).json({ jsonrpc: "2.0", - error: { - code: -32001, - message: validationResult.error || "Invalid token. Please re-authenticate.", - }, + error: { code: -32001, message }, id: null, }); } } + } else { + // Lazy auth: connect/list anonymously; challenge only when an + // unauthenticated caller hits a protected tool or exhausts its + // anonymous quota. Decided here, before the transport streams a 200. + const auth = await resolveAuthState(apiKey); + const { challenge } = await evaluateLazyAuth({ + body: req.body, + auth, + clientIp: getClientIp(req), + sessionId: extractHeaderValue(req.headers["mcp-session-id"]), + }); + if (challenge) { + res.set( + "WWW-Authenticate", + buildWwwAuthenticate(baseUrl, challenge.error, challenge.message) + ); + // ChatGPT and Codex only raise their link-account UI for a + // challenge carried in the tool result; everyone else needs the + // transport-level 401. Same challenge string either way. + // A batch would need one result per message to stay well-formed; + // the 401 refuses the whole request, which is correct either way. + const challengeTransport = Array.isArray(req.body) + ? "http-401" + : challengeTransportFor(extractHeaderValue(req.headers["user-agent"])); + if (challengeTransport === "tool-result") { + return res.status(200).json(buildToolResultChallenge(challenge, baseUrl)); + } + return res.status(challenge.status).json(buildHttpChallenge(challenge)); + } } const context: ClientContext = { @@ -522,14 +554,15 @@ async function main() { } }; - // Anonymous access endpoint - no authentication required + // Lazy-auth endpoint: anonymous connect/list and public tool calls, with a + // 401 challenge only for protected tools or once the anonymous quota is spent. app.all("/mcp", async (req, res) => { - await handleMcpRequest(req, res, false); + await handleMcpRequest(req, res, "lazy"); }); - // OAuth-protected endpoint - requires authentication + // OAuth-protected endpoint - requires authentication on every request 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) => { @@ -538,17 +571,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: ["profile", "email"], + 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). Clients connecting to + // /mcp or /mcp/oauth try these before falling back to the root document, + // so serving them avoids 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..ee8d194a9 100644 --- a/packages/mcp/src/lib/api.ts +++ b/packages/mcp/src/lib/api.ts @@ -94,12 +94,6 @@ 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; - } -} - /** * Searches for libraries matching the given query * @param query The user's question or task (used for LLM relevance ranking) @@ -120,7 +114,6 @@ export async function searchLibraries( const headers = generateHeaders(context); const response = await fetch(url, { headers }); - readPromptSignal(response, context); if (!response.ok) { const errorMessage = await parseErrorResponse(response, context.apiKey); console.error(errorMessage); @@ -153,7 +146,6 @@ export async function fetchLibraryContext( const headers = generateHeaders(context); const response = await fetch(url, { headers }); - readPromptSignal(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 319c22524..000000000 --- a/packages/mcp/src/lib/auth/auth-prompt.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -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. - */ -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..24fa8b50a --- /dev/null +++ b/packages/mcp/src/lib/auth/lazy-auth.ts @@ -0,0 +1,299 @@ +import { getRedis } from "../redis.js"; +import { isJWT, validateJWT } from "../jwt.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. it exhausts the anonymous free-call allowance ({@link ANON_FREE_CALLS}). + * + * The decision MUST be made at the HTTP layer before the JSON-RPC handler runs: + * the StreamableHTTP transport streams a 200 and flushes headers as soon as it + * starts handling a request, so a tool-level error can no longer become a 401 + * and the client never sees the auth challenge. See {@link evaluateLazyAuth}. + * + * The challenge is delivered in one of two shapes, because the two client + * families disagree on what an auth challenge 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 and Codex read; a bare 401 does not raise their link-account UI. + * + * Both shapes carry the same RFC 6750 challenge string, so a client that + * understands either one gets the same discovery chain. + */ + +const PROTECTED_TOOLS_ENV = (process.env.CONTEXT7_PROTECTED_TOOLS ?? "") + .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`. Add the tool + * name here (or via the comma-separated `CONTEXT7_PROTECTED_TOOLS` env var) to + * gate it. + */ +export const PROTECTED_TOOLS = new Set([ + // e.g. "query-private-docs", + ...PROTECTED_TOOLS_ENV, +]); + +/** + * Number of anonymous `tools/call` requests allowed per client before the + * server starts returning a 401 auth challenge. Set `CONTEXT7_ANON_FREE_CALLS=0` + * to disable the quota gate (protected-tool gating still applies). + */ +export const ANON_FREE_CALLS = (() => { + const parsed = parseInt(process.env.CONTEXT7_ANON_FREE_CALLS ?? "", 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 5; +})(); + +const ANON_QUOTA_TTL_SECONDS = 24 * 60 * 60; +const ANON_QUOTA_PREFIX = "#mcp#anon-quota#"; + +/** Scope advertised in the challenge; mirrors PRM `scopes_supported`. */ +const CHALLENGE_SCOPE = "profile email"; + +/** Scopes advertised per tool in `tools/list`; mirrors {@link CHALLENGE_SCOPE}. */ +const TOOL_SCOPES = CHALLENGE_SCOPE.split(" "); + +/** + * Per-tool authentication requirements advertised on the `tools/list` wire + * format. 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" — which is exactly the lazy-auth contract. + * + * Clients that don't know the field ignore it, so it is safe to send to + * everyone. + */ +export type SecurityScheme = { type: "noauth" } | { type: "oauth2"; scopes: string[] }; + +/** + * Security schemes for `toolName`: protected tools require OAuth up front, + * every other tool advertises mixed auth. + */ +export function securitySchemesFor(toolName: string): SecurityScheme[] { + if (PROTECTED_TOOLS.has(toolName)) { + return [{ type: "oauth2", scopes: TOOL_SCOPES }]; + } + return [{ type: "noauth" }, { type: "oauth2", scopes: TOOL_SCOPES }]; +} + +export interface AuthState { + /** A credential was presented and accepted (opaque key present, or JWT valid). */ + authenticated: boolean; +} + +/** + * Resolve whether the caller is authenticated for gating purposes. Mirrors the + * `/mcp/oauth` path: opaque Context7 keys are accepted as-is (the backend is the + * authority on their validity), while JWTs are cryptographically verified here. + */ +export async function resolveAuthState(apiKey: string | undefined): Promise { + if (!apiKey) return { authenticated: false }; + if (isJWT(apiKey)) { + const result = await validateJWT(apiKey); + return { authenticated: result.valid }; + } + return { authenticated: true }; +} + +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"); +} + +/** First JSON-RPC id in the request, echoed back on the challenge response. */ +function firstId(body: unknown): unknown { + const msg = asMessages(body).find((m) => m.id !== undefined); + return msg?.id ?? null; +} + +/** Per-client key for the anonymous quota counter (IP, falling back to session). */ +function clientFingerprint(opts: { clientIp?: string; sessionId?: string }): string | undefined { + return opts.clientIp || opts.sessionId || undefined; +} + +/** + * Increment and test the anonymous quota for this client. Returns true once the + * caller has spent its free allowance. Fail-open: Redis errors never block a + * request (a quota miss is preferable to a false challenge). + */ +async function anonymousQuotaExceeded(fingerprint: string): Promise { + if (ANON_FREE_CALLS <= 0) return false; + try { + const redis = getRedis(); + const key = `${ANON_QUOTA_PREFIX}${fingerprint}`; + const count = await redis.incr(key); + if (count === 1) await redis.expire(key, ANON_QUOTA_TTL_SECONDS); + return count > ANON_FREE_CALLS; + } catch (err) { + console.error("[LazyAuth] anonymous quota check failed:", err); + return false; + } +} + +export interface Challenge { + status: 401 | 403; + /** RFC 6750 error code echoed in the `WWW-Authenticate` header. */ + error: "invalid_token" | "insufficient_scope"; + message: string; + /** Echoes the request 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 fragments identifying clients that surface an auth prompt from a + * `CallToolResult` rather than from an HTTP 401. Override with a + * comma-separated `CONTEXT7_TOOL_RESULT_CHALLENGE_CLIENTS` if a client changes + * its UA or a new one needs the same treatment. + */ +const TOOL_RESULT_CHALLENGE_CLIENTS = ( + process.env.CONTEXT7_TOOL_RESULT_CHALLENGE_CLIENTS ?? "openai-mcp,chatgpt,codex" +) + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + +/** + * Pick the challenge shape for a caller. Defaults to the spec-compliant HTTP + * 401 and only opts a client into the `_meta` form when its User-Agent says it + * needs it — an unrecognised client is far better served by the standard. + */ +export function challengeTransportFor(userAgent: string | undefined): ChallengeTransport { + if (!userAgent) return "http-401"; + const ua = userAgent.toLowerCase(); + return TOOL_RESULT_CHALLENGE_CLIENTS.some((marker) => ua.includes(marker)) + ? "tool-result" + : "http-401"; +} + +/** + * 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 here — the + * OpenAI clients use its presence to decide the failure is an auth problem + * rather than 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)], + }, + }, + }; +} + +/** JSON-RPC body for an `http-401` challenge, sent with `challenge.status`. */ +export function buildHttpChallenge(challenge: Challenge) { + return { + jsonrpc: "2.0" as const, + error: { code: -32001, message: challenge.message }, + id: challenge.id ?? null, + }; +} + +export interface LazyAuthDecision { + challenge?: Challenge; +} + +/** + * 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. Authenticated callers bypass both gates. + */ +export async function evaluateLazyAuth(opts: { + body: unknown; + auth: AuthState; + clientIp?: string; + sessionId?: string; +}): Promise { + const tools = toolCallsIn(opts.body); + if (tools.length === 0) return {}; + if (opts.auth.authenticated) return {}; + + if (tools.some((name) => PROTECTED_TOOLS.has(name))) { + return { + challenge: { + status: 401, + error: "invalid_token", + message: "This tool requires authentication. Please sign in to continue.", + id: firstId(opts.body), + }, + }; + } + + const fingerprint = clientFingerprint(opts); + if (fingerprint && (await anonymousQuotaExceeded(fingerprint))) { + return { + challenge: { + status: 401, + error: "invalid_token", + message: "Anonymous usage limit reached. Please sign in to continue using Context7.", + id: firstId(opts.body), + }, + }; + } + + return {}; +} + +/** Strip the quoting characters RFC 6750 does not allow inside a quoted 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 on ordinary responses where the header is purely advisory. + * + * `scope` tells the client which scopes to request, so it doesn't fall back to + * asking for everything in `scopes_supported`. + */ +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(", ")}`; +} diff --git a/packages/mcp/src/lib/auth/tool-security.ts b/packages/mcp/src/lib/auth/tool-security.ts new file mode 100644 index 000000000..f035edb6a --- /dev/null +++ b/packages/mcp/src/lib/auth/tool-security.ts @@ -0,0 +1,46 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { securitySchemesFor } from "./lazy-auth.js"; + +interface ToolListEntry { + name: string; + [key: string]: unknown; +} + +type ListToolsHandler = (...args: unknown[]) => Promise<{ tools?: ToolListEntry[] }>; + +/** + * Add a `securitySchemes` array to every tool in `tools/list`, declaring which + * tools run anonymously and which need OAuth. OpenAI clients read this to know + * a tool is callable before the user has linked an account; clients that don't + * know the field ignore it. + * + * The SDK's `registerTool` only forwards the config fields it knows about, so + * this wraps the `tools/list` handler the SDK installed rather than passing the + * array through registration. If a future SDK stops registering the handler + * under that key, the wrap is skipped: tools still list correctly, only the + * extra field goes missing. `test/tool-security.test.ts` asserts the field on + * the wire of a real server so that regression fails the build instead of + * silently reaching clients. + * + * Call after every tool is registered — the SDK installs the handler lazily on + * first `registerTool`. + */ +export function advertiseToolSecuritySchemes(server: McpServer): void { + const handlers = ( + server.server as unknown as { _requestHandlers?: Map } + )._requestHandlers; + const listTools = handlers?.get("tools/list"); + if (!handlers || !listTools) return; + + handlers.set("tools/list", async (...args: unknown[]) => { + const result = await listTools(...args); + if (!Array.isArray(result?.tools)) return result; + return { + ...result, + tools: result.tools.map((tool) => ({ + ...tool, + securitySchemes: securitySchemesFor(tool.name), + })), + }; + }); +} 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..e5eefccbc --- /dev/null +++ b/packages/mcp/test/lazy-auth.test.ts @@ -0,0 +1,292 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// In-memory stand-in for the Upstash Redis counter used by the quota gate. +const store = new Map(); +const incr = vi.fn(async (key: string) => { + const next = (store.get(key) ?? 0) + 1; + store.set(key, next); + return next; +}); +const expire = vi.fn(async () => 1); + +vi.mock("../src/lib/redis.js", () => ({ + getRedis: () => ({ incr, expire }), +})); + +// JWTs are only relevant for the authenticated-bypass path; treat any 3-part +// token as valid so we can exercise the bypass without real crypto. +vi.mock("../src/lib/jwt.js", () => ({ + isJWT: (t: string) => t.split(".").length === 3, + validateJWT: vi.fn(async () => ({ valid: true })), +})); + +async function loadModule(env: Record = {}) { + vi.resetModules(); + store.clear(); + incr.mockClear(); + expire.mockClear(); + for (const [k, v] of Object.entries(env)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + return import("../src/lib/auth/lazy-auth.js"); +} + +function toolCall(name: string, id: unknown = 1) { + return { jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: {} } }; +} + +const BASE_ENV = { + CONTEXT7_PROTECTED_TOOLS: undefined, + CONTEXT7_ANON_FREE_CALLS: undefined, +}; + +beforeEach(() => { + process.env.CONTEXT7_PROTECTED_TOOLS = undefined; + process.env.CONTEXT7_ANON_FREE_CALLS = undefined; +}); + +afterEach(() => { + delete process.env.CONTEXT7_PROTECTED_TOOLS; + delete process.env.CONTEXT7_ANON_FREE_CALLS; +}); + +describe("toolCallsIn", () => { + test("extracts tool names from a single message and a batch", async () => { + const { toolCallsIn } = await loadModule(BASE_ENV); + expect(toolCallsIn(toolCall("query-docs"))).toEqual(["query-docs"]); + expect(toolCallsIn([toolCall("a"), { method: "tools/list" }, toolCall("b")])).toEqual([ + "a", + "b", + ]); + }); + + test("ignores non-tools/call methods", async () => { + const { toolCallsIn } = await loadModule(BASE_ENV); + expect(toolCallsIn({ method: "initialize" })).toEqual([]); + expect(toolCallsIn({ method: "tools/list" })).toEqual([]); + }); +}); + +describe("evaluateLazyAuth — pass-through", () => { + test("initialize and tools/list always pass, even anonymously", async () => { + const { evaluateLazyAuth } = await loadModule(BASE_ENV); + const auth = { authenticated: false }; + expect( + (await evaluateLazyAuth({ body: { method: "initialize" }, auth, clientIp: "1.1.1.1" })) + .challenge + ).toBeUndefined(); + expect( + (await evaluateLazyAuth({ body: { method: "tools/list" }, auth, clientIp: "1.1.1.1" })) + .challenge + ).toBeUndefined(); + }); + + test("authenticated callers bypass both gates", async () => { + const { evaluateLazyAuth } = await loadModule({ + CONTEXT7_PROTECTED_TOOLS: "secret-tool", + CONTEXT7_ANON_FREE_CALLS: "0", + }); + const decision = await evaluateLazyAuth({ + body: toolCall("secret-tool"), + auth: { authenticated: true }, + clientIp: "1.1.1.1", + }); + expect(decision.challenge).toBeUndefined(); + }); +}); + +describe("evaluateLazyAuth — protected tools", () => { + test("anonymous call to a protected tool is challenged with 401", async () => { + const { evaluateLazyAuth } = await loadModule({ CONTEXT7_PROTECTED_TOOLS: "secret-tool" }); + const decision = await evaluateLazyAuth({ + body: toolCall("secret-tool", 42), + auth: { authenticated: false }, + clientIp: "1.1.1.1", + }); + expect(decision.challenge).toMatchObject({ status: 401, error: "invalid_token", id: 42 }); + }); + + test("protected-tool challenge does not consume anonymous quota", async () => { + const { evaluateLazyAuth } = await loadModule({ + CONTEXT7_PROTECTED_TOOLS: "secret-tool", + CONTEXT7_ANON_FREE_CALLS: "5", + }); + await evaluateLazyAuth({ + body: toolCall("secret-tool"), + auth: { authenticated: false }, + clientIp: "1.1.1.1", + }); + expect(incr).not.toHaveBeenCalled(); + }); + + test("public tools stay anonymous when no protected set is configured", async () => { + const { evaluateLazyAuth } = await loadModule(BASE_ENV); + const decision = await evaluateLazyAuth({ + body: toolCall("query-docs"), + auth: { authenticated: false }, + clientIp: "1.1.1.1", + }); + expect(decision.challenge).toBeUndefined(); + }); +}); + +describe("evaluateLazyAuth — anonymous quota", () => { + test("allows the free allowance then challenges", async () => { + const { evaluateLazyAuth } = await loadModule({ CONTEXT7_ANON_FREE_CALLS: "3" }); + const call = () => + evaluateLazyAuth({ + body: toolCall("query-docs"), + auth: { authenticated: false }, + clientIp: "9.9.9.9", + }); + + for (let i = 0; i < 3; i++) { + expect((await call()).challenge).toBeUndefined(); + } + const fourth = await call(); + expect(fourth.challenge).toMatchObject({ status: 401, error: "invalid_token" }); + expect(expire).toHaveBeenCalledTimes(1); // TTL set once, on the first hit + }); + + test("quota is per-client", async () => { + const { evaluateLazyAuth } = await loadModule({ CONTEXT7_ANON_FREE_CALLS: "1" }); + const callFrom = (ip: string) => + evaluateLazyAuth({ + body: toolCall("query-docs"), + auth: { authenticated: false }, + clientIp: ip, + }); + + expect((await callFrom("1.1.1.1")).challenge).toBeUndefined(); + expect((await callFrom("1.1.1.1")).challenge).toBeDefined(); + // A different client still has its full allowance. + expect((await callFrom("2.2.2.2")).challenge).toBeUndefined(); + }); + + test("CONTEXT7_ANON_FREE_CALLS=0 disables the quota gate", async () => { + const { evaluateLazyAuth } = await loadModule({ CONTEXT7_ANON_FREE_CALLS: "0" }); + for (let i = 0; i < 10; i++) { + const decision = await evaluateLazyAuth({ + body: toolCall("query-docs"), + auth: { authenticated: false }, + clientIp: "5.5.5.5", + }); + expect(decision.challenge).toBeUndefined(); + } + expect(incr).not.toHaveBeenCalled(); + }); +}); + +describe("buildWwwAuthenticate", () => { + const base = "https://mcp.context7.com"; + + test("includes resource_metadata and scope, and error when challenging", async () => { + const { buildWwwAuthenticate } = await loadModule(BASE_ENV); + expect(buildWwwAuthenticate(base)).toBe( + `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource", scope="profile email"` + ); + expect(buildWwwAuthenticate(base, "invalid_token")).toBe( + `Bearer error="invalid_token", resource_metadata="${base}/.well-known/oauth-protected-resource", scope="profile email"` + ); + }); + + test("carries error_description, which ChatGPT needs to recognise an auth failure", async () => { + const { buildWwwAuthenticate } = await loadModule(BASE_ENV); + expect(buildWwwAuthenticate(base, "invalid_token", "Please sign in")).toBe( + `Bearer error="invalid_token", error_description="Please sign in", ` + + `resource_metadata="${base}/.well-known/oauth-protected-resource", scope="profile email"` + ); + }); + + test("strips quotes and newlines that would break header parsing", async () => { + const { buildWwwAuthenticate } = await loadModule(BASE_ENV); + const header = buildWwwAuthenticate(base, "invalid_token", 'say "hi"\nthen bye'); + expect(header).toContain('error_description="say hi then bye"'); + }); + + test("omits error_description when there is no error to describe", async () => { + const { buildWwwAuthenticate } = await loadModule(BASE_ENV); + expect(buildWwwAuthenticate(base, undefined, "ignored")).not.toContain("error_description"); + }); +}); + +describe("challengeTransportFor", () => { + test("defaults to the spec-compliant 401 for unknown and missing clients", async () => { + const { challengeTransportFor } = await loadModule(BASE_ENV); + expect(challengeTransportFor(undefined)).toBe("http-401"); + expect(challengeTransportFor("claude-code/2.1.0")).toBe("http-401"); + expect(challengeTransportFor("node")).toBe("http-401"); + expect(challengeTransportFor("Visual Studio Code/1.108.0")).toBe("http-401"); + expect(challengeTransportFor("Cursor/1.9.2")).toBe("http-401"); + }); + + test("selects the tool-result form for OpenAI clients, case-insensitively", async () => { + const { challengeTransportFor } = await loadModule(BASE_ENV); + expect(challengeTransportFor("openai-mcp/1.0")).toBe("tool-result"); + expect(challengeTransportFor("ChatGPT/1.2025.0")).toBe("tool-result"); + expect(challengeTransportFor("codex_cli_rs/0.104.0")).toBe("tool-result"); + }); + + test("the client list is overridable for clients that change their UA", async () => { + const { challengeTransportFor } = await loadModule({ + ...BASE_ENV, + CONTEXT7_TOOL_RESULT_CHALLENGE_CLIENTS: "some-new-client", + }); + expect(challengeTransportFor("some-new-client/1.0")).toBe("tool-result"); + expect(challengeTransportFor("codex_cli_rs/0.104.0")).toBe("http-401"); + }); +}); + +describe("challenge bodies", () => { + const base = "https://mcp.context7.com"; + const challenge = { + status: 401 as const, + error: "invalid_token" as const, + message: "Anonymous usage limit reached.", + id: 7, + }; + + test("http form is a JSON-RPC error echoing the request id", async () => { + const { buildHttpChallenge } = await loadModule(BASE_ENV); + expect(buildHttpChallenge(challenge)).toEqual({ + jsonrpc: "2.0", + error: { code: -32001, message: "Anonymous usage limit reached." }, + id: 7, + }); + }); + + test("http form sends a null id when the request had none", async () => { + const { buildHttpChallenge } = await loadModule(BASE_ENV); + expect(buildHttpChallenge({ ...challenge, id: null }).id).toBeNull(); + }); + + test("tool-result form is a failed tool call carrying the challenge in _meta", async () => { + const { buildToolResultChallenge } = await loadModule(BASE_ENV); + const body = buildToolResultChallenge(challenge, base); + expect(body.id).toBe(7); + 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 } = await loadModule(BASE_ENV); + expect(securitySchemesFor("query-docs")).toEqual([ + { type: "noauth" }, + { type: "oauth2", scopes: ["profile", "email"] }, + ]); + }); + + test("protected tools advertise oauth2 only", async () => { + const { securitySchemesFor } = await loadModule({ CONTEXT7_PROTECTED_TOOLS: "secret-tool" }); + expect(securitySchemesFor("secret-tool")).toEqual([ + { type: "oauth2", scopes: ["profile", "email"] }, + ]); + }); +}); diff --git a/packages/mcp/test/tool-security.test.ts b/packages/mcp/test/tool-security.test.ts new file mode 100644 index 000000000..4fdc751e9 --- /dev/null +++ b/packages/mcp/test/tool-security.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { z } from "zod"; +import { advertiseToolSecuritySchemes } from "../src/lib/auth/tool-security.js"; + +/** + * These assert what actually goes out on the wire, not what our helper returns. + * `securitySchemes` is not part of the SDK's `Tool` schema, so a client built on + * the SDK would parse it away — the only faithful check is the raw JSON-RPC + * message the server sends. This is also the regression guard for the fact that + * the helper wraps an SDK-internal handler: if a future SDK stops registering + * `tools/list` under that key, these fail instead of the field quietly + * disappearing from production responses. + */ + +interface CapturedMessage { + id?: unknown; + result?: { tools?: Array> }; +} + +function captureTransport(sent: CapturedMessage[]): Transport { + return { + start: async () => {}, + send: async (message: unknown) => { + sent.push(message as CapturedMessage); + }, + close: async () => {}, + } as Transport; +} + +async function listToolsOverWire(server: McpServer): Promise>> { + const sent: CapturedMessage[] = []; + const transport = captureTransport(sent); + await server.connect(transport); + + transport.onmessage?.({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} } as never); + // Handlers resolve on the microtask queue; one macrotask is enough to drain. + await new Promise((resolve) => setTimeout(resolve, 0)); + + const response = sent.find((m) => m.id === 1); + expect(response, "server never answered tools/list").toBeDefined(); + return response!.result?.tools ?? []; +} + +function serverWithTools(): McpServer { + const server = new McpServer({ name: "test", version: "0.0.0" }); + server.registerTool( + "resolve-library-id", + { description: "public", inputSchema: { libraryName: z.string() } }, + async () => ({ content: [{ type: "text", text: "ok" }] }) + ); + server.registerTool( + "query-docs", + { description: "public", inputSchema: { libraryId: z.string() } }, + async () => ({ content: [{ type: "text", text: "ok" }] }) + ); + return server; +} + +describe("advertiseToolSecuritySchemes", () => { + test("every listed tool carries securitySchemes on the wire", async () => { + const server = serverWithTools(); + advertiseToolSecuritySchemes(server); + + const tools = await listToolsOverWire(server); + expect(tools.map((t) => t.name).sort()).toEqual(["query-docs", "resolve-library-id"]); + for (const tool of tools) { + expect(tool.securitySchemes, `missing on ${tool.name}`).toEqual([ + { type: "noauth" }, + { type: "oauth2", scopes: ["profile", "email"] }, + ]); + } + }); + + test("the tool definition is otherwise untouched", async () => { + const server = serverWithTools(); + const before = await listToolsOverWire(serverWithTools()); + advertiseToolSecuritySchemes(server); + const after = await listToolsOverWire(server); + + for (const [i, tool] of after.entries()) { + const { securitySchemes, ...rest } = tool; + expect(securitySchemes).toBeDefined(); + expect(rest).toEqual(before[i]); + } + }); + + test("is a no-op on a server with no tools/list handler", async () => { + const server = new McpServer({ name: "test", version: "0.0.0" }); + expect(() => advertiseToolSecuritySchemes(server)).not.toThrow(); + }); +});