UNPKG

@upstash/context7-mcp

Version:
425 lines (424 loc) • 20.2 kB
import { GET_UPGRADE_GUIDE_TOOL } from "./tool-names.js"; import { generateHeaders } from "./encryption.js"; import { Agent, ProxyAgent, setGlobalDispatcher } from "undici"; import { CONTEXT7_API_BASE_URL } from "./constants.js"; import { readFileSync } from "fs"; import tls from "tls"; import { observeUpstreamRequest } from "./telemetry-runtime.js"; import { INVALID_OAUTH_TOKEN_ERROR_CODE, isOpaqueOAuthToken, rememberInvalidOAuthToken, } from "./oauth-token-validation.js"; import { isClerkOAuthJwt } from "./jwt.js"; /** * Ceiling on a single Context7 API call. Without a signal a stalled backend * call rides undici's ~300s default before failing. 60s is generous for these * vector queries: p99.9 is ~3.2s, and no request exceeded 30s across a full * day of production traffic. */ const API_TIMEOUT_MS = 60_000; const EXPIRED_SIGN_IN_MESSAGE = "Your Context7 sign-in expired. Retry the request so your MCP client can refresh the sign-in."; /** The response body as JSON, or undefined when it is not JSON. */ async function readJson(response) { try { return (await response.json()); } catch { return undefined; } } /** * Parses error response from the Context7 API * Extracts the server's error message, falling back to status-based messages if parsing fails * @param response The fetch Response object * @param context Client context (transport and credential shape pick the message) * @returns Error message string */ async function parseErrorResponse(response, context) { const { apiKey } = context; const json = await readJson(response); const status = response.status; // On the hosted transport the auth gate answers the next request with an HTTP // 401 so the client refreshes its sign-in; the tool text should say so rather // than talk about API keys. stdio keeps the API's own message: there is no // HTTP status to act on and the credential there is a configured key. if (status === 401 && context.transport === "http" && (isOpaqueOAuthToken(apiKey) || isClerkOAuthJwt(apiKey)) && (json?.error === INVALID_OAUTH_TOKEN_ERROR_CODE || json?.error === undefined)) { rememberInvalidOAuthToken(apiKey); return EXPIRED_SIGN_IN_MESSAGE; } if (json?.message) { return json.message; } if (status === 429) { return apiKey ? "Rate limited or quota exceeded. Upgrade your plan at https://context7.com/plans for higher limits." : "Rate limited or quota exceeded. Create a free API key at https://context7.com/dashboard for higher limits."; } if (status === 404) { return "The library you are trying to access does not exist. Please try with a different library ID."; } if (status === 401) { return "Invalid API key. Please check your API key. API keys should start with 'ctx7sk' prefix."; } return `Request failed with status ${status}. Please try again later.`; } const PROXY_URL = process.env.HTTPS_PROXY ?? process.env.https_proxy ?? process.env.HTTP_PROXY ?? process.env.http_proxy ?? null; const CUSTOM_CA_CERTS = process.env.NODE_EXTRA_CA_CERTS; export function getDefaultCACertificates() { if (typeof tls.getCACertificates === "function") { return tls.getCACertificates("default"); } return [...tls.rootCertificates]; } export function loadCustomCACerts(customCACertsPath = CUSTOM_CA_CERTS) { if (!customCACertsPath) return undefined; try { const customCa = readFileSync(customCACertsPath, "utf-8"); return [...getDefaultCACertificates(), customCa]; } catch (error) { console.error(`[Context7] Failed to load custom CA certificates from ${customCACertsPath}:`, error); return undefined; } } if (PROXY_URL && !PROXY_URL.startsWith("$") && /^(http|https):\/\//i.test(PROXY_URL)) { try { const ca = loadCustomCACerts(); setGlobalDispatcher(new ProxyAgent({ uri: PROXY_URL, ...(ca ? { requestTls: { ca }, proxyTls: { ca } } : {}), })); } catch (error) { console.error(`[Context7] Failed to configure proxy agent for provided proxy URL: ${PROXY_URL}:`, error); } } else if (CUSTOM_CA_CERTS) { const ca = loadCustomCACerts(); if (ca) { try { setGlobalDispatcher(new Agent({ connect: { ca } })); } catch (error) { console.error(`[Context7] Failed to configure custom CA certificates:`, error); } } } function readPromptSignal(response, context) { if (response.headers.get("X-Context7-Auth-Prompt") === "1") { context.shouldPrompt = true; } } /** * Searches for libraries matching the given query * @param query The user's question or task (used for LLM relevance ranking) * @param libraryName The library name to search for in the database * @param context Client context including IP, API key, and client info * @returns Search results or error */ export async function searchLibraries(query, libraryName, context = {}) { try { const url = new URL(`${CONTEXT7_API_BASE_URL}/v2/libs/search`); url.searchParams.set("query", query); url.searchParams.set("libraryName", libraryName); const headers = generateHeaders(context); const abortSignal = AbortSignal.timeout(API_TIMEOUT_MS); return await observeUpstreamRequest("search_libraries", () => fetch(url, { headers, signal: abortSignal }), async (response) => { readPromptSignal(response, context); if (!response.ok) { const errorMessage = await parseErrorResponse(response, context); console.error(errorMessage); return { results: [], error: errorMessage }; } const searchData = await response.json(); return searchData; }, { abortSignal }); } catch (error) { const errorMessage = `Error searching libraries: ${error}`; console.error(errorMessage); return { results: [], error: errorMessage }; } } /** * Sends feedback on a library's documentation to the Context7 API * @param feedback The feedback the agent or user gave * @param context Client context including IP, API key, and client info * @returns The text to show and whether the API stored the feedback */ export async function submitDocsFeedback(feedback, context = {}) { try { const url = new URL(`${CONTEXT7_API_BASE_URL}/v2/feedback`); const headers = { ...generateHeaders(context), "Content-Type": "application/json" }; const abortSignal = AbortSignal.timeout(API_TIMEOUT_MS); return await observeUpstreamRequest("submit_feedback", () => fetch(url, { method: "POST", headers, body: JSON.stringify(feedback), signal: abortSignal, }), async (response) => { if (!response.ok) { const errorMessage = await parseErrorResponse(response, context); console.error(errorMessage); return { text: errorMessage, outcome: "error" }; } return { text: "Feedback recorded. No need to mention it to the user.", outcome: "success", }; }, { abortSignal }); } catch (error) { const errorMessage = `Error sending feedback. Please try again later. ${error}`; console.error(errorMessage); return { text: errorMessage, outcome: "error" }; } } /** Seconds to wait from a 202 body or a Retry-After header (seconds or an HTTP date). */ export function retryAfterSeconds(bodyValue, header) { if (typeof bodyValue === "number" && Number.isFinite(bodyValue) && bodyValue >= 0) return Math.ceil(bodyValue); if (header) { const seconds = Number(header); if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds); const date = Date.parse(header); if (Number.isFinite(date)) return Math.max(1, Math.ceil((date - Date.now()) / 1000)); } return 30; } /** * The summary line of an empty /v2/code result, such as "0 matching lines in 0 files", * "0 entries in src" or "Symbols in a.ts (0 of 0):". The server's lib/code/format.ts * writes it on the line after the "Library: ..." header. */ const EMPTY_CODE_SUMMARY = /^(?:(?:at least )?0 |(?:Symbols in .+|Exported API) \(0 of )/; /** True when the result's summary line counts no results. Library source lines are not checked. */ export function isEmptyCodeResult(text) { const [first = "", second = ""] = text.split("\n", 2); return EMPTY_CODE_SUMMARY.test(first.startsWith("Library: ") ? second : first); } /** * The error text for a failed /v2/code call. The next step comes from the * HTTP status and the error code in the JSON body, not from the message text. */ async function codeErrorMessage(response, request, context) { const body = (await response .clone() .json() .catch(() => undefined)); const code = typeof body?.error === "string" ? body.error : undefined; // A gateway error has no body from the code API: write the whole message here. if (!body?.message && response.status >= 500) return `Source code search is not available now (HTTP ${response.status}). Do not retry now: answer from query-docs.`; const message = await parseErrorResponse(response, context); // For a git tag, index_failed means that the tag may not exist; the message lists the docs versions. const hasVersion = !!request.version || request.libraryId.split("/").filter(Boolean).length > 2; if (code === "index_failed" && hasVersion) return `${message} Call query-code again with one of the docs versions in this message, or without version.`; if (code === "code_search_unavailable") return `${message} Answer from query-docs.`; return message; } /** * Queries a library's source code at the version its docs were built from. * A 202 means the source is still being indexed: that is a normal answer * telling the agent to call again, not an error. */ export async function fetchLibraryCode(request, context = {}) { try { const url = new URL(`${CONTEXT7_API_BASE_URL}/v2/code`); url.searchParams.set("libraryId", request.libraryId); // An agent may copy a full versioned ID ("/vercel/next.js/v15.1.8") into version. const version = request.version?.startsWith(`${request.libraryId}/`) ? request.version.slice(request.libraryId.length + 1) : request.version; if (version) url.searchParams.set("version", version); if (request.operation) url.searchParams.set("operation", request.operation); if (request.pattern !== undefined) url.searchParams.set("pattern", request.pattern); if (request.fixed_strings) url.searchParams.set("fixed_strings", "true"); if (request.ignore_case) url.searchParams.set("ignore_case", "true"); for (const glob of [request.glob ?? []].flat()) url.searchParams.append("glob", glob); if (request.path) url.searchParams.set("path", request.path); if (request.context !== undefined) url.searchParams.set("context", String(request.context)); if (request.output_mode) url.searchParams.set("output_mode", request.output_mode); if (request.head_limit !== undefined) url.searchParams.set("head_limit", String(request.head_limit)); if (request.offset) url.searchParams.set("offset", String(request.offset)); const headers = generateHeaders(context); const abortSignal = AbortSignal.timeout(API_TIMEOUT_MS); return await observeUpstreamRequest("fetch_code", () => fetch(url, { headers, signal: abortSignal }), async (response) => { readPromptSignal(response, context); if (response.status === 202) { const body = (await response.json().catch(() => ({}))); const wait = retryAfterSeconds(body.retryAfter, response.headers.get("Retry-After")); const message = body.message ?? "The library source is being indexed."; return { data: `${message} This is not an error. Call query-code again with the same arguments in about ${wait} seconds, at most twice. Meanwhile, or if it is still indexing, answer from query-docs.`, // No "indexing" outcome exists: telemetry counts it with the empty results. outcome: "not_found", }; } if (!response.ok) { const errorMessage = await codeErrorMessage(response, request, context); console.error(errorMessage); return { data: errorMessage, outcome: "error" }; } const text = await response.text(); if (!text) return { data: "No source results for this query.", outcome: "not_found" }; return { data: text, outcome: isEmptyCodeResult(text) ? "not_found" : "success" }; }, { abortSignal }); } catch (error) { const errorMessage = `Error querying library source code: ${error}. Do not retry now: answer from query-docs.`; console.error(errorMessage); return { data: errorMessage, outcome: "error" }; } } /** * The endpoint's error codes that answer the agent's request, not failures, with their telemetry * outcome: a library without a guide or an unknown version (not found), a page past the last * (invalid), or a guide that cannot be prepared for now (unavailable). The agent sees the API's * message, which says what to do next, without `isError`. */ const UPGRADE_GUIDE_ANSWERS = { library_not_prepared: "not_found", version_not_found: "not_found", page_out_of_range: "invalid", guide_unavailable: "unavailable", }; function isUpgradeGuideAnswer(error) { return Object.hasOwn(UPGRADE_GUIDE_ANSWERS, error); } /** * The tool's answer to an error response of the upgrade guide endpoint that answers the request * (UPGRADE_GUIDE_ANSWERS), or undefined for any other error. The text is the API's message, * which says what to do next and, for a guide unavailable for now, when to ask again. */ export function upgradeGuideAnswer(json) { const error = json?.error; if (!error || !isUpgradeGuideAnswer(error)) return undefined; return { text: json.message ?? error, outcome: UPGRADE_GUIDE_ANSWERS[error] }; } /** * Fetches the upgrade guide for a library between two versions * @param request The library ID, the versions to upgrade from and to, and the page of the guide * @param context Client context including IP, API key, and client info * @returns The guide, a note to call again while it is prepared, or an error */ export async function fetchUpgradeGuide(request, context = {}) { try { const url = new URL(`${CONTEXT7_API_BASE_URL}/v2/version-upgrade-guide`); url.searchParams.set("libraryId", request.libraryId); url.searchParams.set("fromVersion", request.fromVersion); if (request.toVersion) url.searchParams.set("toVersion", request.toVersion); // Always sent, so the app can tell a client that pages from one that predates paging // (CTX7-3607). The app reads `page` from the deploy that ships paging: deploy it first. url.searchParams.set("page", String(request.page ?? 1)); const headers = generateHeaders(context); const abortSignal = AbortSignal.timeout(API_TIMEOUT_MS); return await observeUpstreamRequest("fetch_upgrade_guide", () => fetch(url, { headers, signal: abortSignal }), async (response) => { // Checked for any status, before the status-specific fallbacks below; cloned so // parseErrorResponse can still read the body of any other error. if (!response.ok) { const json = await readJson(response.clone()); const answer = upgradeGuideAnswer(json); if (answer) return answer; } switch (response.status) { // The source code is being indexed or the guide is being prepared; the agent calls again later. case 202: { const json = await readJson(response); const retryAfter = typeof json?.retryAfter === "number" && json.retryAfter > 0 ? json.retryAfter : 60; const note = `The upgrade guide is being prepared. Call '${GET_UPGRADE_GUIDE_TOOL}' again with the same arguments after ${retryAfter} seconds.`; return { text: json?.message ? `${note}\n\n${json.message}` : note, outcome: "success", }; } case 404: { // Any other 404 (for example a deployment without the endpoint) is an upstream error. const text = "The upgrade guide service is not available (HTTP 404). Please try again later."; console.error(text); return { text, outcome: "error" }; } } if (!response.ok) { const errorMessage = await parseErrorResponse(response, context); console.error(errorMessage); return { text: errorMessage, outcome: "error" }; } const text = await response.text(); if (!text) { return { text: "No upgrade guide was returned for these versions.", outcome: "not_found", }; } return { text, outcome: "success" }; }, { abortSignal }); } catch (error) { const errorMessage = `Error fetching the upgrade guide. Please try again later. ${error}`; console.error(errorMessage); return { text: errorMessage, outcome: "error" }; } } /** * Fetches intelligent, reranked context for a natural language query * @param request The context request parameters (query, libraryId, language) * @param context Client context including IP, API key, and client info * @returns Context response with data */ export async function fetchLibraryContext(request, context = {}) { try { const url = new URL(`${CONTEXT7_API_BASE_URL}/v2/context`); url.searchParams.set("query", request.query); url.searchParams.set("libraryId", request.libraryId); if (request.language) url.searchParams.set("language", request.language); const headers = generateHeaders(context); const abortSignal = AbortSignal.timeout(API_TIMEOUT_MS); return await observeUpstreamRequest("fetch_context", () => fetch(url, { headers, signal: abortSignal }), async (response) => { readPromptSignal(response, context); if (!response.ok) { const errorMessage = await parseErrorResponse(response, context); console.error(errorMessage); return { data: errorMessage, outcome: "error" }; } const text = await response.text(); if (!text) { return { data: "Documentation not found or not finalized for this library. This might have happened because you used an invalid Context7-compatible library ID. To get a valid Context7-compatible library ID, use the 'resolve-library-id' with the package name you wish to retrieve documentation for.", outcome: "not_found", }; } return { data: text, outcome: "success" }; }, { abortSignal }); } catch (error) { const errorMessage = `Error fetching library context. Please try again later. ${error}`; console.error(errorMessage); return { data: errorMessage, outcome: "error" }; } }