Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/lazy-auth-mcp-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@upstash/context7-mcp": major
---

The public `/mcp` endpoint now asks clients to authenticate when they connect.

**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 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.

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.

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.
31 changes: 23 additions & 8 deletions docs/howto/oauth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,20 @@ 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` | Asks you to sign in when your client first connects |
| `/mcp/oauth` | Same, and always enforced regardless of server configuration |

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.

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"
Expand All @@ -29,15 +40,19 @@ 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 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, your client stores the token and connects
5. Your client automatically handles token refresh from then on

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>
**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 <server-name>`.
</Warning>

## 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.
3 changes: 3 additions & 0 deletions packages/mcp/mcpb/.mcpbignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
src/
*.ts

# Dev tooling
scripts/

# Config files
eslint.config.js
prettier.config.mjs
Expand Down
155 changes: 155 additions & 0 deletions packages/mcp/scripts/lazy-auth-probe.mjs
Original file line number Diff line number Diff line change
@@ -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);
118 changes: 118 additions & 0 deletions packages/mcp/scripts/quota-stub-backend.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// 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";

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;

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",
};
}

function spend() {
if (remaining > 0) remaining -= 1;
return 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)",
};

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({
error: "Quota Exceeded",
message:
"Monthly quota exceeded. Create a free API key at https://context7.com/dashboard for more requests.",
})
);
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(PORT, () => {
const mode = UPSTREAM === "none" ? "canned responses" : `proxying ${UPSTREAM}`;
console.log(`quota stub on :${PORT} — ${mode}, ${remaining} free request(s) then 429`);
});
Loading
Loading