@upstash/context7-mcp 2.3.0 → 3.1.0

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
@@ -7,8 +7,11 @@ import { formatSearchResults, extractClientInfoFromUserAgent } from "./lib/utils
7
7
  import { isJWT, validateJWT } from "./lib/jwt.js";
8
8
  import express from "express";
9
9
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
10
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
10
11
  import { Command } from "commander";
11
12
  import { AsyncLocalStorage } from "async_hooks";
13
+ import { randomUUID } from "node:crypto";
14
+ import { createSessionStore } from "./lib/sessionStore.js";
12
15
  import { SERVER_VERSION, RESOURCE_URL, AUTH_SERVER_URL, OPENAI_APPS_CHALLENGE_TOKEN, } from "./lib/constants.js";
13
16
  import { appendAuthPrompt } from "./lib/auth/auth-prompt.js";
14
17
  /** Default HTTP server port */
@@ -50,6 +53,8 @@ const requestContext = new AsyncLocalStorage();
50
53
  // Global state for stdio mode only
51
54
  let stdioApiKey;
52
55
  let stdioClientInfo;
56
+ // One session ID per stdio process.
57
+ let stdioSessionId;
53
58
  /**
54
59
  * Get the effective client context
55
60
  */
@@ -64,6 +69,7 @@ function getClientContext() {
64
69
  apiKey: stdioApiKey,
65
70
  clientInfo: stdioClientInfo,
66
71
  transport: "stdio",
72
+ sessionId: stdioSessionId,
67
73
  };
68
74
  }
69
75
  /**
@@ -297,10 +303,11 @@ async function main() {
297
303
  extractHeaderValue(req.headers["context7_api_key"]) ||
298
304
  extractHeaderValue(req.headers["x_api_key"]));
299
305
  };
306
+ const sessionStore = createSessionStore();
300
307
  const handleMcpRequest = async (req, res, requireAuth) => {
301
- // Reject GET requests — this server is stateless and does not send server-initiated
302
- // notifications, so SSE streams serve no purpose and cause mass NGINX timeouts.
303
- // Returning 405 is spec-compliant per MCP StreamableHTTP (2025-03-26).
308
+ // Reject GET requests — sessions are tracked in Redis, but this server does not send
309
+ // server-initiated notifications, so SSE streams serve no purpose and cause mass NGINX
310
+ // timeouts. Returning 405 is spec-compliant per MCP StreamableHTTP (2025-03-26).
304
311
  if (req.method === "GET") {
305
312
  return res.status(405).json({
306
313
  jsonrpc: "2.0",
@@ -345,6 +352,51 @@ async function main() {
345
352
  clientInfo: extractClientInfoFromUserAgent(req.headers["user-agent"]),
346
353
  transport: "http",
347
354
  };
355
+ const sessionId = extractHeaderValue(req.headers["mcp-session-id"]);
356
+ if (req.method === "DELETE") {
357
+ if (!sessionId) {
358
+ return res.status(400).json({
359
+ jsonrpc: "2.0",
360
+ error: { code: -32000, message: "Bad Request: No valid session ID provided" },
361
+ id: null,
362
+ });
363
+ }
364
+ await sessionStore.delete(sessionId);
365
+ return res.status(200).end();
366
+ }
367
+ let effectiveSessionId;
368
+ if (!sessionId && req.method === "POST" && isInitializeRequest(req.body)) {
369
+ effectiveSessionId = randomUUID();
370
+ await sessionStore.create(effectiveSessionId);
371
+ res.setHeader("mcp-session-id", effectiveSessionId);
372
+ }
373
+ else if (sessionId && req.method === "POST" && !isInitializeRequest(req.body)) {
374
+ const sessionExists = await sessionStore.refresh(sessionId);
375
+ if (!sessionExists) {
376
+ // Per MCP Streamable HTTP spec: 404 signals to the client that the session
377
+ // has been terminated/expired, so it should re-initialize with a fresh InitializeRequest.
378
+ return res.status(404).json({
379
+ jsonrpc: "2.0",
380
+ error: {
381
+ code: -32000,
382
+ message: "Session not found or expired. Please re-initialize.",
383
+ },
384
+ id: null,
385
+ });
386
+ }
387
+ effectiveSessionId = sessionId;
388
+ }
389
+ else {
390
+ return res.status(400).json({
391
+ jsonrpc: "2.0",
392
+ error: { code: -32000, message: "Bad Request: No valid session ID provided" },
393
+ id: null,
394
+ });
395
+ }
396
+ context.sessionId = effectiveSessionId;
397
+ // sessionIdGenerator is undefined because session lifecycle (create/refresh/delete)
398
+ // is owned by the route handler above and persisted in Redis, not by the SDK transport.
399
+ //
348
400
  // Use SSE responses for tool calls (enableJsonResponse: false). The SDK then
349
401
  // flushes response headers immediately after parsing the request rather than
350
402
  // buffering until the tool returns. This is required for long-running tools
@@ -359,9 +411,9 @@ async function main() {
359
411
  transport.close();
360
412
  server.close();
361
413
  });
414
+ installTransportArgAliasing(transport);
415
+ await server.connect(transport);
362
416
  await requestContext.run(context, async () => {
363
- installTransportArgAliasing(transport);
364
- await server.connect(transport);
365
417
  await transport.handleRequest(req, res, req.body);
366
418
  });
367
419
  }
@@ -456,6 +508,7 @@ async function main() {
456
508
  }
457
509
  else {
458
510
  stdioApiKey = cliOptions.apiKey || process.env.CONTEXT7_API_KEY;
511
+ stdioSessionId = randomUUID();
459
512
  process.stdin.on("end", () => process.exit(0));
460
513
  process.stdin.on("close", () => process.exit(0));
461
514
  process.on("SIGHUP", () => process.exit(0));
@@ -36,6 +36,9 @@ export function generateHeaders(context) {
36
36
  if (context.clientIp) {
37
37
  headers["mcp-client-ip"] = encryptClientIp(context.clientIp);
38
38
  }
39
+ if (context.sessionId) {
40
+ headers["mcp-session-id"] = context.sessionId;
41
+ }
39
42
  if (context.apiKey) {
40
43
  headers["Authorization"] = `Bearer ${context.apiKey}`;
41
44
  }
package/dist/lib/jwt.js CHANGED
@@ -1,17 +1,71 @@
1
1
  import * as jose from "jose";
2
- import { CLERK_DOMAIN } from "./constants.js";
3
- const JWKS_URL = `https://${CLERK_DOMAIN}/.well-known/jwks.json`;
4
- const ISSUER = `https://${CLERK_DOMAIN}`;
5
- const jwks = jose.createRemoteJWKSet(new URL(JWKS_URL));
2
+ import { CLERK_DOMAIN, CONTEXT7_API_BASE_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`));
5
+ const ENTRA_V2_ISSUER_RE = /^https:\/\/login\.microsoftonline\.com\/[0-9a-f-]{36}\/v2\.0$/;
6
+ const jwksByTenant = new Map();
7
+ function entraJwks(tenantId) {
8
+ let jwks = jwksByTenant.get(tenantId);
9
+ if (!jwks) {
10
+ jwks = jose.createRemoteJWKSet(new URL(`https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`));
11
+ jwksByTenant.set(tenantId, jwks);
12
+ }
13
+ return jwks;
14
+ }
15
+ const CONFIG_TTL_MS = 5 * 60 * 1000;
16
+ const configByAudience = new Map();
17
+ async function fetchEntraConfig(audience) {
18
+ const now = Date.now();
19
+ const cached = configByAudience.get(audience);
20
+ if (cached && cached.expiresAt > now)
21
+ return cached.value;
22
+ try {
23
+ const res = await fetch(`${CONTEXT7_API_BASE_URL}/v2/entra/config/${encodeURIComponent(audience)}`);
24
+ if (res.ok) {
25
+ const value = (await res.json());
26
+ configByAudience.set(audience, { value, expiresAt: now + CONFIG_TTL_MS });
27
+ return value;
28
+ }
29
+ if (res.status === 404) {
30
+ // Authoritative "not configured" response — safe to cache the miss so we
31
+ // don't hammer the app on every token verification.
32
+ configByAudience.set(audience, { value: null, expiresAt: now + CONFIG_TTL_MS });
33
+ return null;
34
+ }
35
+ }
36
+ catch {
37
+ // Network or JSON parse error: transient. Fall through without caching so
38
+ // the next request retries.
39
+ }
40
+ return null;
41
+ }
6
42
  export function isJWT(token) {
7
- const parts = token.split(".");
8
- return parts.length === 3;
43
+ return token.split(".").length === 3;
9
44
  }
10
45
  export async function validateJWT(token) {
11
46
  try {
12
- await jose.jwtVerify(token, jwks, {
13
- issuer: ISSUER,
14
- });
47
+ const decoded = jose.decodeJwt(token);
48
+ const iss = typeof decoded.iss === "string" ? decoded.iss : "";
49
+ if (ENTRA_V2_ISSUER_RE.test(iss)) {
50
+ const audience = typeof decoded.aud === "string" ? decoded.aud : "";
51
+ if (!audience)
52
+ return { valid: false, error: "Missing audience" };
53
+ const config = await fetchEntraConfig(audience);
54
+ if (!config)
55
+ return { valid: false, error: "Unknown audience" };
56
+ const { payload } = await jose.jwtVerify(token, entraJwks(config.tenantId), {
57
+ issuer: `https://login.microsoftonline.com/${config.tenantId}/v2.0`,
58
+ audience,
59
+ });
60
+ if (config.requiredScope) {
61
+ const scopes = String(payload.scp ?? "").split(" ");
62
+ if (!scopes.includes(config.requiredScope)) {
63
+ return { valid: false, error: "Missing required scope" };
64
+ }
65
+ }
66
+ return { valid: true };
67
+ }
68
+ await jose.jwtVerify(token, clerkJwks, { issuer: CLERK_ISSUER });
15
69
  return { valid: true };
16
70
  }
17
71
  catch (error) {
@@ -0,0 +1,14 @@
1
+ import { Redis } from "@upstash/redis";
2
+ let cached;
3
+ /**
4
+ * Returns the shared Upstash Redis client. Throws if credentials are missing.
5
+ */
6
+ export function getRedis() {
7
+ if (cached)
8
+ return cached;
9
+ if (!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN) {
10
+ throw new Error("Upstash Redis credentials are required. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.");
11
+ }
12
+ cached = Redis.fromEnv();
13
+ return cached;
14
+ }
@@ -0,0 +1,47 @@
1
+ import { getRedis } from "./redis.js";
2
+ const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
3
+ const REFRESH_THRESHOLD_SECONDS = 24 * 60 * 60; // 1 day — only extend TTL when below this
4
+ const SESSION_KEY_PREFIX = "#mcp#session#";
5
+ // Fail-open: log Redis errors and proceed. The session ID isn't an auth/authz
6
+ // primitive — only an opaque identifier for log correlation and spec compliance —
7
+ // so an unreachable Redis shouldn't block clients. Ghost sessions self-heal on
8
+ // the next refresh (returns false → client gets 404 → re-inits).
9
+ export function createSessionStore() {
10
+ const redis = getRedis();
11
+ const getSessionKey = (sessionId) => `${SESSION_KEY_PREFIX}${sessionId}`;
12
+ return {
13
+ async create(sessionId) {
14
+ try {
15
+ await redis.set(getSessionKey(sessionId), "1", { ex: SESSION_TTL_SECONDS });
16
+ }
17
+ catch (err) {
18
+ console.error(`Error creating Redis session record ${sessionId}:`, err);
19
+ }
20
+ },
21
+ async refresh(sessionId) {
22
+ try {
23
+ // One TTL call tells us both whether the key exists AND how much time it has left.
24
+ // Only issue an EXPIRE write when the key is approaching expiry
25
+ const ttl = await redis.ttl(getSessionKey(sessionId));
26
+ if (ttl < 0)
27
+ return false;
28
+ if (ttl < REFRESH_THRESHOLD_SECONDS) {
29
+ await redis.expire(getSessionKey(sessionId), SESSION_TTL_SECONDS);
30
+ }
31
+ return true;
32
+ }
33
+ catch (err) {
34
+ console.error(`Error refreshing Redis session record ${sessionId}:`, err);
35
+ return true;
36
+ }
37
+ },
38
+ async delete(sessionId) {
39
+ try {
40
+ await redis.del(getSessionKey(sessionId));
41
+ }
42
+ catch (err) {
43
+ console.error(`Error deleting Redis session record ${sessionId}:`, err);
44
+ }
45
+ },
46
+ };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upstash/context7-mcp",
3
- "version": "2.3.0",
3
+ "version": "3.1.0",
4
4
  "mcpName": "io.github.upstash/context7",
5
5
  "description": "MCP server for Context7",
6
6
  "repository": {
@@ -35,6 +35,7 @@
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/sdk": "^1.25.1",
37
37
  "@types/express": "^5.0.4",
38
+ "@upstash/redis": "^1.38.0",
38
39
  "commander": "^14.0.0",
39
40
  "express": "^5.1.0",
40
41
  "jose": "^6.1.3",