@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 +28 -0
- package/dist/index.js +28 -11
- package/dist/lib/encryption.js +7 -26
- package/dist/lib/jwt.js +4 -0
- package/dist/lib/mcp-body-error-handler.js +23 -0
- package/dist/lib/subscriptions.js +11 -0
- package/dist/lib/vercelMarketplaceJwt.js +54 -0
- package/package.json +4 -2
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
});
|
package/dist/lib/encryption.js
CHANGED
|
@@ -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
|
|
13
|
-
const key = process.env
|
|
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 =
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|