Skip to content
Merged
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
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 @@ -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()
Expand Down Expand Up @@ -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;

Expand All @@ -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",
Expand Down Expand Up @@ -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",
};

Expand All @@ -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) => {
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 @@ -100,6 +100,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 @@ -306,3 +306,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