feat: MCP v2 (#2843)

Co-authored-by: enesgules <abdullah.enes.gules@gmail.com>
Co-authored-by: Fahreddin Özcan <ozcanfahrettinn@gmail.com>
This commit is contained in:
Konstantin Konstantinov
2026-08-06 15:57:58 +03:00
committed by GitHub
parent 903a057dcc
commit 8d52608e4e
13 changed files with 512 additions and 332 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@upstash/context7-mcp": major
---
Migrate the MCP server to the v2 SDK (`@modelcontextprotocol/{node,server,client}` 2.0.0) and the 2026-07-28 protocol revision. HTTP serving is now stateless for both modern and legacy clients, and Redis-backed sessions are removed.
-2
View File
@@ -3,8 +3,6 @@ CONTEXT7_API_KEY=
CONTEXT7_API_URL=https://context7.com/api
# MCP HTTP server
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
RESOURCE_URL=
AUTH_SERVER_URL=
OPENAI_APPS_CHALLENGE_TOKEN=
+2 -2
View File
@@ -7,7 +7,7 @@ This guide helps you resolve common issues when setting up or using Context7 MCP
## Quick Checklist
- Confirm Node.js v18+ is installed (`node --version`)
- Confirm Node.js v20+ is installed (`node --version`)
- Update to the latest Context7 MCP package (`@upstash/context7-mcp@latest`)
- Verify connectivity with `curl https://mcp.context7.com/ping`
- Add your API key to the configuration if you hit rate limits
@@ -92,7 +92,7 @@ Use the `--experimental-fetch` flag:
### Node.js Version
Ensure you're using Node.js v18 or higher (`node --version`).
Ensure you're using Node.js v20 or higher (`node --version`).
## Platform-Specific Issues
+1 -1
View File
@@ -37,7 +37,7 @@
"compatibility": {
"platforms": ["darwin", "win32", "linux"],
"runtimes": {
"node": ">=v18.0.0"
"node": ">=v20.18.1"
}
},
"keywords": ["vibe-coding", "developer tools", "documentation", "context"],
+3 -2
View File
@@ -45,9 +45,9 @@
},
"homepage": "https://github.com/upstash/context7#readme",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@modelcontextprotocol/node": "2.0.0",
"@modelcontextprotocol/server": "2.0.0",
"@types/express": "^5.0.4",
"@upstash/redis": "^1.38.0",
"commander": "^13.1.0",
"express": "^5.1.0",
"jose": "^6.2.3",
@@ -55,6 +55,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@modelcontextprotocol/client": "2.0.0",
"@types/node": "^25.0.3",
"typescript": "^5.8.2",
"vitest": "^4.1.9"
+141 -209
View File
@@ -1,25 +1,21 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { McpServer, createMcpHandler, type ServerContext } from "@modelcontextprotocol/server";
import { z } from "zod";
import { searchLibraries, fetchLibraryContext } from "./lib/api.js";
import type { ClientContext } from "./lib/types.js";
import { formatSearchResults, extractClientInfoFromUserAgent } from "./lib/utils.js";
import {
formatSearchResults,
extractClientInfoFromUserAgent,
envelopeClientInfo,
} from "./lib/utils.js";
import { isJWT, validateJWT } from "./lib/jwt.js";
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { Command } from "commander";
import { AsyncLocalStorage } from "async_hooks";
import { randomUUID } from "node:crypto";
import { createSessionStore } from "./lib/sessionStore.js";
import {
SERVER_VERSION,
RESOURCE_URL,
@@ -92,23 +88,62 @@ let stdioSessionId: string | undefined;
/**
* Get the effective client context
*/
function getClientContext(): ClientContext {
function getClientContext(toolCtx: ServerContext): ClientContext {
const ctx = requestContext.getStore();
const requestClientInfo = envelopeClientInfo(toolCtx.mcpReq.envelope);
// HTTP mode: context is fully populated from request
// Use protocol client info when available; fall back to the HTTP User-Agent.
if (ctx) {
return ctx;
return { ...ctx, clientInfo: requestClientInfo ?? ctx.clientInfo };
}
// stdio mode: use globals
// stdio mode: envelope (modern clients) or globals (legacy initialize)
return {
apiKey: stdioApiKey,
clientInfo: stdioClientInfo,
clientInfo: requestClientInfo ?? stdioClientInfo,
transport: "stdio",
sessionId: stdioSessionId,
};
}
// Map of canonical arg name -> hallucinated aliases that should be rewritten
// to it. LLM clients often echo phrasing from tool descriptions instead of
// the literal schema keys, which trips Zod validation before the tool runs.
type AliasMap = Record<string, readonly string[]>;
const GLOBAL_ALIASES: AliasMap = {
query: ["userQuery", "question"],
};
// Tool-scoped aliases, for keys that are canonical on one tool but a
// hallucination on another (e.g. `libraryName` is canonical for
// `resolve-library-id`, so we only rewrite it on `query-docs` calls).
const QUERY_DOCS_ALIASES: AliasMap = {
libraryId: ["context7CompatibleLibraryID", "libraryID", "libraryName"],
};
// z.preprocess step that rewrites aliased arg names before validation. Living
// in the schema keeps aliasing transport-agnostic: the SDK parses the wire
// message (any transport, any protocol era) and runs this on validation.
// Returns a remapped copy — the raw wire params object stays untouched.
function aliasArgs(aliases: AliasMap) {
return (value: unknown) => {
if (!value || typeof value !== "object") return value;
const args: Record<string, unknown> = { ...value };
for (const [canonical, alternatives] of Object.entries(aliases)) {
if (canonical in args) continue;
for (const alt of alternatives) {
if (alt in args) {
args[canonical] = args[alt];
delete args[alt];
break;
}
}
}
return args;
};
}
function createMcpServer() {
const server = new McpServer(
{
@@ -125,6 +160,11 @@ function createMcpServer() {
],
},
{
// Declaring the capabilities makes the SDK install prompts/list,
// resources/list, and resources/templates/list handlers that answer
// with the registered (i.e. empty) collections, for clients that
// request them unconditionally.
capabilities: { prompts: {}, resources: {} },
instructions: `Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service — even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer — your training data may not reflect recent changes. Prefer this over web search for library docs.
Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts.`,
@@ -168,18 +208,21 @@ Response Format:
For ambiguous queries, request clarification before proceeding with a best-guess match.
IMPORTANT: Do not call this tool more than 3 times per question. If you cannot find what you need after 3 calls, use the best result you have.`,
inputSchema: {
query: z
.string()
.describe(
"What to look up in the library's documentation. This is used to rank library results by relevance to what the user is trying to accomplish. The query is sent to the Context7 API for processing. Do not include any sensitive or confidential information such as API keys, passwords, credentials, personal data, or proprietary code in your query."
),
libraryName: z
.string()
.describe(
"Library name to search for and retrieve a Context7-compatible library ID. Use the official library name with proper punctuation — e.g., 'Next.js' instead of 'nextjs', 'Customer.io' instead of 'customerio', 'Three.js' instead of 'threejs'."
),
},
inputSchema: z.preprocess(
aliasArgs(GLOBAL_ALIASES),
z.object({
query: z
.string()
.describe(
"What to look up in the library's documentation. This is used to rank library results by relevance to what the user is trying to accomplish. The query is sent to the Context7 API for processing. Do not include any sensitive or confidential information such as API keys, passwords, credentials, personal data, or proprietary code in your query."
),
libraryName: z
.string()
.describe(
"Library name to search for and retrieve a Context7-compatible library ID. Use the official library name with proper punctuation — e.g., 'Next.js' instead of 'nextjs', 'Customer.io' instead of 'customerio', 'Three.js' instead of 'threejs'."
),
})
),
annotations: {
readOnlyHint: true,
destructiveHint: false,
@@ -187,8 +230,8 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f
idempotentHint: true,
},
},
async ({ query, libraryName }: { query: string; libraryName: string }) => {
const ctx = getClientContext();
async ({ query, libraryName }: { query: string; libraryName: string }, toolCtx) => {
const ctx = getClientContext(toolCtx);
const searchResponse = await searchLibraries(query, libraryName, ctx);
if (!searchResponse.results || searchResponse.results.length === 0) {
@@ -227,18 +270,21 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f
You must call 'Resolve Context7 Library ID' tool first to obtain the exact Context7-compatible library ID required to use this tool, UNLESS the user explicitly provides a library ID in the format '/org/project' or '/org/project/version' in their query.
Do not call this tool more than 3 times per question.`,
inputSchema: {
libraryId: z
.string()
.describe(
"Exact Context7-compatible library ID (e.g., '/mongodb/docs', '/vercel/next.js', '/supabase/supabase', '/vercel/next.js/v14.3.0-canary.87') retrieved from 'resolve-library-id' or directly from user query in the format '/org/project' or '/org/project/version'."
),
query: z
.string()
.describe(
"What to look up in the library's documentation, scoped to a single concept. Be specific and include relevant details, but keep each query to one topic — if the user's question spans multiple distinct concepts, make a separate call per concept instead of combining them, unless the question is about how the concepts interact. Good: 'How to set up authentication with JWT in Express.js' or 'React useEffect cleanup function examples'. Bad (too vague): 'auth' or 'hooks'. Bad (too broad): 'routing and auth and caching in Next.js'. The query is sent to the Context7 API for processing. Do not include any sensitive or confidential information such as API keys, passwords, credentials, personal data, or proprietary code in your query."
),
},
inputSchema: z.preprocess(
aliasArgs({ ...GLOBAL_ALIASES, ...QUERY_DOCS_ALIASES }),
z.object({
libraryId: z
.string()
.describe(
"Exact Context7-compatible library ID (e.g., '/mongodb/docs', '/vercel/next.js', '/supabase/supabase', '/vercel/next.js/v14.3.0-canary.87') retrieved from 'resolve-library-id' or directly from user query in the format '/org/project' or '/org/project/version'."
),
query: z
.string()
.describe(
"What to look up in the library's documentation, scoped to a single concept. Be specific and include relevant details, but keep each query to one topic — if the user's question spans multiple distinct concepts, make a separate call per concept instead of combining them, unless the question is about how the concepts interact. Good: 'How to set up authentication with JWT in Express.js' or 'React useEffect cleanup function examples'. Bad (too vague): 'auth' or 'hooks'. Bad (too broad): 'routing and auth and caching in Next.js'. The query is sent to the Context7 API for processing. Do not include any sensitive or confidential information such as API keys, passwords, credentials, personal data, or proprietary code in your query."
),
})
),
annotations: {
readOnlyHint: true,
destructiveHint: false,
@@ -246,8 +292,8 @@ Do not call this tool more than 3 times per question.`,
idempotentHint: true,
},
},
async ({ query, libraryId }: { query: string; libraryId: string }) => {
const ctx = getClientContext();
async ({ query, libraryId }: { query: string; libraryId: string }, toolCtx) => {
const ctx = getClientContext(toolCtx);
const response = await fetchLibraryContext({ query, libraryId }, ctx);
maybeElicitAuthSignIn(server, ctx);
return {
@@ -261,76 +307,11 @@ Do not call this tool more than 3 times per question.`,
}
);
server.server.registerCapabilities({ prompts: {}, resources: {} });
server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [],
}));
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
resourceTemplates: [],
}));
return server;
}
// Map of canonical arg name -> hallucinated aliases that should be rewritten
// to it. LLM clients often echo phrasing from tool descriptions instead of
// the literal schema keys, which trips Zod validation before the tool runs.
type AliasMap = Record<string, readonly string[]>;
const GLOBAL_ALIASES: AliasMap = {
query: ["userQuery", "question"],
};
// Tool-scoped aliases, for keys that are canonical on one tool but a
// hallucination on another (e.g. `libraryName` is canonical for
// `resolve-library-id`, so we only rewrite it on `query-docs` calls).
const TOOL_ALIASES: Record<string, AliasMap> = {
"query-docs": {
libraryId: ["context7CompatibleLibraryID", "libraryID", "libraryName"],
},
};
function applyAliases(args: Record<string, unknown>, aliases: AliasMap): void {
for (const [canonical, alternatives] of Object.entries(aliases)) {
if (canonical in args) continue;
for (const alt of alternatives) {
if (alt in args) {
args[canonical] = args[alt];
delete args[alt];
break;
}
}
}
}
// Install BEFORE `server.connect(transport)`: the SDK's `Protocol.connect()`
// captures the existing `onmessage` and chains its dispatch handler over it,
// so our hook runs first on every incoming JSON-RPC message.
function installTransportArgAliasing(transport: Transport): void {
transport.onmessage = (message) => {
const msg = message as {
method?: string;
params?: { name?: string; arguments?: unknown };
};
if (msg.method !== "tools/call") return;
const args = msg.params?.arguments;
if (!args || typeof args !== "object") return;
const argsRecord = args as Record<string, unknown>;
applyAliases(argsRecord, GLOBAL_ALIASES);
const toolName = msg.params?.name;
if (toolName && toolName in TOOL_ALIASES) {
applyAliases(argsRecord, TOOL_ALIASES[toolName]);
}
};
}
async function main() {
const transportType = TRANSPORT_TYPE;
if (transportType === "http") {
if (TRANSPORT_TYPE === "http") {
const initialPort = CLI_PORT ?? DEFAULT_PORT;
const app = express();
@@ -339,12 +320,14 @@ async function main() {
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,DELETE");
// Mcp-Method / Mcp-Name are the SEP-2243 standard headers 2026-07-28
// clients send on every request; without them here, browser-based modern
// clients fail the CORS preflight. (Mcp-Param-* mirroring is skipped by
// browser clients, so those are not needed.)
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, MCP-Session-Id, MCP-Protocol-Version, X-Context7-API-Key, Context7-API-Key, X-API-Key, Authorization"
"Content-Type, MCP-Session-Id, MCP-Protocol-Version, Mcp-Method, Mcp-Name, X-Context7-API-Key, Context7-API-Key, X-API-Key, Authorization"
);
res.setHeader("Access-Control-Expose-Headers", "MCP-Session-Id");
if (req.method === "OPTIONS") {
res.sendStatus(200);
return;
@@ -378,30 +361,40 @@ async function main() {
);
};
const sessionStore = createSessionStore();
// Stateless serving: a fresh server instance per request, no Mcp-Session-Id,
// no session store. The handler serves modern (2026-07-28) traffic natively
// and 2025-era traffic through its stateless legacy fallback, which answers
// GET/DELETE (session operations) with 405.
//
// responseMode "sse" keeps responses streaming: headers flush immediately
// after parsing the request rather than buffering until the tool returns.
// This is required for long-running tools because some MCP HTTP clients cap
// the underlying fetch at 60s waiting for headers, even though the per-tool
// timeout is much higher.
const mcpHandler = createMcpHandler(() => createMcpServer(), {
responseMode: "sse",
onerror: (error) => console.error("MCP handler error:", error),
});
// Without onerror, request-conversion / handler.fetch throws are answered
// with a bare 500 inside the adapter and never reach our express handler.
const nodeHandler = toNodeHandler(mcpHandler, {
onerror: (error) => console.error("MCP node adapter error:", error),
});
const handleMcpRequest = async (
req: express.Request,
res: express.Response,
requireAuth: boolean
) => {
// Reject GET requests — sessions are tracked in Redis, but this server does not send
// server-initiated notifications, so SSE streams serve no purpose and cause mass NGINX
// timeouts. Returning 405 is spec-compliant per MCP StreamableHTTP (2025-03-26).
if (req.method === "GET") {
return res.status(405).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Server does not support GET requests" },
id: null,
});
}
try {
const apiKey = extractApiKey(req);
const resourceUrl = RESOURCE_URL;
const baseUrl = new URL(resourceUrl).origin;
const baseUrl = new URL(RESOURCE_URL).origin;
// OAuth discovery info header, used by MCP clients to discover the authorization server
// TODO: @modelcontextprotocol/server now ships canonical OAuth helpers
// (bearerAuthChallengeResponse, buildOAuthProtectedResourceMetadata,
// oauthMetadataResponse) — replace this hand-rolled header and the
// /.well-known/oauth-protected-resource route with them.
res.set(
"WWW-Authenticate",
`Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`
@@ -441,74 +434,8 @@ async function main() {
transport: "http",
};
const sessionId = extractHeaderValue(req.headers["mcp-session-id"]);
if (req.method === "DELETE") {
if (!sessionId) {
return res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Bad Request: No valid session ID provided" },
id: null,
});
}
await sessionStore.delete(sessionId);
return res.status(200).end();
}
let effectiveSessionId: string;
if (!sessionId && req.method === "POST" && isInitializeRequest(req.body)) {
effectiveSessionId = randomUUID();
await sessionStore.create(effectiveSessionId);
res.setHeader("mcp-session-id", effectiveSessionId);
} else if (sessionId && req.method === "POST" && !isInitializeRequest(req.body)) {
const sessionExists = await sessionStore.refresh(sessionId);
if (!sessionExists) {
// Per MCP Streamable HTTP spec: 404 signals to the client that the session
// has been terminated/expired, so it should re-initialize with a fresh InitializeRequest.
return res.status(404).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Session not found or expired. Please re-initialize.",
},
id: null,
});
}
effectiveSessionId = sessionId;
} else {
return res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Bad Request: No valid session ID provided" },
id: null,
});
}
context.sessionId = effectiveSessionId;
// sessionIdGenerator is undefined because session lifecycle (create/refresh/delete)
// is owned by the route handler above and persisted in Redis, not by the SDK transport.
//
// Use SSE responses for tool calls (enableJsonResponse: false). The SDK then
// flushes response headers immediately after parsing the request rather than
// buffering until the tool returns. This is required for long-running tools
// because some MCP HTTP clients cap the underlying fetch at 60s waiting for
// headers, even though the per-tool timeout is much higher.
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: false,
});
const server = createMcpServer();
res.on("close", () => {
transport.close();
server.close();
});
installTransportArgAliasing(transport);
await server.connect(transport);
await requestContext.run(context, async () => {
await transport.handleRequest(req, res, req.body);
await nodeHandler(req, res, req.body);
});
} catch (error) {
console.error("Error handling MCP request:", error);
@@ -627,23 +554,28 @@ async function main() {
process.stdin.on("close", () => process.exit(0));
process.on("SIGHUP", () => process.exit(0));
const transport = new StdioServerTransport();
const server = createMcpServer();
serveStdio(
() => {
const server = createMcpServer();
// Capture client info from MCP initialize handshake (stdio only — HTTP
// mode plumbs client info through requestContext per request).
server.server.oninitialized = () => {
const clientVersion = server.server.getClientVersion();
if (clientVersion) {
stdioClientInfo = {
ide: clientVersion.name,
version: clientVersion.version,
// Capture client info from MCP initialize handshake (stdio only — HTTP
// mode plumbs client info through requestContext per request).
server.server.oninitialized = () => {
const clientVersion = server.server.getClientVersion();
if (clientVersion) {
stdioClientInfo = {
ide: clientVersion.name,
version: clientVersion.version,
};
}
};
}
};
installTransportArgAliasing(transport);
await server.connect(transport);
return server;
},
{
onerror: (error) => console.error("MCP stdio error:", error),
}
);
console.error(`Context7 Documentation MCP Server v${SERVER_VERSION} running on stdio`);
}
+11 -1
View File
@@ -1,4 +1,4 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { McpServer } from "@modelcontextprotocol/server";
import type { ClientContext } from "../types.js";
function clientFlagForCli(ide: string | undefined): string {
@@ -62,6 +62,16 @@ const CHOICE_STAY_ANON = "Continue anonymously with smaller limits";
* No-op for authenticated callers, when the signal wasn't set, or when the
* client did not advertise the `elicitation` capability. Fire-and-forget:
* never blocks or fails the surrounding tool response.
*
* 2025-era stdio only, by design. The 2026-07-28 protocol revision removed
* the push-style server-to-client request channel, and its replacement —
* returning `inputRequired(...)` from the tool handler — would replace the
* tool result and force a client retry, i.e. gate doc delivery behind the
* nudge. That trade is wrong for a soft hint, so modern-era connections get
* no nudge: `getClientCapabilities()` is undefined there (no initialize
* handshake), so the capability guard below short-circuits. On HTTP the guard
* short-circuits for the same reason — each stateless request runs on a fresh
* server that never saw an initialize.
*/
export function maybeElicitAuthSignIn(server: McpServer, ctx: ClientContext): void {
if (ctx.apiKey || !ctx.shouldPrompt) return;
-17
View File
@@ -1,17 +0,0 @@
import { Redis } from "@upstash/redis";
let cached: Redis | undefined;
/**
* Returns the shared Upstash Redis client. Throws if credentials are missing.
*/
export function getRedis(): Redis {
if (cached) return cached;
if (!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN) {
throw new Error(
"Upstash Redis credentials are required. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN."
);
}
cached = Redis.fromEnv();
return cached;
}
-49
View File
@@ -1,49 +0,0 @@
import { getRedis } from "./redis.js";
const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
const REFRESH_THRESHOLD_SECONDS = 24 * 60 * 60; // 1 day — only extend TTL when below this
const SESSION_KEY_PREFIX = "#mcp#session#";
// Fail-open: log Redis errors and proceed. The session ID isn't an auth/authz
// primitive — only an opaque identifier for log correlation and spec compliance —
// so an unreachable Redis shouldn't block clients. Ghost sessions self-heal on
// the next refresh (returns false → client gets 404 → re-inits).
export function createSessionStore() {
const redis = getRedis();
const getSessionKey = (sessionId: string) => `${SESSION_KEY_PREFIX}${sessionId}`;
return {
async create(sessionId: string) {
try {
await redis.set(getSessionKey(sessionId), "1", { ex: SESSION_TTL_SECONDS });
} catch (err) {
console.error(`Error creating Redis session record ${sessionId}:`, err);
}
},
async refresh(sessionId: string) {
try {
// One TTL call tells us both whether the key exists AND how much time it has left.
// Only issue an EXPIRE write when the key is approaching expiry
const ttl = await redis.ttl(getSessionKey(sessionId));
if (ttl < 0) return false;
if (ttl < REFRESH_THRESHOLD_SECONDS) {
await redis.expire(getSessionKey(sessionId), SESSION_TTL_SECONDS);
}
return true;
} catch (err) {
console.error(`Error refreshing Redis session record ${sessionId}:`, err);
return true;
}
},
async delete(sessionId: string) {
try {
await redis.del(getSessionKey(sessionId));
} catch (err) {
console.error(`Error deleting Redis session record ${sessionId}:`, err);
}
},
};
}
+19
View File
@@ -1,3 +1,4 @@
import { CLIENT_INFO_META_KEY } from "@modelcontextprotocol/server";
import { SearchResponse, SearchResult } from "./types.js";
/**
@@ -82,6 +83,24 @@ export function formatSearchResults(searchResponse: SearchResponse): string {
return parts.join("\n\n");
}
/**
* Reads the client name/version that modern (2026-07-28) clients attach to
* every request's `_meta` envelope. Legacy (2025) clients declare it once in
* the initialize handshake instead.
*
* The envelope is untyped (`RequestMetaEnvelope = {}`) as of SDK 2.0.0, so
* the shape probed here is not compile-checked against the SDK
* utils.test.ts locks it so an SDK bump that changes it fails loudly.
*/
export function envelopeClientInfo(
envelope: unknown
): { ide?: string; version?: string } | undefined {
const info = (envelope as Record<string, { name?: string; version?: string }> | undefined)?.[
CLIENT_INFO_META_KEY
];
return info ? { ide: info.name, version: info.version } : undefined;
}
/**
* Extract client info from User-Agent header.
* Parses formats like "Cursor/2.2.44 (darwin arm64)" or "claude-code/2.0.71"
+232
View File
@@ -0,0 +1,232 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { Client } from "@modelcontextprotocol/client";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/client/stdio";
import { execSync } from "node:child_process";
import { spawn, type ChildProcess } from "node:child_process";
import http from "node:http";
import { fileURLToPath } from "node:url";
import path from "node:path";
// End-to-end tests: the real built binary (dist/index.js) is exercised over
// both transports (spawned HTTP server, spawned stdio child) by both protocol
// eras (modern 2026-07-28 pinned, legacy 2025 handshake). The Context7 API is
// stubbed with a local HTTP server via CONTEXT7_API_URL, which also records
// requests so arg aliasing and client-info propagation can be asserted at the
// wire.
const PKG_ROOT = path.resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const DIST = path.join(PKG_ROOT, "dist", "index.js");
const BASE_PORT = 43117;
const STUB_DOCS = "stub docs text";
interface RecordedRequest {
path: string;
query: URLSearchParams;
headers: http.IncomingHttpHeaders;
}
const requests: RecordedRequest[] = [];
let stubServer: http.Server;
let childEnv: Record<string, string>;
let httpChild: ChildProcess;
let httpUrl: string;
function startStubApi(): Promise<string> {
stubServer = http.createServer((req, res) => {
const url = new URL(req.url!, "http://stub.local");
const apiPath = url.pathname.replace(/^\/api/, "");
requests.push({ path: apiPath, query: url.searchParams, headers: req.headers });
if (apiPath === "/v2/libs/search") {
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
results: [
{
id: "/vercel/next.js",
title: "Next.js",
description: "The React Framework",
branch: "main",
lastUpdateDate: "2026-01-01",
state: "finalized",
totalTokens: 100,
totalSnippets: 10,
},
],
})
);
} else if (apiPath === "/v2/context") {
res.setHeader("Content-Type", "text/plain");
res.end(STUB_DOCS);
} else {
res.statusCode = 404;
res.end();
}
});
return new Promise((resolve) => {
stubServer.listen(0, "127.0.0.1", () => {
const address = stubServer.address() as { port: number };
resolve(`http://127.0.0.1:${address.port}/api`);
});
});
}
function startHttpChild(): Promise<{ child: ChildProcess; url: string }> {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[DIST, "--transport", "http", "--port", String(BASE_PORT)],
{ env: childEnv, stdio: ["ignore", "ignore", "pipe"] }
);
let stderr = "";
child.stderr!.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
// The binary retries on EADDRINUSE, so parse the actual port it settled on.
const match = stderr.match(/running on HTTP at (http:\/\/localhost:\d+\/mcp)/);
if (match) resolve({ child, url: match[1] });
});
child.once("exit", (code) => {
reject(new Error(`HTTP server exited before listening (code ${code}): ${stderr}`));
});
});
}
beforeAll(async () => {
execSync("pnpm build", { cwd: PKG_ROOT, stdio: "pipe" });
const stubUrl = await startStubApi();
// getDefaultEnvironment() inherits only safe vars, so a real
// CONTEXT7_API_KEY in the parent shell cannot leak into the children.
childEnv = { ...getDefaultEnvironment(), CONTEXT7_API_URL: stubUrl };
({ child: httpChild, url: httpUrl } = await startHttpChild());
}, 120_000);
afterAll(() => {
httpChild?.kill();
stubServer?.close();
});
async function connect(transportKind: "http" | "stdio", era: "modern" | "legacy") {
const client = new Client(
{ name: "test-harness", version: "1.0.0" },
era === "modern" ? { versionNegotiation: { mode: { pin: "2026-07-28" } } } : undefined
);
const transport =
transportKind === "http"
? new StreamableHTTPClientTransport(new URL(httpUrl), {
// Parseable UA so the legacy-HTTP fallback path (no protocol client
// info) is observable; modern clients must beat it via the envelope.
requestInit: { headers: { "user-agent": "ua-fallback/9.9.9" } },
})
: new StdioClientTransport({ command: process.execPath, args: [DIST], env: childEnv });
await client.connect(transport);
return client;
}
describe.each([
["http", "modern"],
["http", "legacy"],
["stdio", "modern"],
["stdio", "legacy"],
] as const)("%s transport, %s client", (transportKind, era) => {
let client: Client;
beforeAll(async () => {
client = await connect(transportKind, era);
}, 15_000);
afterAll(async () => {
await client.close();
});
beforeEach(() => {
requests.length = 0;
});
test("negotiates the expected protocol era", () => {
expect(client.getProtocolEra()).toBe(era);
});
test("lists both tools with derived input schemas", async () => {
const { tools } = await client.listTools();
expect(tools.map((t) => t.name).sort()).toEqual(["query-docs", "resolve-library-id"]);
// The z.preprocess wrapper must not break JSON Schema derivation.
const resolve = tools.find((t) => t.name === "resolve-library-id")!;
expect(Object.keys(resolve.inputSchema.properties ?? {}).sort()).toEqual([
"libraryName",
"query",
]);
const queryDocs = tools.find((t) => t.name === "query-docs")!;
expect(Object.keys(queryDocs.inputSchema.properties ?? {}).sort()).toEqual([
"libraryId",
"query",
]);
});
// The declared `capabilities: { prompts: {}, resources: {} }` replaced three
// hand-written empty-list handlers; clients that call these unconditionally
// must still get an empty collection rather than "method not found".
test("answers prompts/resources list requests with empty collections", async () => {
expect((await client.listPrompts()).prompts).toEqual([]);
expect((await client.listResources()).resources).toEqual([]);
expect((await client.listResourceTemplates()).resourceTemplates).toEqual([]);
});
test("calls query-docs end to end", async () => {
const result = await client.callTool({
name: "query-docs",
arguments: { libraryId: "/vercel/next.js", query: "app router" },
});
expect(result.isError).toBeFalsy();
expect(result.content).toMatchObject([{ type: "text", text: STUB_DOCS }]);
const apiCalls = requests.filter((r) => r.path === "/v2/context");
expect(apiCalls).toHaveLength(1);
expect(apiCalls[0].query.get("libraryId")).toBe("/vercel/next.js");
expect(apiCalls[0].query.get("query")).toBe("app router");
expect(apiCalls[0].headers["x-context7-transport"]).toBe(transportKind);
});
test("calls resolve-library-id end to end", async () => {
const result = await client.callTool({
name: "resolve-library-id",
arguments: { query: "next.js docs", libraryName: "Next.js" },
});
expect(result.isError).toBeFalsy();
const text = (result.content as { type: string; text: string }[])[0].text;
expect(text).toContain("Available Libraries");
expect(text).toContain("/vercel/next.js");
});
test("rewrites hallucinated argument aliases before validation", async () => {
const result = await client.callTool({
name: "query-docs",
// Both keys are aliases: libraryName -> libraryId, userQuery -> query.
arguments: { libraryName: "/vercel/next.js", userQuery: "app router" },
});
expect(result.isError).toBeFalsy();
const apiCalls = requests.filter((r) => r.path === "/v2/context");
expect(apiCalls).toHaveLength(1);
expect(apiCalls[0].query.get("libraryId")).toBe("/vercel/next.js");
expect(apiCalls[0].query.get("query")).toBe("app router");
});
test("propagates client info to the Context7 API", async () => {
await client.callTool({
name: "query-docs",
arguments: { libraryId: "/vercel/next.js", query: "app router" },
});
const apiCall = requests.find((r) => r.path === "/v2/context")!;
// Legacy HTTP is the only combo with no protocol-level client info: it
// falls back to parsing the User-Agent header. Everywhere else the MCP
// client identity wins (initialize handshake on legacy stdio, per-request
// _meta envelope on modern — which must override the UA fallback on HTTP).
const expected =
transportKind === "http" && era === "legacy"
? { ide: "ua-fallback", version: "9.9.9" }
: { ide: "test-harness", version: "1.0.0" };
expect(apiCall.headers["x-context7-client-ide"]).toBe(expected.ide);
expect(apiCall.headers["x-context7-client-version"]).toBe(expected.version);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vitest";
import { CLIENT_INFO_META_KEY } from "@modelcontextprotocol/server";
import { envelopeClientInfo } from "../src/lib/utils.js";
// The request _meta envelope is untyped as of SDK 2.0.0, so
// envelopeClientInfo's probing of it is not compile-checked. These tests pin
// the expected shape so an SDK bump that changes it fails loudly.
describe("envelopeClientInfo", () => {
test("extracts ide/version from a client-info envelope entry", () => {
const envelope = {
[CLIENT_INFO_META_KEY]: { name: "cursor", version: "2.2.44" },
};
expect(envelopeClientInfo(envelope)).toEqual({ ide: "cursor", version: "2.2.44" });
});
test("returns undefined when the envelope is missing or lacks client info", () => {
expect(envelopeClientInfo(undefined)).toBeUndefined();
expect(envelopeClientInfo({})).toBeUndefined();
});
});
+78 -49
View File
@@ -109,15 +109,15 @@ importers:
packages/mcp:
dependencies:
'@modelcontextprotocol/sdk':
specifier: ^1.29.0
version: 1.29.0(zod@4.4.3)
'@modelcontextprotocol/node':
specifier: 2.0.0
version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.12.23)
'@modelcontextprotocol/server':
specifier: 2.0.0
version: 2.0.0
'@types/express':
specifier: ^5.0.4
version: 5.0.5
'@upstash/redis':
specifier: ^1.38.0
version: 1.38.0
commander:
specifier: ^13.1.0
version: 13.1.0
@@ -134,6 +134,9 @@ importers:
specifier: ^4.4.3
version: 4.4.3
devDependencies:
'@modelcontextprotocol/client':
specifier: 2.0.0
version: 2.0.0
'@types/node':
specifier: ^25.0.3
version: 25.0.3
@@ -999,35 +1002,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@mariozechner/clipboard-linux-arm64-musl@0.3.9':
resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@mariozechner/clipboard-linux-riscv64-gnu@0.3.9':
resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@mariozechner/clipboard-linux-x64-gnu@0.3.9':
resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@mariozechner/clipboard-linux-x64-musl@0.3.9':
resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@mariozechner/clipboard-win32-arm64-msvc@0.3.9':
resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==}
@@ -1048,6 +1046,24 @@ packages:
'@mistralai/mistralai@2.2.1':
resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==}
'@modelcontextprotocol/client@2.0.0':
resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==}
engines: {node: '>=20'}
'@modelcontextprotocol/core@2.0.0':
resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==}
engines: {node: '>=20'}
'@modelcontextprotocol/node@2.0.0':
resolution: {integrity: sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==}
engines: {node: '>=20'}
peerDependencies:
'@modelcontextprotocol/server': ^2.0.0
hono: ^4.11.4
peerDependenciesMeta:
hono:
optional: true
'@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'}
@@ -1058,6 +1074,10 @@ packages:
'@cfworker/json-schema':
optional: true
'@modelcontextprotocol/server@2.0.0':
resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==}
engines: {node: '>=20'}
'@nodable/entities@2.1.0':
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
@@ -1145,67 +1165,56 @@ packages:
resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.53.3':
resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.53.3':
resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.53.3':
resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.53.3':
resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.53.3':
resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.53.3':
resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.53.3':
resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.53.3':
resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.53.3':
resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.53.3':
resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.53.3':
resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==}
@@ -1424,9 +1433,6 @@ packages:
resolution: {integrity: sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@upstash/redis@1.38.0':
resolution: {integrity: sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==}
'@vercel/oidc@3.1.0':
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
engines: {node: '>= 20'}
@@ -2147,10 +2153,6 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.0:
resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
@@ -2949,9 +2951,6 @@ packages:
ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -4081,6 +4080,27 @@ snapshots:
- bufferutil
- utf-8-validate
'@modelcontextprotocol/client@2.0.0':
dependencies:
'@modelcontextprotocol/core': 2.0.0
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.6
jose: 6.2.3
pkce-challenge: 5.0.0
zod: 4.4.3
'@modelcontextprotocol/core@2.0.0':
dependencies:
zod: 4.4.3
'@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.12.23)':
dependencies:
'@hono/node-server': 1.19.14(hono@4.12.23)
'@modelcontextprotocol/server': 2.0.0
optionalDependencies:
hono: 4.12.23
'@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)':
dependencies:
'@hono/node-server': 1.19.14(hono@4.12.23)
@@ -4102,6 +4122,12 @@ snapshots:
zod-to-json-schema: 3.25.2(zod@4.4.3)
transitivePeerDependencies:
- supports-color
optional: true
'@modelcontextprotocol/server@2.0.0':
dependencies:
'@modelcontextprotocol/core': 2.0.0
zod: 4.4.3
'@nodable/entities@2.1.0': {}
@@ -4463,10 +4489,6 @@ snapshots:
'@typescript-eslint/types': 8.47.0
eslint-visitor-keys: 4.2.1
'@upstash/redis@1.38.0':
dependencies:
uncrypto: 0.1.3
'@vercel/oidc@3.1.0': {}
'@vitest/expect@4.1.9':
@@ -4552,6 +4574,7 @@ snapshots:
ajv-formats@3.0.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
optional: true
ajv@6.15.0:
dependencies:
@@ -4566,6 +4589,7 @@ snapshots:
fast-uri: 3.1.0
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
optional: true
ansi-align@3.0.1:
dependencies:
@@ -4636,6 +4660,7 @@ snapshots:
type-is: 2.0.1
transitivePeerDependencies:
- supports-color
optional: true
bowser@2.14.1: {}
@@ -4757,6 +4782,7 @@ snapshots:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
optional: true
cross-spawn@7.0.6:
dependencies:
@@ -4989,6 +5015,7 @@ snapshots:
dependencies:
express: 5.2.1
ip-address: 10.2.0
optional: true
express@5.1.0:
dependencies:
@@ -5044,7 +5071,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.14.0
qs: 6.15.2
range-parser: 1.2.1
router: 2.2.0
send: 1.2.0
@@ -5054,6 +5081,7 @@ snapshots:
vary: 1.1.2
transitivePeerDependencies:
- supports-color
optional: true
extend@3.0.2: {}
@@ -5075,7 +5103,8 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-uri@3.1.0: {}
fast-uri@3.1.0:
optional: true
fast-xml-builder@1.2.0:
dependencies:
@@ -5302,10 +5331,6 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.0:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
@@ -5323,7 +5348,8 @@ snapshots:
inherits@2.0.4: {}
ip-address@10.2.0: {}
ip-address@10.2.0:
optional: true
ipaddr.js@1.9.1: {}
@@ -5389,9 +5415,11 @@ snapshots:
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
json-schema-traverse@1.0.0:
optional: true
json-schema-typed@8.0.2: {}
json-schema-typed@8.0.2:
optional: true
json-schema@0.4.0: {}
@@ -5710,6 +5738,7 @@ snapshots:
qs@6.15.2:
dependencies:
side-channel: 1.1.0
optional: true
quansync@0.2.11: {}
@@ -5721,7 +5750,7 @@ snapshots:
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.7.0
iconv-lite: 0.7.2
unpipe: 1.0.0
read-yaml-file@1.1.0:
@@ -5733,7 +5762,8 @@ snapshots:
readdirp@4.1.2: {}
require-from-string@2.0.2: {}
require-from-string@2.0.2:
optional: true
resolve-from@4.0.0: {}
@@ -6048,8 +6078,6 @@ snapshots:
ufo@1.6.1: {}
uncrypto@0.1.3: {}
undici-types@6.21.0: {}
undici-types@7.16.0: {}
@@ -6245,5 +6273,6 @@ snapshots:
zod-to-json-schema@3.25.2(zod@4.4.3):
dependencies:
zod: 4.4.3
optional: true
zod@4.4.3: {}