@upstash/context7-mcp 4.0.2 → 4.0.4

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
@@ -10,9 +10,8 @@ import express from "express";
10
10
  import { Command } from "commander";
11
11
  import { AsyncLocalStorage } from "async_hooks";
12
12
  import { randomUUID } from "node:crypto";
13
- import { SERVER_VERSION, RESOURCE_URL, AUTH_SERVER_URL, OPENAI_APPS_CHALLENGE_TOKEN, } from "./lib/constants.js";
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";
16
15
  /** Default HTTP server port */
17
16
  const DEFAULT_PORT = 3000;
18
17
  // Parse CLI arguments using commander
@@ -242,6 +241,9 @@ async function main() {
242
241
  if (TRANSPORT_TYPE === "http") {
243
242
  const initialPort = CLI_PORT ?? DEFAULT_PORT;
244
243
  const app = express();
244
+ // Only private/local infrastructure may supply forwarding headers. Express
245
+ // then walks the chain right-to-left and ignores attacker-added prefixes.
246
+ app.set("trust proxy", ["loopback", "linklocal", "uniquelocal", "100.64.0.0/10"]);
245
247
  app.use(express.json());
246
248
  app.use((req, res, next) => {
247
249
  res.setHeader("Access-Control-Allow-Origin", "*");
@@ -273,6 +275,7 @@ async function main() {
273
275
  };
274
276
  const extractApiKey = (req) => {
275
277
  return (extractBearerToken(req.headers.authorization) ||
278
+ extractHeaderValue(req.headers["x-context7-api-key"]) ||
276
279
  extractHeaderValue(req.headers["context7-api-key"]) ||
277
280
  extractHeaderValue(req.headers["x-api-key"]) ||
278
281
  extractHeaderValue(req.headers["context7_api_key"]) ||
@@ -336,7 +339,7 @@ async function main() {
336
339
  }
337
340
  }
338
341
  const context = {
339
- clientIp: getClientIp(req),
342
+ clientIp: req.ip,
340
343
  apiKey: apiKey,
341
344
  clientInfo: extractClientInfoFromUserAgent(req.headers["user-agent"]),
342
345
  transport: "http",
@@ -372,13 +375,16 @@ async function main() {
372
375
  app.get("/.well-known/oauth-protected-resource", (_req, res) => {
373
376
  res.json({
374
377
  resource: RESOURCE_URL,
375
- authorization_servers: [AUTH_SERVER_URL],
378
+ // Each entry is an independent authorization server. Clerk handles
379
+ // regular authorization-code flows; Context7 handles only the
380
+ // enterprise-managed id-jag exchange.
381
+ authorization_servers: Array.from(new Set([OAUTH_AUTH_SERVER_URL, EMA_ISSUER])),
376
382
  scopes_supported: ["profile", "email"],
377
383
  bearer_methods_supported: ["header"],
378
384
  });
379
385
  });
380
386
  app.get("/.well-known/oauth-authorization-server", async (_req, res) => {
381
- const authServerUrl = AUTH_SERVER_URL;
387
+ const authServerUrl = OAUTH_AUTH_SERVER_URL;
382
388
  try {
383
389
  const response = await fetch(`${authServerUrl}/.well-known/oauth-authorization-server`);
384
390
  if (!response.ok) {
@@ -6,12 +6,20 @@ const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf-
6
6
  export const SERVER_VERSION = pkg.version;
7
7
  const CONTEXT7_BASE_URL = "https://context7.com";
8
8
  const MCP_RESOURCE_URL = "https://mcp.context7.com";
9
- export const CLERK_DOMAIN = "clerk.context7.com";
9
+ const DEFAULT_OAUTH_AUTH_SERVER_URL = "https://clerk.context7.com";
10
10
  export const CONTEXT7_API_BASE_URL = process.env.CONTEXT7_API_URL || `${CONTEXT7_BASE_URL}/api`;
11
11
  export const RESOURCE_URL = process.env.RESOURCE_URL || MCP_RESOURCE_URL;
12
- export const AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || CONTEXT7_BASE_URL;
12
+ // Clerk owns the interactive OAuth flow and is the issuer returned in the
13
+ // authorization response. Advertising Clerk directly keeps RFC 8414 discovery
14
+ // and RFC 9207 response-issuer validation on the same authorization-server
15
+ // identity.
16
+ export const OAUTH_AUTH_SERVER_URL = (process.env.OAUTH_AUTH_SERVER_URL || DEFAULT_OAUTH_AUTH_SERVER_URL).replace(/\/+$/, "");
17
+ export const OAUTH_JWKS_URL = process.env.OAUTH_JWKS_URL || `${OAUTH_AUTH_SERVER_URL}/.well-known/jwks.json`;
13
18
  // Enterprise-Managed Auth (id-jag): access tokens minted by the Context7
14
19
  // authorization server, validated against its public JWKS.
15
- export const EMA_ISSUER = AUTH_SERVER_URL;
20
+ // AUTH_SERVER_URL remains a backwards-compatible alias for local EMA setups;
21
+ // it does not move interactive user OAuth. Local end-to-end OAuth environments
22
+ // must set OAUTH_AUTH_SERVER_URL separately when Clerk is not the intended issuer.
23
+ export const EMA_ISSUER = process.env.EMA_ISSUER || process.env.AUTH_SERVER_URL || CONTEXT7_BASE_URL;
16
24
  export const EMA_JWKS_URL = process.env.EMA_JWKS_URL || `${CONTEXT7_API_BASE_URL}/oauth/ema-jwks`;
17
25
  export const OPENAI_APPS_CHALLENGE_TOKEN = process.env.OPENAI_APPS_CHALLENGE_TOKEN;
@@ -1,27 +1,59 @@
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 LEGACY_ALGORITHM = "aes-256-cbc";
5
+ const ASSERTION_ALGORITHM = "aes-256-gcm";
6
+ const ASSERTION_VERSION = "v1";
7
+ let reportedInvalidAssertionKey = false;
6
8
  function validateEncryptionKey(key) {
7
9
  // Must be exactly 64 hex characters (32 bytes)
8
10
  return /^[0-9a-fA-F]{64}$/.test(key);
9
11
  }
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
14
- }
12
+ function encryptionKey(name) {
13
+ const key = process.env[name];
14
+ return key && validateEncryptionKey(key) ? Buffer.from(key, "hex") : null;
15
+ }
16
+ /**
17
+ * Temporary compatibility header for API deployments that predate authenticated assertions.
18
+ * This header is ignored by patched API deployments. Removal is tracked by CTX7-2536.
19
+ */
20
+ function encryptLegacyClientIp(clientIp, key) {
15
21
  try {
16
22
  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;
23
+ const cipher = createCipheriv(LEGACY_ALGORITHM, key, iv);
24
+ const encrypted = Buffer.concat([cipher.update(clientIp, "utf8"), cipher.final()]);
25
+ return `${iv.toString("hex")}:${encrypted.toString("hex")}`;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /**
32
+ * Create a short-lived, authenticated client-IP assertion.
33
+ * Format: v1:<unix timestamp seconds>:<12-byte nonce hex>:<ciphertext + tag hex>
34
+ */
35
+ export function createClientIpAssertion(clientIp, nowMs = Date.now(), nonce = randomBytes(12)) {
36
+ const key = encryptionKey("MCP_CLIENT_IP_ASSERTION_KEY");
37
+ if (!key) {
38
+ if (!reportedInvalidAssertionKey) {
39
+ reportedInvalidAssertionKey = true;
40
+ console.error("MCP_CLIENT_IP_ASSERTION_KEY is missing or invalid; client IP assertions are disabled.");
41
+ }
42
+ return null;
43
+ }
44
+ if (nonce.length !== 12 || isIP(clientIp) === 0)
45
+ return null;
46
+ try {
47
+ const timestamp = Math.floor(nowMs / 1000).toString();
48
+ const aad = `${ASSERTION_VERSION}:${timestamp}`;
49
+ const cipher = createCipheriv(ASSERTION_ALGORITHM, key, nonce);
50
+ cipher.setAAD(Buffer.from(aad, "utf8"));
51
+ const ciphertext = Buffer.concat([cipher.update(clientIp, "utf8"), cipher.final()]);
52
+ const ciphertextAndTag = Buffer.concat([ciphertext, cipher.getAuthTag()]);
53
+ return `${aad}:${nonce.toString("hex")}:${ciphertextAndTag.toString("hex")}`;
21
54
  }
22
- catch (error) {
23
- console.error("Error encrypting client IP:", error);
24
- return clientIp; // Fallback to unencrypted
55
+ catch {
56
+ return null;
25
57
  }
26
58
  }
27
59
  /**
@@ -34,7 +66,15 @@ export function generateHeaders(context) {
34
66
  "X-Context7-Server-Version": SERVER_VERSION,
35
67
  };
36
68
  if (context.clientIp) {
37
- headers["mcp-client-ip"] = encryptClientIp(context.clientIp);
69
+ const assertion = createClientIpAssertion(context.clientIp);
70
+ if (assertion) {
71
+ headers["mcp-client-ip-assertion"] = assertion;
72
+ // Producer-first rollout compatibility. Removal is tracked by CTX7-2536.
73
+ const key = encryptionKey("CLIENT_IP_ENCRYPTION_KEY");
74
+ const legacyValue = key ? encryptLegacyClientIp(context.clientIp, key) : null;
75
+ if (legacyValue)
76
+ headers["mcp-client-ip"] = legacyValue;
77
+ }
38
78
  }
39
79
  if (context.sessionId) {
40
80
  headers["mcp-session-id"] = context.sessionId;
package/dist/lib/jwt.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import * as jose from "jose";
2
- import { CLERK_DOMAIN, CONTEXT7_API_BASE_URL, EMA_ISSUER, EMA_JWKS_URL, RESOURCE_URL, } from "./constants.js";
3
- const CLERK_ISSUER = `https://${CLERK_DOMAIN}`;
4
- const clerkJwks = jose.createRemoteJWKSet(new URL(`https://${CLERK_DOMAIN}/.well-known/jwks.json`));
2
+ import { CONTEXT7_API_BASE_URL, EMA_ISSUER, EMA_JWKS_URL, OAUTH_AUTH_SERVER_URL, OAUTH_JWKS_URL, RESOURCE_URL, } from "./constants.js";
3
+ const oauthJwks = jose.createRemoteJWKSet(new URL(OAUTH_JWKS_URL));
5
4
  const emaJwks = jose.createRemoteJWKSet(new URL(EMA_JWKS_URL));
6
5
  const ENTRA_V2_ISSUER_RE = /^https:\/\/login\.microsoftonline\.com\/[0-9a-f-]{36}\/v2\.0$/;
7
6
  const jwksByTenant = new Map();
@@ -70,7 +69,7 @@ export async function validateJWT(token) {
70
69
  await jose.jwtVerify(token, emaJwks, { issuer: EMA_ISSUER, audience: RESOURCE_URL });
71
70
  return { valid: true };
72
71
  }
73
- await jose.jwtVerify(token, clerkJwks, { issuer: CLERK_ISSUER });
72
+ await jose.jwtVerify(token, oauthJwks, { issuer: OAUTH_AUTH_SERVER_URL });
74
73
  return { valid: true };
75
74
  }
76
75
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upstash/context7-mcp",
3
- "version": "4.0.2",
3
+ "version": "4.0.4",
4
4
  "mcpName": "io.github.upstash/context7",
5
5
  "description": "MCP server for Context7",
6
6
  "repository": {
@@ -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
- }