@upstash/context7-mcp 4.0.3 → 4.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -12,9 +12,16 @@ import { AsyncLocalStorage } from "async_hooks";
12
12
  import { randomUUID } from "node:crypto";
13
13
  import { SERVER_VERSION, RESOURCE_URL, OAUTH_AUTH_SERVER_URL, EMA_ISSUER, OPENAI_APPS_CHALLENGE_TOKEN, } from "./lib/constants.js";
14
14
  import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js";
15
- import { getClientIp } from "./lib/client-ip.js";
15
+ import { getMaxSubscriptions } from "./lib/subscriptions.js";
16
16
  /** Default HTTP server port */
17
17
  const DEFAULT_PORT = 3000;
18
+ const CLAUDE_CODE_PLUGIN = "claude-code-plugin";
19
+ function getPluginFromRequest(req) {
20
+ return req.query.client === CLAUDE_CODE_PLUGIN ? CLAUDE_CODE_PLUGIN : undefined;
21
+ }
22
+ function requiresAuthentication(req, plugin) {
23
+ return req.path === "/mcp/oauth" || Boolean(plugin);
24
+ }
18
25
  // Parse CLI arguments using commander
19
26
  const program = new Command()
20
27
  .version(SERVER_VERSION, "-v, --version", "output the current version")
@@ -242,6 +249,9 @@ async function main() {
242
249
  if (TRANSPORT_TYPE === "http") {
243
250
  const initialPort = CLI_PORT ?? DEFAULT_PORT;
244
251
  const app = express();
252
+ // Only private/local infrastructure may supply forwarding headers. Express
253
+ // then walks the chain right-to-left and ignores attacker-added prefixes.
254
+ app.set("trust proxy", ["loopback", "linklocal", "uniquelocal", "100.64.0.0/10"]);
245
255
  app.use(express.json());
246
256
  app.use((req, res, next) => {
247
257
  res.setHeader("Access-Control-Allow-Origin", "*");
@@ -273,6 +283,7 @@ async function main() {
273
283
  };
274
284
  const extractApiKey = (req) => {
275
285
  return (extractBearerToken(req.headers.authorization) ||
286
+ extractHeaderValue(req.headers["x-context7-api-key"]) ||
276
287
  extractHeaderValue(req.headers["context7-api-key"]) ||
277
288
  extractHeaderValue(req.headers["x-api-key"]) ||
278
289
  extractHeaderValue(req.headers["context7_api_key"]) ||
@@ -293,6 +304,7 @@ async function main() {
293
304
  // go idle and the gateway reaps them at streamIdleTimeout (300s).
294
305
  const mcpHandler = createMcpHandler(() => createMcpServer(), {
295
306
  keepAliveMs: 0,
307
+ maxSubscriptions: getMaxSubscriptions(),
296
308
  onerror: (error) => console.error("MCP handler error:", error),
297
309
  });
298
310
  // Without onerror, request-conversion / handler.fetch throws are answered
@@ -300,8 +312,9 @@ async function main() {
300
312
  const nodeHandler = toNodeHandler(mcpHandler, {
301
313
  onerror: (error) => console.error("MCP node adapter error:", error),
302
314
  });
303
- const handleMcpRequest = async (req, res, requireAuth) => {
315
+ const handleMcpRequest = async (req, res) => {
304
316
  try {
317
+ const plugin = getPluginFromRequest(req);
305
318
  const apiKey = extractApiKey(req);
306
319
  const baseUrl = new URL(RESOURCE_URL).origin;
307
320
  // OAuth discovery info header, used by MCP clients to discover the authorization server
@@ -310,7 +323,7 @@ async function main() {
310
323
  // oauthMetadataResponse) — replace this hand-rolled header and the
311
324
  // /.well-known/oauth-protected-resource route with them.
312
325
  res.set("WWW-Authenticate", `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`);
313
- if (requireAuth) {
326
+ if (requiresAuthentication(req, plugin)) {
314
327
  if (!apiKey) {
315
328
  return res.status(401).json({
316
329
  jsonrpc: "2.0",
@@ -336,9 +349,10 @@ async function main() {
336
349
  }
337
350
  }
338
351
  const context = {
339
- clientIp: getClientIp(req),
340
- apiKey: apiKey,
352
+ clientIp: req.ip,
353
+ apiKey,
341
354
  clientInfo: extractClientInfoFromUserAgent(req.headers["user-agent"]),
355
+ plugin,
342
356
  transport: "http",
343
357
  };
344
358
  await requestContext.run(context, async () => {
@@ -356,13 +370,12 @@ async function main() {
356
370
  }
357
371
  }
358
372
  };
359
- // Anonymous access endpoint - no authentication required
360
373
  app.all("/mcp", async (req, res) => {
361
- await handleMcpRequest(req, res, false);
374
+ await handleMcpRequest(req, res);
362
375
  });
363
376
  // OAuth-protected endpoint - requires authentication
364
377
  app.all("/mcp/oauth", async (req, res) => {
365
- await handleMcpRequest(req, res, true);
378
+ await handleMcpRequest(req, res);
366
379
  });
367
380
  app.get("/ping", (_req, res) => {
368
381
  res.json({ status: "ok", message: "pong" });
@@ -1,27 +1,43 @@
1
1
  import { createCipheriv, randomBytes } from "crypto";
2
+ import { isIP } from "node:net";
2
3
  import { SERVER_VERSION } from "./constants.js";
3
- const DEFAULT_ENCRYPTION_KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
4
- const ENCRYPTION_KEY = process.env.CLIENT_IP_ENCRYPTION_KEY || DEFAULT_ENCRYPTION_KEY;
5
- const ALGORITHM = "aes-256-cbc";
4
+ const ASSERTION_ALGORITHM = "aes-256-gcm";
5
+ const ASSERTION_VERSION = "v1";
6
+ let reportedInvalidAssertionKey = false;
6
7
  function validateEncryptionKey(key) {
7
8
  // Must be exactly 64 hex characters (32 bytes)
8
9
  return /^[0-9a-fA-F]{64}$/.test(key);
9
10
  }
10
- function encryptClientIp(clientIp) {
11
- if (!validateEncryptionKey(ENCRYPTION_KEY)) {
12
- console.error("Invalid encryption key format. Must be 64 hex characters.");
13
- return clientIp; // Fallback to unencrypted
11
+ function assertionKey() {
12
+ const key = process.env.MCP_CLIENT_IP_ASSERTION_KEY;
13
+ return key && validateEncryptionKey(key) ? Buffer.from(key, "hex") : null;
14
+ }
15
+ /**
16
+ * Create a short-lived, authenticated client-IP assertion.
17
+ * Format: v1:<unix timestamp seconds>:<12-byte nonce hex>:<ciphertext + tag hex>
18
+ */
19
+ export function createClientIpAssertion(clientIp, nowMs = Date.now(), nonce = randomBytes(12)) {
20
+ const key = assertionKey();
21
+ if (!key) {
22
+ if (!reportedInvalidAssertionKey) {
23
+ reportedInvalidAssertionKey = true;
24
+ console.error("MCP_CLIENT_IP_ASSERTION_KEY is missing or invalid; client IP assertions are disabled.");
25
+ }
26
+ return null;
14
27
  }
28
+ if (nonce.length !== 12 || isIP(clientIp) === 0)
29
+ return null;
15
30
  try {
16
- const iv = randomBytes(16);
17
- const cipher = createCipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, "hex"), iv);
18
- let encrypted = cipher.update(clientIp, "utf8", "hex");
19
- encrypted += cipher.final("hex");
20
- return iv.toString("hex") + ":" + encrypted;
31
+ const timestamp = Math.floor(nowMs / 1000).toString();
32
+ const aad = `${ASSERTION_VERSION}:${timestamp}`;
33
+ const cipher = createCipheriv(ASSERTION_ALGORITHM, key, nonce);
34
+ cipher.setAAD(Buffer.from(aad, "utf8"));
35
+ const ciphertext = Buffer.concat([cipher.update(clientIp, "utf8"), cipher.final()]);
36
+ const ciphertextAndTag = Buffer.concat([ciphertext, cipher.getAuthTag()]);
37
+ return `${aad}:${nonce.toString("hex")}:${ciphertextAndTag.toString("hex")}`;
21
38
  }
22
- catch (error) {
23
- console.error("Error encrypting client IP:", error);
24
- return clientIp; // Fallback to unencrypted
39
+ catch {
40
+ return null;
25
41
  }
26
42
  }
27
43
  /**
@@ -34,7 +50,9 @@ export function generateHeaders(context) {
34
50
  "X-Context7-Server-Version": SERVER_VERSION,
35
51
  };
36
52
  if (context.clientIp) {
37
- headers["mcp-client-ip"] = encryptClientIp(context.clientIp);
53
+ const assertion = createClientIpAssertion(context.clientIp);
54
+ if (assertion)
55
+ headers["mcp-client-ip-assertion"] = assertion;
38
56
  }
39
57
  if (context.sessionId) {
40
58
  headers["mcp-session-id"] = context.sessionId;
@@ -48,6 +66,9 @@ export function generateHeaders(context) {
48
66
  if (context.clientInfo?.version) {
49
67
  headers["X-Context7-Client-Version"] = context.clientInfo.version;
50
68
  }
69
+ if (context.plugin) {
70
+ headers["X-Context7-Plugin"] = context.plugin;
71
+ }
51
72
  if (context.transport) {
52
73
  headers["X-Context7-Transport"] = context.transport;
53
74
  }
@@ -0,0 +1,11 @@
1
+ // 16k stayed near baseline latency in Docker; 32,768 raised tools/list p95 to ~39 ms.
2
+ export const DEFAULT_MAX_SUBSCRIPTIONS = 16_000;
3
+ export function getMaxSubscriptions(value = process.env.MCP_MAX_SUBSCRIPTIONS) {
4
+ if (value === undefined)
5
+ return DEFAULT_MAX_SUBSCRIPTIONS;
6
+ const parsed = Number(value);
7
+ if (Number.isSafeInteger(parsed) && parsed > 0)
8
+ return parsed;
9
+ console.warn(`Invalid MCP_MAX_SUBSCRIPTIONS; using the default of ${DEFAULT_MAX_SUBSCRIPTIONS}.`);
10
+ return DEFAULT_MAX_SUBSCRIPTIONS;
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upstash/context7-mcp",
3
- "version": "4.0.3",
3
+ "version": "4.0.5",
4
4
  "mcpName": "io.github.upstash/context7",
5
5
  "description": "MCP server for Context7",
6
6
  "repository": {
@@ -45,6 +45,7 @@
45
45
  "devDependencies": {
46
46
  "@modelcontextprotocol/client": "2.0.0",
47
47
  "@types/node": "^25.0.3",
48
+ "esbuild": "^0.28.2",
48
49
  "typescript": "^5.8.2",
49
50
  "vitest": "^4.1.9"
50
51
  },
@@ -61,6 +62,7 @@
61
62
  "format:check": "prettier --check .",
62
63
  "dev": "tsc --watch",
63
64
  "start": "node dist/index.js --transport http",
64
- "pack-mcpb": "pnpm install && pnpm run build && rm -rf node_modules && pnpm install --prod && cp mcpb/manifest.json manifest.json && cp mcpb/.mcpbignore .mcpbignore && cp ../../public/icon.png icon.png && mcpb validate manifest.json && mcpb pack . mcpb/context7.mcpb && rm manifest.json .mcpbignore icon.png && pnpm install"
65
+ "build:mcpb": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=mcpb/stage/server/dist/index.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\"",
66
+ "pack-mcpb": "rm -rf mcpb/stage && pnpm run build:mcpb && cp package.json mcpb/stage/package.json && cp mcpb/manifest.json mcpb/stage/manifest.json && cp ../../public/icon.png mcpb/stage/icon.png && mcpb validate mcpb/stage/manifest.json && mcpb pack mcpb/stage mcpb/context7.mcpb && rm -rf mcpb/stage"
65
67
  }
66
68
  }
@@ -1,60 +0,0 @@
1
- function stripIpv4MappedPrefix(ip) {
2
- return ip.replace(/^::ffff:/i, "");
3
- }
4
- /**
5
- * Returns true for RFC1918, CGNAT, loopback, link-local, and IPv6 private ranges.
6
- */
7
- export function isPrivateOrLocalIp(ip) {
8
- const plainIp = stripIpv4MappedPrefix(ip).toLowerCase();
9
- if (plainIp.includes(".")) {
10
- return (plainIp.startsWith("10.") ||
11
- plainIp.startsWith("192.168.") ||
12
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(plainIp) ||
13
- /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./.test(plainIp) ||
14
- plainIp.startsWith("127.") ||
15
- plainIp.startsWith("169.254."));
16
- }
17
- // ::1 loopback in any textual form (e.g. "0::1", "0:0:0:0:0:0:0:1")
18
- if (/^[0:]+1$/.test(plainIp)) {
19
- return true;
20
- }
21
- // First hextets in fe80::/10 and fc00::/7 start with a non-zero digit, so a
22
- // valid textual form always spells out all 4 digits.
23
- // fe80::/10 link-local
24
- if (/^fe[89ab][0-9a-f]:/.test(plainIp)) {
25
- return true;
26
- }
27
- // fc00::/7 unique local
28
- if (/^f[cd][0-9a-f]{2}:/.test(plainIp)) {
29
- return true;
30
- }
31
- return false;
32
- }
33
- function pickClientIpFromForwardedList(ipList) {
34
- for (const ip of ipList) {
35
- const plainIp = stripIpv4MappedPrefix(ip);
36
- if (!isPrivateOrLocalIp(plainIp)) {
37
- return plainIp;
38
- }
39
- }
40
- if (ipList.length === 0) {
41
- return undefined;
42
- }
43
- return stripIpv4MappedPrefix(ipList[0]);
44
- }
45
- /**
46
- * Extract client IP address from request headers.
47
- * Handles X-Forwarded-For header for proxied requests.
48
- */
49
- export function getClientIp(req) {
50
- const forwardedFor = req.headers["x-forwarded-for"] || req.headers["X-Forwarded-For"];
51
- if (forwardedFor) {
52
- const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
53
- const ipList = ips.split(",").map((ip) => ip.trim());
54
- return pickClientIpFromForwardedList(ipList);
55
- }
56
- if (req.socket?.remoteAddress) {
57
- return stripIpv4MappedPrefix(req.socket.remoteAddress);
58
- }
59
- return undefined;
60
- }