mirror of
https://github.com/upstash/context7.git
synced 2026-09-14 19:09:34 +08:00
feat(mcp)!: challenge on connect by default, keep lazy behind a flag
BREAKING CHANGE: an anonymous client on /mcp is now challenged on its first request, including initialize. Set CONTEXT7_MCP_AUTH_MODE=lazy to restore the previous behaviour, where anonymous callers connect and spend their free monthly requests before being asked to sign in. Testing the lazy gate against real clients showed the challenge lands in the wrong place. Every client we tried runs OAuth natively at connect time: Codex starts the flow the moment it discovers the resource metadata, without sending a single JSON-RPC message; Claude Code exposes its authorize helpers only for servers already flagged when the session started; Zed handles the 401 during server startup. The same challenge raised mid-conversation is handled far worse — it fails the turn in progress, and on Claude Code the recovery path does not appear until the next session, so the user loses their turn and has to know to run /mcp. Challenging on connect trades the anonymous trial for a prompt the client knows how to show. Deployments that would rather keep the trial can set the flag; the gate, the backend-driven quota mirror and the per-client challenge shapes all still work in that mode and are unchanged. The integration suite runs with the flag set, since it exercises anonymous protocol behaviour. test/auth-mode.test.ts covers the new default against the built binary: anonymous initialize and tools/list are refused with a challenge carrying resource_metadata and scope, a credential gets through, and the discovery document stays public.
This commit is contained in:
@@ -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.
|
||||
|
||||
+15
-15
@@ -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.
|
||||
|
||||
<Warning>
|
||||
**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 <server-name>`.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>): 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<string, string> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user