Skip to content

Commit 21c3dd4

Browse files
enesgulescursoragentfahreddinozcan
authored
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>
1 parent 6d77761 commit 21c3dd4

11 files changed

Lines changed: 146 additions & 37 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@upstash/context7-mcp": patch
3+
---
4+
5+
Require authentication and track usage separately for the Claude Code plugin.

.claude-plugin/marketplace.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
"owner": {
44
"name": "Upstash"
55
},
6+
"description": "Context7 plugins for coding agents.",
67
"plugins": [
78
{
89
"name": "context7",
910
"source": "./plugins/claude/context7",
1011
"description": "Up-to-date documentation lookup. Pull version-specific documentation and code examples directly from source repositories into your LLM context.",
11-
"version": "1.0.2"
12+
"version": "1.0.3"
1213
}
1314
]
1415
}
Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,43 @@
11
import { describe, test, expect } from "vitest";
22
import { readFile } from "fs/promises";
33
import { join } from "path";
4+
import { execFile } from "child_process";
5+
import { promisify } from "util";
46

57
const REPO_ROOT = join(import.meta.dirname, "..", "..", "..", "..");
8+
const execFileAsync = promisify(execFile);
69

710
describe("plugin MCP manifests", () => {
8-
// Deliberately the raw key, not `Bearer <key>` as the CLI writes. Both plugins
9-
// document that an unset key still works over the anonymous tier, and this is
10-
// the only form that survives both states: the server rejects `Bearer` with an
11-
// empty token but treats an empty Authorization as anonymous.
12-
test.each(["plugins/claude/context7/.mcp.json", "plugins/copilot/context7/.mcp.json"])(
13-
"%s passes the raw key via Authorization",
14-
async (relPath) => {
15-
const raw = await readFile(join(REPO_ROOT, relPath), "utf-8");
16-
const config = JSON.parse(raw) as {
17-
mcpServers: { context7: { headers: Record<string, string> } };
18-
};
19-
expect(config.mcpServers.context7.headers).toEqual({
20-
Authorization: "${CONTEXT7_API_KEY:-}",
21-
});
22-
}
23-
);
11+
test("Claude uses an API key only when one is set", async () => {
12+
const relPath = "plugins/claude/context7/.mcp.json";
13+
const raw = await readFile(join(REPO_ROOT, relPath), "utf-8");
14+
const config = JSON.parse(raw) as {
15+
mcpServers: { context7: { headers?: Record<string, string>; headersHelper: string } };
16+
};
17+
expect(config.mcpServers.context7.headers).toBeUndefined();
18+
expect(config.mcpServers.context7.headersHelper).toBe(
19+
'node "${CLAUDE_PLUGIN_ROOT}/scripts/headers.mjs"'
20+
);
21+
22+
const helper = join(REPO_ROOT, "plugins/claude/context7/scripts/headers.mjs");
23+
const withoutKey = await execFileAsync(process.execPath, [helper], {
24+
env: { ...process.env, CONTEXT7_API_KEY: "" },
25+
});
26+
expect(JSON.parse(withoutKey.stdout)).toEqual({});
27+
28+
const withKey = await execFileAsync(process.execPath, [helper], {
29+
env: { ...process.env, CONTEXT7_API_KEY: "ctx7sk-test" },
30+
});
31+
expect(JSON.parse(withKey.stdout)).toEqual({ Authorization: "ctx7sk-test" });
32+
});
33+
34+
test("Copilot passes the raw API key via Authorization", async () => {
35+
const raw = await readFile(join(REPO_ROOT, "plugins/copilot/context7/.mcp.json"), "utf-8");
36+
const config = JSON.parse(raw) as {
37+
mcpServers: { context7: { headers: Record<string, string> } };
38+
};
39+
expect(config.mcpServers.context7.headers).toEqual({
40+
Authorization: "${CONTEXT7_API_KEY:-}",
41+
});
42+
});
2443
});

packages/mcp/src/index.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ import { getMaxSubscriptions } from "./lib/subscriptions.js";
2828

2929
/** Default HTTP server port */
3030
const DEFAULT_PORT = 3000;
31+
const CLAUDE_CODE_PLUGIN = "claude-code-plugin";
32+
33+
function getPluginFromRequest(req: express.Request): typeof CLAUDE_CODE_PLUGIN | undefined {
34+
return req.query.client === CLAUDE_CODE_PLUGIN ? CLAUDE_CODE_PLUGIN : undefined;
35+
}
36+
37+
function requiresAuthentication(req: express.Request, plugin?: typeof CLAUDE_CODE_PLUGIN): boolean {
38+
return req.path === "/mcp/oauth" || Boolean(plugin);
39+
}
3140

3241
// Parse CLI arguments using commander
3342
const program = new Command()
@@ -390,12 +399,9 @@ async function main() {
390399
onerror: (error) => console.error("MCP node adapter error:", error),
391400
});
392401

393-
const handleMcpRequest = async (
394-
req: express.Request,
395-
res: express.Response,
396-
requireAuth: boolean
397-
) => {
402+
const handleMcpRequest = async (req: express.Request, res: express.Response) => {
398403
try {
404+
const plugin = getPluginFromRequest(req);
399405
const apiKey = extractApiKey(req);
400406
const baseUrl = new URL(RESOURCE_URL).origin;
401407

@@ -409,7 +415,7 @@ async function main() {
409415
`Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`
410416
);
411417

412-
if (requireAuth) {
418+
if (requiresAuthentication(req, plugin)) {
413419
if (!apiKey) {
414420
return res.status(401).json({
415421
jsonrpc: "2.0",
@@ -438,8 +444,9 @@ async function main() {
438444

439445
const context: ClientContext = {
440446
clientIp: req.ip,
441-
apiKey: apiKey,
447+
apiKey,
442448
clientInfo: extractClientInfoFromUserAgent(req.headers["user-agent"]),
449+
plugin,
443450
transport: "http",
444451
};
445452

@@ -458,14 +465,13 @@ async function main() {
458465
}
459466
};
460467

461-
// Anonymous access endpoint - no authentication required
462468
app.all("/mcp", async (req, res) => {
463-
await handleMcpRequest(req, res, false);
469+
await handleMcpRequest(req, res);
464470
});
465471

466472
// OAuth-protected endpoint - requires authentication
467473
app.all("/mcp/oauth", async (req, res) => {
468-
await handleMcpRequest(req, res, true);
474+
await handleMcpRequest(req, res);
469475
});
470476

471477
app.get("/ping", (_req: express.Request, res: express.Response) => {

packages/mcp/src/lib/encryption.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ export function generateHeaders(context: ClientContext): Record<string, string>
7777
if (context.clientInfo?.version) {
7878
headers["X-Context7-Client-Version"] = context.clientInfo.version;
7979
}
80+
if (context.plugin) {
81+
headers["X-Context7-Plugin"] = context.plugin;
82+
}
8083
if (context.transport) {
8184
headers["X-Context7-Transport"] = context.transport;
8285
}

packages/mcp/src/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface ClientContext {
3939
ide?: string;
4040
version?: string;
4141
};
42+
plugin?: string;
4243
transport?: "stdio" | "http";
4344
sessionId?: string;
4445
/** Mutable: set by the upstream API layer when the backend signals the

packages/mcp/test/integration.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,3 +305,74 @@ describe.each([
305305
expect(apiCall.headers["x-context7-client-version"]).toBe(expected.version);
306306
});
307307
});
308+
309+
const INITIALIZE = {
310+
jsonrpc: "2.0",
311+
id: 1,
312+
method: "initialize",
313+
params: {
314+
protocolVersion: "2025-06-18",
315+
capabilities: {},
316+
clientInfo: { name: "t", version: "1" },
317+
},
318+
};
319+
320+
async function postMcp(target: string, headers: Record<string, string> = {}) {
321+
const res = await fetch(target, {
322+
method: "POST",
323+
headers: {
324+
"Content-Type": "application/json",
325+
Accept: "application/json, text/event-stream",
326+
...headers,
327+
},
328+
body: JSON.stringify(INITIALIZE),
329+
});
330+
return { status: res.status, wwwAuthenticate: res.headers.get("www-authenticate") };
331+
}
332+
333+
describe("plugin authentication", () => {
334+
beforeEach(() => {
335+
requests.length = 0;
336+
});
337+
338+
test("only challenges the supported plugin", async () => {
339+
expect((await postMcp(`${httpUrl}?client=other-plugin`)).status).toBe(200);
340+
341+
const res = await postMcp(`${httpUrl}?client=claude-code-plugin`);
342+
expect(res.status).toBe(401);
343+
expect(res.wwwAuthenticate).toContain("resource_metadata=");
344+
expect(res.wwwAuthenticate).toContain("/.well-known/oauth-protected-resource");
345+
});
346+
347+
test("keeps the OAuth endpoint protected", async () => {
348+
const res = await postMcp(httpUrl.replace(/\/mcp$/, "/mcp/oauth"));
349+
350+
expect(res.status).toBe(401);
351+
});
352+
353+
test("tracks authenticated plugin requests separately", async () => {
354+
const client = new Client(
355+
{ name: "claude-code", version: "1.0.0" },
356+
{ versionNegotiation: { mode: { pin: "2026-07-28" } } }
357+
);
358+
await client.connect(
359+
new StreamableHTTPClientTransport(new URL(`${httpUrl}?client=claude-code-plugin`), {
360+
requestInit: { headers: { Authorization: "Bearer ctx7sk-test" } },
361+
})
362+
);
363+
364+
try {
365+
await client.callTool({
366+
name: "query-docs",
367+
arguments: { libraryId: "/vercel/next.js", query: "app router" },
368+
});
369+
} finally {
370+
await client.close();
371+
}
372+
373+
const apiCall = requests.find((request) => request.path === "/v2/context");
374+
expect(apiCall?.headers["x-context7-client-ide"]).toBe("claude-code");
375+
expect(apiCall?.headers["x-context7-client-version"]).toBe("1.0.0");
376+
expect(apiCall?.headers["x-context7-plugin"]).toBe("claude-code-plugin");
377+
});
378+
});

plugins/claude/context7/.claude-plugin/plugin.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"name": "context7",
3+
"version": "1.0.3",
34
"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.",
45
"author": {
56
"name": "Upstash"

plugins/claude/context7/.mcp.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@
22
"mcpServers": {
33
"context7": {
44
"type": "http",
5-
"url": "https://mcp.context7.com/mcp",
6-
"headers": {
7-
"Authorization": "${CONTEXT7_API_KEY:-}"
8-
}
5+
"url": "https://mcp.context7.com/mcp?client=claude-code-plugin",
6+
"headersHelper": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/headers.mjs\""
97
}
108
}
119
}

plugins/claude/context7/README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,17 @@ claude plugin marketplace add upstash/context7
2020
claude plugin install context7@context7-marketplace
2121
```
2222

23-
## API Key (Recommended)
23+
## Authentication
2424

25-
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:
25+
After installing the plugin, restart Claude Code and run:
2626

27-
```bash
28-
# e.g. in ~/.zshrc or ~/.bashrc
29-
export CONTEXT7_API_KEY="your-api-key"
3027
```
28+
/mcp
29+
```
30+
31+
Select Context7 and follow the browser sign-in flow. No API key is required.
3132

32-
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).
33+
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.
3334

3435
## Available Tools
3536

0 commit comments

Comments
 (0)