Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/mcp-plugin-client-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@upstash/context7-mcp": patch
---

Require authentication and track usage separately for the Claude Code plugin.
3 changes: 2 additions & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
51 changes: 35 additions & 16 deletions packages/cli/src/__tests__/plugin-manifests.test.ts
Original file line number Diff line number Diff line change
@@ -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:-}",
});
});
});
26 changes: 16 additions & 10 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ import { getClientIp } from "./lib/client-ip.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()
Expand Down Expand Up @@ -384,12 +393,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;

Expand All @@ -403,7 +409,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",
Expand Down Expand Up @@ -432,8 +438,9 @@ async function main() {

const context: ClientContext = {
clientIp: getClientIp(req),
apiKey: apiKey,
apiKey,
clientInfo: extractClientInfoFromUserAgent(req.headers["user-agent"]),
plugin,
transport: "http",
};

Expand All @@ -452,14 +459,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) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/mcp/src/lib/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions packages/mcp/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,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 change: 1 addition & 0 deletions plugins/claude/context7/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
6 changes: 2 additions & 4 deletions plugins/claude/context7/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\""
}
}
}
13 changes: 7 additions & 6 deletions plugins/claude/context7/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Select Context7 and follow the browser sign-in flow. No API key is required.

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

Expand Down
3 changes: 3 additions & 0 deletions plugins/claude/context7/scripts/headers.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const apiKey = process.env.CONTEXT7_API_KEY;

process.stdout.write(JSON.stringify(apiKey ? { Authorization: apiKey } : {}));
Loading