@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 +21 -8
- package/dist/lib/encryption.js +37 -16
- package/dist/lib/subscriptions.js +11 -0
- package/package.json +4 -2
- package/dist/lib/client-ip.js +0 -60
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 {
|
|
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
|
|
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 (
|
|
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:
|
|
340
|
-
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
|
|
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
|
|
378
|
+
await handleMcpRequest(req, res);
|
|
366
379
|
});
|
|
367
380
|
app.get("/ping", (_req, res) => {
|
|
368
381
|
res.json({ status: "ok", message: "pong" });
|
package/dist/lib/encryption.js
CHANGED
|
@@ -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
|
|
4
|
-
const
|
|
5
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
23
|
-
|
|
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
|
-
|
|
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
|
+
"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
|
-
"
|
|
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
|
}
|
package/dist/lib/client-ip.js
DELETED
|
@@ -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
|
-
}
|