mirror of
https://github.com/upstash/context7.git
synced 2026-09-14 19:09:34 +08:00
feat(mcp): authenticate and track Claude Code plugin (#3028)
* feat(mcp): require auth on /mcp when client is a plugin Plugin hosts such as Claude Code only start OAuth for servers that 401 at connect time. Matching Exa MCP, ?client=claude-code-plugin (any client value containing "plugin") now gates /mcp the same way /mcp/oauth does, while anonymous access on the public URL is unchanged. Co-authored-by: Enes Gules <enesgules@users.noreply.github.com> * chore(mcp): drop the SDK OAuth-helpers TODO The v2 helpers (bearerAuthChallengeResponse, oauthMetadataResponse) assume Bearer-only OAuth on a fetch() handler. This server also accepts API keys, mixes anonymous and required routes, returns JSON-RPC 401 bodies, and proxies authorization-server metadata live, so they are not a drop-in. Co-authored-by: Enes Gules <enesgules@users.noreply.github.com> * docs: keep the plugin client auth gate out of user-facing docs The ?client=claude-code-plugin gate stays in the server and Claude plugin URL. OAuth docs continue to describe /mcp/oauth only. Co-authored-by: Enes Gules <enesgules@users.noreply.github.com> * simplify Claude plugin auth tracking * separate plugin and client metrics * extract plugin request detection * simplify MCP request handling * extract authentication policy * use OAuth for Claude Code plugin * support API key or OAuth in Claude plugin * simplify Claude plugin authentication --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Enes Gules <enesgules@users.noreply.github.com> Co-authored-by: Fahreddin Özcan <ozcanfahrettinn@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@upstash/context7-mcp": patch
|
||||
---
|
||||
|
||||
Require authentication and track usage separately for the Claude Code plugin.
|
||||
@@ -3,12 +3,13 @@
|
||||
"owner": {
|
||||
"name": "Upstash"
|
||||
},
|
||||
"description": "Context7 plugins for coding agents.",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "context7",
|
||||
"source": "./plugins/claude/context7",
|
||||
"description": "Up-to-date documentation lookup. Pull version-specific documentation and code examples directly from source repositories into your LLM context.",
|
||||
"version": "1.0.2"
|
||||
"version": "1.0.3"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const REPO_ROOT = join(import.meta.dirname, "..", "..", "..", "..");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
describe("plugin MCP manifests", () => {
|
||||
// Deliberately the raw key, not `Bearer <key>` as the CLI writes. Both plugins
|
||||
// document that an unset key still works over the anonymous tier, and this is
|
||||
// the only form that survives both states: the server rejects `Bearer` with an
|
||||
// empty token but treats an empty Authorization as anonymous.
|
||||
test.each(["plugins/claude/context7/.mcp.json", "plugins/copilot/context7/.mcp.json"])(
|
||||
"%s passes the raw key via Authorization",
|
||||
async (relPath) => {
|
||||
const raw = await readFile(join(REPO_ROOT, relPath), "utf-8");
|
||||
const config = JSON.parse(raw) as {
|
||||
mcpServers: { context7: { headers: Record<string, string> } };
|
||||
};
|
||||
expect(config.mcpServers.context7.headers).toEqual({
|
||||
Authorization: "${CONTEXT7_API_KEY:-}",
|
||||
});
|
||||
}
|
||||
);
|
||||
test("Claude uses an API key only when one is set", async () => {
|
||||
const relPath = "plugins/claude/context7/.mcp.json";
|
||||
const raw = await readFile(join(REPO_ROOT, relPath), "utf-8");
|
||||
const config = JSON.parse(raw) as {
|
||||
mcpServers: { context7: { headers?: Record<string, string>; headersHelper: string } };
|
||||
};
|
||||
expect(config.mcpServers.context7.headers).toBeUndefined();
|
||||
expect(config.mcpServers.context7.headersHelper).toBe(
|
||||
'node "${CLAUDE_PLUGIN_ROOT}/scripts/headers.mjs"'
|
||||
);
|
||||
|
||||
const helper = join(REPO_ROOT, "plugins/claude/context7/scripts/headers.mjs");
|
||||
const withoutKey = await execFileAsync(process.execPath, [helper], {
|
||||
env: { ...process.env, CONTEXT7_API_KEY: "" },
|
||||
});
|
||||
expect(JSON.parse(withoutKey.stdout)).toEqual({});
|
||||
|
||||
const withKey = await execFileAsync(process.execPath, [helper], {
|
||||
env: { ...process.env, CONTEXT7_API_KEY: "ctx7sk-test" },
|
||||
});
|
||||
expect(JSON.parse(withKey.stdout)).toEqual({ Authorization: "ctx7sk-test" });
|
||||
});
|
||||
|
||||
test("Copilot passes the raw API key via Authorization", async () => {
|
||||
const raw = await readFile(join(REPO_ROOT, "plugins/copilot/context7/.mcp.json"), "utf-8");
|
||||
const config = JSON.parse(raw) as {
|
||||
mcpServers: { context7: { headers: Record<string, string> } };
|
||||
};
|
||||
expect(config.mcpServers.context7.headers).toEqual({
|
||||
Authorization: "${CONTEXT7_API_KEY:-}",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+16
-10
@@ -28,6 +28,15 @@ import { getMaxSubscriptions } from "./lib/subscriptions.js";
|
||||
|
||||
/** Default HTTP server port */
|
||||
const DEFAULT_PORT = 3000;
|
||||
const CLAUDE_CODE_PLUGIN = "claude-code-plugin";
|
||||
|
||||
function getPluginFromRequest(req: express.Request): typeof CLAUDE_CODE_PLUGIN | undefined {
|
||||
return req.query.client === CLAUDE_CODE_PLUGIN ? CLAUDE_CODE_PLUGIN : undefined;
|
||||
}
|
||||
|
||||
function requiresAuthentication(req: express.Request, plugin?: typeof CLAUDE_CODE_PLUGIN): boolean {
|
||||
return req.path === "/mcp/oauth" || Boolean(plugin);
|
||||
}
|
||||
|
||||
// Parse CLI arguments using commander
|
||||
const program = new Command()
|
||||
@@ -390,12 +399,9 @@ async function main() {
|
||||
onerror: (error) => console.error("MCP node adapter error:", error),
|
||||
});
|
||||
|
||||
const handleMcpRequest = async (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
requireAuth: boolean
|
||||
) => {
|
||||
const handleMcpRequest = async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const plugin = getPluginFromRequest(req);
|
||||
const apiKey = extractApiKey(req);
|
||||
const baseUrl = new URL(RESOURCE_URL).origin;
|
||||
|
||||
@@ -409,7 +415,7 @@ async function main() {
|
||||
`Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`
|
||||
);
|
||||
|
||||
if (requireAuth) {
|
||||
if (requiresAuthentication(req, plugin)) {
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({
|
||||
jsonrpc: "2.0",
|
||||
@@ -438,8 +444,9 @@ async function main() {
|
||||
|
||||
const context: ClientContext = {
|
||||
clientIp: req.ip,
|
||||
apiKey: apiKey,
|
||||
apiKey,
|
||||
clientInfo: extractClientInfoFromUserAgent(req.headers["user-agent"]),
|
||||
plugin,
|
||||
transport: "http",
|
||||
};
|
||||
|
||||
@@ -458,14 +465,13 @@ async function main() {
|
||||
}
|
||||
};
|
||||
|
||||
// Anonymous access endpoint - no authentication required
|
||||
app.all("/mcp", async (req, res) => {
|
||||
await handleMcpRequest(req, res, false);
|
||||
await handleMcpRequest(req, res);
|
||||
});
|
||||
|
||||
// OAuth-protected endpoint - requires authentication
|
||||
app.all("/mcp/oauth", async (req, res) => {
|
||||
await handleMcpRequest(req, res, true);
|
||||
await handleMcpRequest(req, res);
|
||||
});
|
||||
|
||||
app.get("/ping", (_req: express.Request, res: express.Response) => {
|
||||
|
||||
@@ -77,6 +77,9 @@ export function generateHeaders(context: ClientContext): Record<string, string>
|
||||
if (context.clientInfo?.version) {
|
||||
headers["X-Context7-Client-Version"] = context.clientInfo.version;
|
||||
}
|
||||
if (context.plugin) {
|
||||
headers["X-Context7-Plugin"] = context.plugin;
|
||||
}
|
||||
if (context.transport) {
|
||||
headers["X-Context7-Transport"] = context.transport;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface ClientContext {
|
||||
ide?: string;
|
||||
version?: string;
|
||||
};
|
||||
plugin?: string;
|
||||
transport?: "stdio" | "http";
|
||||
sessionId?: string;
|
||||
/** Mutable: set by the upstream API layer when the backend signals the
|
||||
|
||||
@@ -305,3 +305,74 @@ describe.each([
|
||||
expect(apiCall.headers["x-context7-client-version"]).toBe(expected.version);
|
||||
});
|
||||
});
|
||||
|
||||
const INITIALIZE = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "t", version: "1" },
|
||||
},
|
||||
};
|
||||
|
||||
async function postMcp(target: string, headers: Record<string, string> = {}) {
|
||||
const res = await fetch(target, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(INITIALIZE),
|
||||
});
|
||||
return { status: res.status, wwwAuthenticate: res.headers.get("www-authenticate") };
|
||||
}
|
||||
|
||||
describe("plugin authentication", () => {
|
||||
beforeEach(() => {
|
||||
requests.length = 0;
|
||||
});
|
||||
|
||||
test("only challenges the supported plugin", async () => {
|
||||
expect((await postMcp(`${httpUrl}?client=other-plugin`)).status).toBe(200);
|
||||
|
||||
const res = await postMcp(`${httpUrl}?client=claude-code-plugin`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.wwwAuthenticate).toContain("resource_metadata=");
|
||||
expect(res.wwwAuthenticate).toContain("/.well-known/oauth-protected-resource");
|
||||
});
|
||||
|
||||
test("keeps the OAuth endpoint protected", async () => {
|
||||
const res = await postMcp(httpUrl.replace(/\/mcp$/, "/mcp/oauth"));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("tracks authenticated plugin requests separately", async () => {
|
||||
const client = new Client(
|
||||
{ name: "claude-code", version: "1.0.0" },
|
||||
{ versionNegotiation: { mode: { pin: "2026-07-28" } } }
|
||||
);
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL(`${httpUrl}?client=claude-code-plugin`), {
|
||||
requestInit: { headers: { Authorization: "Bearer ctx7sk-test" } },
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
await client.callTool({
|
||||
name: "query-docs",
|
||||
arguments: { libraryId: "/vercel/next.js", query: "app router" },
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
|
||||
const apiCall = requests.find((request) => request.path === "/v2/context");
|
||||
expect(apiCall?.headers["x-context7-client-ide"]).toBe("claude-code");
|
||||
expect(apiCall?.headers["x-context7-client-version"]).toBe("1.0.0");
|
||||
expect(apiCall?.headers["x-context7-plugin"]).toBe("claude-code-plugin");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "context7",
|
||||
"version": "1.0.3",
|
||||
"description": "Upstash Context7 MCP server for up-to-date documentation lookup. Pull version-specific documentation and code examples directly from source repositories into your LLM context.",
|
||||
"author": {
|
||||
"name": "Upstash"
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
"mcpServers": {
|
||||
"context7": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "${CONTEXT7_API_KEY:-}"
|
||||
}
|
||||
"url": "https://mcp.context7.com/mcp?client=claude-code-plugin",
|
||||
"headersHelper": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/headers.mjs\""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,16 +20,17 @@ claude plugin marketplace add upstash/context7
|
||||
claude plugin install context7@context7-marketplace
|
||||
```
|
||||
|
||||
## API Key (Recommended)
|
||||
## Authentication
|
||||
|
||||
Without an API key, the plugin connects anonymously and shares the anonymous rate limits. To use your own plan, create an API key in the [Context7 dashboard](https://context7.com/dashboard) and export it as an environment variable before launching Claude Code:
|
||||
After installing the plugin, restart Claude Code and run:
|
||||
|
||||
```bash
|
||||
# e.g. in ~/.zshrc or ~/.bashrc
|
||||
export CONTEXT7_API_KEY="your-api-key"
|
||||
```
|
||||
/mcp
|
||||
```
|
||||
|
||||
The plugin's MCP server configuration picks up `CONTEXT7_API_KEY` automatically. Restart Claude Code after setting it, then verify the key is being used by checking your usage in the [dashboard](https://context7.com/dashboard).
|
||||
Select Context7 and follow the browser sign-in flow. No API key is required.
|
||||
|
||||
To use an API key instead, set `CONTEXT7_API_KEY` before starting Claude Code. The plugin sends the key only when it is present; otherwise it uses OAuth.
|
||||
|
||||
## Available Tools
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
const apiKey = process.env.CONTEXT7_API_KEY;
|
||||
|
||||
process.stdout.write(JSON.stringify(apiKey ? { Authorization: apiKey } : {}));
|
||||
Reference in New Issue
Block a user