@upstash/context7-mcp 4.0.4 → 4.0.6

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/README.md CHANGED
@@ -1621,6 +1621,34 @@ Stay updated and join our community:
1621
1621
  - [AICodeKing: "Context7 + Cline & RooCode: This MCP Server Makes CLINE 100X MORE EFFECTIVE!"](https://www.youtube.com/watch?v=qZfENAPMnyo)
1622
1622
  - [Sean Kochel: "5 MCP Servers For Vibe Coding Glory (Just Plug-In & Go)"](https://www.youtube.com/watch?v=LqTQi8qexJM)
1623
1623
 
1624
+ ## Vercel Marketplace OIDC
1625
+
1626
+ Vercel Marketplace resources can call the remote MCP server without a
1627
+ long-lived Context7 API key. Obtain a fresh per-resource access token from
1628
+ Vercel's Marketplace integration runtime and send it as the bearer credential:
1629
+
1630
+ ```ts
1631
+ import { getIntegrationToken } from "@vercel/integrations";
1632
+
1633
+ const authorization = `Bearer ${await getIntegrationToken("context7")}`;
1634
+ ```
1635
+
1636
+ The `getIntegrationToken` signature is based on Vercel's current provider
1637
+ specification and may change before Marketplace OIDC is generally available.
1638
+
1639
+ Use `authorization` as the `Authorization` header when creating the MCP HTTP
1640
+ transport. Create the transport inside the request that uses it so a short-lived
1641
+ token is not retained across function invocations. Its `resource` claim must
1642
+ match the Context7 resource created during Marketplace provisioning.
1643
+
1644
+ The hosted MCP service must be configured with the exact issuer and audience
1645
+ assigned by Vercel when the Context7 Marketplace product is created:
1646
+
1647
+ ```sh
1648
+ VERCEL_MARKETPLACE_OIDC_ISSUER=https://integrations.vercel.com/oac_...
1649
+ VERCEL_MARKETPLACE_OIDC_AUDIENCE=https://integrations.vercel.com/context7/icfg_...
1650
+ ```
1651
+
1624
1652
  ## 📄 License
1625
1653
 
1626
1654
  MIT
package/dist/index.js CHANGED
@@ -12,8 +12,18 @@ 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 { getMaxSubscriptions } from "./lib/subscriptions.js";
16
+ import { mcpBodyErrorHandler } from "./lib/mcp-body-error-handler.js";
15
17
  /** Default HTTP server port */
16
18
  const DEFAULT_PORT = 3000;
19
+ const CLAUDE_CODE_PLUGIN = "claude-code-plugin";
20
+ function getPluginFromRequest(req) {
21
+ return req.query.client === CLAUDE_CODE_PLUGIN ? CLAUDE_CODE_PLUGIN : undefined;
22
+ }
23
+ function requiresAuthentication(req, plugin) {
24
+ // The MCP routes live on a router mounted at /mcp, so req.path is relative to it.
25
+ return `${req.baseUrl}${req.path}` === "/mcp/oauth" || Boolean(plugin);
26
+ }
17
27
  // Parse CLI arguments using commander
18
28
  const program = new Command()
19
29
  .version(SERVER_VERSION, "-v, --version", "output the current version")
@@ -244,7 +254,9 @@ async function main() {
244
254
  // Only private/local infrastructure may supply forwarding headers. Express
245
255
  // then walks the chain right-to-left and ignores attacker-added prefixes.
246
256
  app.set("trust proxy", ["loopback", "linklocal", "uniquelocal", "100.64.0.0/10"]);
247
- app.use(express.json());
257
+ // Registered ahead of the MCP router so its error responses carry the CORS
258
+ // headers too; browser clients would otherwise see a CORS failure instead
259
+ // of the status.
248
260
  app.use((req, res, next) => {
249
261
  res.setHeader("Access-Control-Allow-Origin", "*");
250
262
  res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,DELETE");
@@ -296,6 +308,7 @@ async function main() {
296
308
  // go idle and the gateway reaps them at streamIdleTimeout (300s).
297
309
  const mcpHandler = createMcpHandler(() => createMcpServer(), {
298
310
  keepAliveMs: 0,
311
+ maxSubscriptions: getMaxSubscriptions(),
299
312
  onerror: (error) => console.error("MCP handler error:", error),
300
313
  });
301
314
  // Without onerror, request-conversion / handler.fetch throws are answered
@@ -303,8 +316,9 @@ async function main() {
303
316
  const nodeHandler = toNodeHandler(mcpHandler, {
304
317
  onerror: (error) => console.error("MCP node adapter error:", error),
305
318
  });
306
- const handleMcpRequest = async (req, res, requireAuth) => {
319
+ const handleMcpRequest = async (req, res) => {
307
320
  try {
321
+ const plugin = getPluginFromRequest(req);
308
322
  const apiKey = extractApiKey(req);
309
323
  const baseUrl = new URL(RESOURCE_URL).origin;
310
324
  // OAuth discovery info header, used by MCP clients to discover the authorization server
@@ -313,7 +327,7 @@ async function main() {
313
327
  // oauthMetadataResponse) — replace this hand-rolled header and the
314
328
  // /.well-known/oauth-protected-resource route with them.
315
329
  res.set("WWW-Authenticate", `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`);
316
- if (requireAuth) {
330
+ if (requiresAuthentication(req, plugin)) {
317
331
  if (!apiKey) {
318
332
  return res.status(401).json({
319
333
  jsonrpc: "2.0",
@@ -340,8 +354,9 @@ async function main() {
340
354
  }
341
355
  const context = {
342
356
  clientIp: req.ip,
343
- apiKey: apiKey,
357
+ apiKey,
344
358
  clientInfo: extractClientInfoFromUserAgent(req.headers["user-agent"]),
359
+ plugin,
345
360
  transport: "http",
346
361
  };
347
362
  await requestContext.run(context, async () => {
@@ -359,14 +374,16 @@ async function main() {
359
374
  }
360
375
  }
361
376
  };
362
- // Anonymous access endpoint - no authentication required
363
- app.all("/mcp", async (req, res) => {
364
- await handleMcpRequest(req, res, false);
365
- });
377
+ // JSON bodies and JSON-RPC error envelopes are the MCP contract only, so the
378
+ // parser and its error boundary live on the MCP router: every other route
379
+ // stays out of the parser and keeps its own response shape.
380
+ const mcpRouter = express.Router();
381
+ mcpRouter.use(express.json());
382
+ mcpRouter.use(mcpBodyErrorHandler);
383
+ mcpRouter.all("/", (req, res) => handleMcpRequest(req, res));
366
384
  // OAuth-protected endpoint - requires authentication
367
- app.all("/mcp/oauth", async (req, res) => {
368
- await handleMcpRequest(req, res, true);
369
- });
385
+ mcpRouter.all("/oauth", (req, res) => handleMcpRequest(req, res));
386
+ app.use("/mcp", mcpRouter);
370
387
  app.get("/ping", (_req, res) => {
371
388
  res.json({ status: "ok", message: "pong" });
372
389
  });
@@ -1,7 +1,6 @@
1
1
  import { createCipheriv, randomBytes } from "crypto";
2
2
  import { isIP } from "node:net";
3
3
  import { SERVER_VERSION } from "./constants.js";
4
- const LEGACY_ALGORITHM = "aes-256-cbc";
5
4
  const ASSERTION_ALGORITHM = "aes-256-gcm";
6
5
  const ASSERTION_VERSION = "v1";
7
6
  let reportedInvalidAssertionKey = false;
@@ -9,31 +8,16 @@ function validateEncryptionKey(key) {
9
8
  // Must be exactly 64 hex characters (32 bytes)
10
9
  return /^[0-9a-fA-F]{64}$/.test(key);
11
10
  }
12
- function encryptionKey(name) {
13
- const key = process.env[name];
11
+ function assertionKey() {
12
+ const key = process.env.MCP_CLIENT_IP_ASSERTION_KEY;
14
13
  return key && validateEncryptionKey(key) ? Buffer.from(key, "hex") : null;
15
14
  }
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) {
21
- try {
22
- const iv = randomBytes(16);
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
15
  /**
32
16
  * Create a short-lived, authenticated client-IP assertion.
33
17
  * Format: v1:<unix timestamp seconds>:<12-byte nonce hex>:<ciphertext + tag hex>
34
18
  */
35
19
  export function createClientIpAssertion(clientIp, nowMs = Date.now(), nonce = randomBytes(12)) {
36
- const key = encryptionKey("MCP_CLIENT_IP_ASSERTION_KEY");
20
+ const key = assertionKey();
37
21
  if (!key) {
38
22
  if (!reportedInvalidAssertionKey) {
39
23
  reportedInvalidAssertionKey = true;
@@ -67,14 +51,8 @@ export function generateHeaders(context) {
67
51
  };
68
52
  if (context.clientIp) {
69
53
  const assertion = createClientIpAssertion(context.clientIp);
70
- if (assertion) {
54
+ if (assertion)
71
55
  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
- }
78
56
  }
79
57
  if (context.sessionId) {
80
58
  headers["mcp-session-id"] = context.sessionId;
@@ -88,6 +66,9 @@ export function generateHeaders(context) {
88
66
  if (context.clientInfo?.version) {
89
67
  headers["X-Context7-Client-Version"] = context.clientInfo.version;
90
68
  }
69
+ if (context.plugin) {
70
+ headers["X-Context7-Plugin"] = context.plugin;
71
+ }
91
72
  if (context.transport) {
92
73
  headers["X-Context7-Transport"] = context.transport;
93
74
  }
package/dist/lib/jwt.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as jose from "jose";
2
2
  import { CONTEXT7_API_BASE_URL, EMA_ISSUER, EMA_JWKS_URL, OAUTH_AUTH_SERVER_URL, OAUTH_JWKS_URL, RESOURCE_URL, } from "./constants.js";
3
+ import { isVercelMarketplaceIssuer, validateVercelMarketplaceJwt } from "./vercelMarketplaceJwt.js";
3
4
  const oauthJwks = jose.createRemoteJWKSet(new URL(OAUTH_JWKS_URL));
4
5
  const emaJwks = jose.createRemoteJWKSet(new URL(EMA_JWKS_URL));
5
6
  const ENTRA_V2_ISSUER_RE = /^https:\/\/login\.microsoftonline\.com\/[0-9a-f-]{36}\/v2\.0$/;
@@ -69,6 +70,9 @@ export async function validateJWT(token) {
69
70
  await jose.jwtVerify(token, emaJwks, { issuer: EMA_ISSUER, audience: RESOURCE_URL });
70
71
  return { valid: true };
71
72
  }
73
+ if (isVercelMarketplaceIssuer(iss)) {
74
+ return validateVercelMarketplaceJwt(token, iss);
75
+ }
72
76
  await jose.jwtVerify(token, oauthJwks, { issuer: OAUTH_AUTH_SERVER_URL });
73
77
  return { valid: true };
74
78
  }
@@ -0,0 +1,23 @@
1
+ // Error boundary for the MCP router's JSON body parser. express.json() reports
2
+ // a body it will not accept via next(err); left unhandled, Express answers with
3
+ // an HTML stack trace naming dependency versions and absolute paths.
4
+ export const mcpBodyErrorHandler = (err, req, res, next) => {
5
+ const { status, type } = err;
6
+ // body-parser tags every rejection with a 4xx; anything else is not a body
7
+ // problem. Once a response has started, only Express can wind it down.
8
+ if (typeof status !== "number" || status < 400 || status >= 500 || res.headersSent) {
9
+ return next(err);
10
+ }
11
+ // Method, path and size only: the body is the rejected input, and the
12
+ // headers carry the API key.
13
+ console.error(`Rejected request body (${status}, ${String(type ?? "unknown")}): ${req.method} ${req.baseUrl}${req.path} bytes=${req.headers["content-length"] ?? "unknown"}`);
14
+ // Fixed messages: body-parser's own quote offsets into the body.
15
+ const parseFailure = err instanceof SyntaxError && type === "entity.parse.failed";
16
+ res.status(status).json({
17
+ jsonrpc: "2.0",
18
+ error: parseFailure
19
+ ? { code: -32700, message: "Parse error" }
20
+ : { code: -32600, message: "Invalid Request" },
21
+ id: null,
22
+ });
23
+ };
@@ -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
+ }
@@ -0,0 +1,54 @@
1
+ import * as jose from "jose";
2
+ const VERCEL_INTEGRATIONS_ORIGIN = "https://integrations.vercel.com";
3
+ const VERCEL_ISSUER_PATH = /^\/oac_[A-Za-z0-9]+$/;
4
+ const VERCEL_AUDIENCE_PATH = /^\/[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\/icfg_[A-Za-z0-9]+$/;
5
+ let jwks;
6
+ function isExpectedVercelUrl(value, pathPattern) {
7
+ try {
8
+ const url = new URL(value);
9
+ return (url.origin === VERCEL_INTEGRATIONS_ORIGIN &&
10
+ !url.username &&
11
+ !url.password &&
12
+ !url.search &&
13
+ !url.hash &&
14
+ pathPattern.test(url.pathname));
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ function getConfig() {
21
+ const issuer = process.env.VERCEL_MARKETPLACE_OIDC_ISSUER ?? "";
22
+ const audience = process.env.VERCEL_MARKETPLACE_OIDC_AUDIENCE ?? "";
23
+ if (!isVercelMarketplaceIssuer(issuer) || !isExpectedVercelUrl(audience, VERCEL_AUDIENCE_PATH)) {
24
+ return null;
25
+ }
26
+ return { issuer, audience };
27
+ }
28
+ function getJwks(issuer) {
29
+ if (!jwks) {
30
+ jwks = jose.createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks`));
31
+ }
32
+ return jwks;
33
+ }
34
+ export function isVercelMarketplaceIssuer(issuer) {
35
+ return isExpectedVercelUrl(issuer, VERCEL_ISSUER_PATH);
36
+ }
37
+ export async function validateVercelMarketplaceJwt(token, tokenIssuer) {
38
+ const config = getConfig();
39
+ if (!config)
40
+ return { valid: false, error: "Vercel Marketplace OIDC not configured" };
41
+ if (tokenIssuer !== config.issuer) {
42
+ return { valid: false, error: "Untrusted Vercel Marketplace issuer" };
43
+ }
44
+ const { payload } = await jose.jwtVerify(token, getJwks(config.issuer), {
45
+ algorithms: ["RS256"],
46
+ audience: config.audience,
47
+ issuer: config.issuer,
48
+ clockTolerance: 60,
49
+ });
50
+ if (typeof payload.resource !== "string" || !payload.resource) {
51
+ return { valid: false, error: "Missing Vercel Marketplace resource" };
52
+ }
53
+ return { valid: true };
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upstash/context7-mcp",
3
- "version": "4.0.4",
3
+ "version": "4.0.6",
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
  }