Skip to main content
Glama

Convex MCP server

Official
by get-convex
utils.ts35.4 kB
import chalk from "chalk"; import os from "os"; import path from "path"; import { ProjectConfig } from "../config.js"; import { spawn } from "child_process"; import { InvalidArgumentError } from "commander"; import fetchRetryFactory, { RequestInitRetryParams } from "fetch-retry"; import { Context, ErrorType } from "../../../bundler/context.js"; import { failExistingSpinner, logError, logMessage, logWarning, } from "../../../bundler/log.js"; import { version } from "../../version.js"; import { Project } from "../api.js"; import { promptOptions, promptSearch, promptYesNo } from "./prompts.js"; import { bigBrainEnableFeatureMetadata, projectHasExistingCloudDev, } from "../localDeployment/bigBrain.js"; import type { paths as ManagementPaths } from "../../generatedApi.js"; import createClient from "openapi-fetch"; const retryingFetch = fetchRetryFactory(fetch); export const productionProvisionHost = "https://api.convex.dev"; export const provisionHost = process.env.CONVEX_PROVISION_HOST || productionProvisionHost; const BIG_BRAIN_URL = `${provisionHost}/api/`; export const ENV_VAR_FILE_PATH = ".env.local"; export const CONVEX_DEPLOY_KEY_ENV_VAR_NAME = "CONVEX_DEPLOY_KEY"; export const CONVEX_DEPLOYMENT_ENV_VAR_NAME = "CONVEX_DEPLOYMENT"; export const CONVEX_SELF_HOSTED_URL_VAR_NAME = "CONVEX_SELF_HOSTED_URL"; export const CONVEX_SELF_HOSTED_ADMIN_KEY_VAR_NAME = "CONVEX_SELF_HOSTED_ADMIN_KEY"; const MAX_RETRIES = 6; // After 3 retries, log a progress message that we're retrying the request const RETRY_LOG_THRESHOLD = 3; export function parsePositiveInteger(value: string) { const parsedValue = parseInteger(value); if (parsedValue <= 0) { // eslint-disable-next-line no-restricted-syntax throw new InvalidArgumentError("Not a positive number."); } return parsedValue; } export function parseInteger(value: string) { const parsedValue = +value; if (isNaN(parsedValue)) { // eslint-disable-next-line no-restricted-syntax throw new InvalidArgumentError("Not a number."); } return parsedValue; } export type ErrorData = { code: string; message: string; }; /** * Error thrown on non-2XX reponse codes to make most `fetch()` error handling * follow a single code path. */ export class ThrowingFetchError extends Error { response: Response; serverErrorData?: ErrorData; constructor( msg: string, { code, message, response, }: { cause?: Error; code?: string; message?: string; response: Response }, ) { if (code !== undefined && message !== undefined) { super(`${msg}: ${code}: ${message}`); this.serverErrorData = { code, message }; } else { super(msg); } Object.setPrototypeOf(this, ThrowingFetchError.prototype); this.response = response; } public static async fromResponse( response: Response, msg?: string, ): Promise<ThrowingFetchError> { msg = `${msg ? `${msg} ` : ""}${response.status} ${response.statusText}`; let code, message; try { ({ code, message } = await response.json()); } catch { // Do nothing because the non-2XX response code is the primary error here. } return new ThrowingFetchError(msg, { code, message, response }); } async handle(ctx: Context): Promise<never> { let error_type: ErrorType = "transient"; await checkFetchErrorForDeprecation(ctx, this.response); let msg = this.message; if (this.response.status === 400) { error_type = "invalid filesystem or env vars"; } else if (this.response.status === 401) { error_type = "fatal"; msg = `${msg}\nAuthenticate with \`npx convex dev\``; } else if (this.response.status === 404) { error_type = "fatal"; msg = `${msg}: ${this.response.url}`; } return await ctx.crash({ exitCode: 1, errorType: error_type, errForSentry: this, printedMessage: chalk.red(msg.trim()), }); } } /** * Thin wrapper around `fetch()` which throws a FetchDataError on non-2XX * responses which includes error code and message from the response JSON. * (Axios-style) * * It also accepts retry options from fetch-retry. */ export async function throwingFetch( resource: RequestInfo | URL, options: (RequestInit & RequestInitRetryParams<typeof fetch>) | undefined, ): Promise<Response> { const Headers = globalThis.Headers; const headers = new Headers((options || {})["headers"]); if (options?.body) { if (!headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } } const response = await retryingFetch(resource, options); if (!response.ok) { // This error must always be handled manually. // eslint-disable-next-line no-restricted-syntax throw await ThrowingFetchError.fromResponse( response, `Error fetching ${options?.method ? options.method + " " : ""} ${ typeof resource === "string" ? resource : "url" in resource ? resource.url : resource.toString() }`, ); } return response; } /** * Handle an error a fetch error or non-2xx response. */ export async function logAndHandleFetchError( ctx: Context, err: unknown, ): Promise<never> { // Fail the spinner so the stderr lines appear failExistingSpinner(); if (err instanceof ThrowingFetchError) { return await err.handle(ctx); } else { return await ctx.crash({ exitCode: 1, errorType: "transient", errForSentry: err, printedMessage: chalk.red(err), }); } } function logDeprecationWarning(ctx: Context, deprecationMessage: string) { if (ctx.deprecationMessagePrinted) { return; } ctx.deprecationMessagePrinted = true; logWarning(chalk.yellow(deprecationMessage)); } async function checkFetchErrorForDeprecation(ctx: Context, resp: Response) { const headers = resp.headers; if (headers) { const deprecationState = headers.get("x-convex-deprecation-state"); const deprecationMessage = headers.get("x-convex-deprecation-message"); switch (deprecationState) { case null: break; case "Deprecated": // This version is deprecated. Print a warning and crash. // Gotcha: // 1. Don't use `logDeprecationWarning` because we should always print // why this we crashed (even if we printed a warning earlier). return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: chalk.red(deprecationMessage), }); default: // The error included a deprecation warning. Print, but handle the // error normally (it was for another reason). logDeprecationWarning( ctx, deprecationMessage || "(no deprecation message included)", ); break; } } } /// Call this method after a successful API response to conditionally print the /// "please upgrade" message. export function deprecationCheckWarning(ctx: Context, resp: Response) { const headers = resp.headers; if (headers) { const deprecationState = headers.get("x-convex-deprecation-state"); const deprecationMessage = headers.get("x-convex-deprecation-message"); switch (deprecationState) { case null: break; case "Deprecated": // This should never happen because such states are errors, not warnings. // eslint-disable-next-line no-restricted-syntax throw new Error( "Called deprecationCheckWarning on a fatal error. This is a bug.", ); default: logDeprecationWarning( ctx, deprecationMessage || "(no deprecation message included)", ); break; } } } type Team = { id: number; name: string; slug: string; }; export async function hasTeam(ctx: Context, teamSlug: string) { const teams: Team[] = await bigBrainAPI({ ctx, method: "GET", url: "teams" }); return teams.some((team) => team.slug === teamSlug); } export async function validateOrSelectTeam( ctx: Context, teamSlug: string | undefined, promptMessage: string, ): Promise<{ teamSlug: string; chosen: boolean }> { const teams: Team[] = await bigBrainAPI({ ctx, method: "GET", url: "teams" }); if (teams.length === 0) { await ctx.crash({ exitCode: 1, errorType: "fatal", errForSentry: "No teams found", printedMessage: chalk.red("Error: No teams found"), }); } if (!teamSlug) { // Prompt the user to select if they belong to more than one team. switch (teams.length) { case 1: return { teamSlug: teams[0].slug, chosen: false }; default: return { teamSlug: await promptSearch(ctx, { message: promptMessage, choices: teams.map((team: Team) => ({ name: `${team.name} (${team.slug})`, value: team.slug, })), }), chosen: true, }; } } else { // Validate the chosen team. if (!teams.find((team) => team.slug === teamSlug)) { await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `Error: Team ${teamSlug} not found, fix the --team option or remove it`, }); } return { teamSlug, chosen: false }; } } export async function selectDevDeploymentType( ctx: Context, { chosenConfiguration, newOrExisting, teamSlug, projectSlug, userHasChosenSomethingInteractively, // from `--configure --dev-deployment local|cloud` devDeploymentFromFlag, // from `--cloud or --local` forceDevDeployment, }: | { chosenConfiguration: "new" | "existing" | "ask" | null; newOrExisting: "existing"; teamSlug: string; projectSlug: string; userHasChosenSomethingInteractively: boolean; devDeploymentFromFlag: "cloud" | "local" | undefined; forceDevDeployment: "cloud" | "local" | undefined; } | { chosenConfiguration: "new" | "existing" | "ask" | null; newOrExisting: "new"; teamSlug: string; // For new projects we don't know the project slug yet. projectSlug: undefined; userHasChosenSomethingInteractively: boolean; devDeploymentFromFlag: "cloud" | "local" | undefined; forceDevDeployment: "cloud" | "local" | undefined; }, ): Promise<{ devDeployment: "cloud" | "local" }> { if (forceDevDeployment) return { devDeployment: forceDevDeployment }; if (devDeploymentFromFlag) return { devDeployment: devDeploymentFromFlag }; if (newOrExisting === "existing" && chosenConfiguration === null) { // Don't suggest local dev if developer already has a cloud deployment. if (await projectHasExistingCloudDev(ctx, { projectSlug, teamSlug })) { // TODO Expand rollout to offer local dev in this case. ENG-8307 return { devDeployment: "cloud" }; } } // To avoid breaking previously non-interactive flows, don't prompt if enough // flags were specified for configure not to already have needed input. if (chosenConfiguration !== "ask" && !userHasChosenSomethingInteractively) { return { devDeployment: "cloud" }; } // For creating a first project (no projects exist) or joining a first project // (one project exists), always use cloud since it's a smoother experience. const isFirstProject = (await bigBrainEnableFeatureMetadata(ctx)).totalProjects.kind !== "multiple"; if (isFirstProject) { return { devDeployment: "cloud" }; } // For now default is always cloud. const devDeployment: "cloud" | "local" = await promptOptions(ctx, { message: "Use cloud or local dev deployment? For more see https://docs.convex.dev/cli/local-deployments", default: "cloud", choices: [ { name: "cloud deployment", value: "cloud" }, { name: "local deployment (BETA)", value: "local" }, ], }); return { devDeployment }; } export async function hasProject( ctx: Context, teamSlug: string, projectSlug: string, ) { try { const projects: Project[] = ( await typedBigBrainClient(ctx).GET("/teams/{team_slug}/projects", { params: { path: { team_slug: teamSlug, }, }, }) ).data!; return !!projects.find((project) => project.slug === projectSlug); } catch { return false; } } export async function hasProjects(ctx: Context) { return !!(await bigBrainAPI({ ctx, method: "GET", url: `has_projects` })); } export async function validateOrSelectProject( ctx: Context, projectSlug: string | undefined, teamSlug: string, singleProjectPrompt: string, multiProjectPrompt: string, ): Promise<string | null> { const projects: Project[] = ( await typedBigBrainClient(ctx).GET("/teams/{team_slug}/projects", { params: { path: { team_slug: teamSlug, }, }, }) ).data!; if (projects.length === 0) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `No existing projects! Run this command again and choose "create a new project."`, }); } if (!projectSlug) { const nonDemoProjects = projects.filter((project) => !project.isDemo); if (nonDemoProjects.length === 0) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `No existing non-demo projects! Run this command again and choose "create a new project."`, }); } // Prompt the user to select project. switch (nonDemoProjects.length) { case 1: { const project = nonDemoProjects[0]; const confirmed = await promptYesNo(ctx, { message: `${singleProjectPrompt} ${project.name} (${project.slug})?`, }); if (!confirmed) { return null; } return nonDemoProjects[0].slug; } default: return await promptSearch(ctx, { message: multiProjectPrompt, choices: nonDemoProjects.map((project: Project) => ({ name: `${project.name} (${project.slug})`, value: project.slug, })), }); } } else { // Validate the chosen project. if (!projects.find((project) => project.slug === projectSlug)) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `Error: Project ${projectSlug} not found, fix the --project option or remove it`, }); } return projectSlug; } } /** * @param ctx * @returns a Record of dependency name to dependency version for dependencies * and devDependencies */ export async function loadPackageJson( ctx: Context, includePeerDeps = false, ): Promise<Record<string, string>> { let packageJson; try { packageJson = ctx.fs.readUtf8File("package.json"); } catch (err) { return await ctx.crash({ exitCode: 1, errorType: "invalid filesystem data", printedMessage: `Unable to read your package.json: ${ err as any }. Make sure you're running this command from the root directory of a Convex app that contains the package.json`, }); } let obj; try { obj = JSON.parse(packageJson); } catch (err) { return await ctx.crash({ exitCode: 1, errorType: "invalid filesystem data", errForSentry: err, printedMessage: `Unable to parse package.json: ${err as any}`, }); } if (typeof obj !== "object") { return await ctx.crash({ exitCode: 1, errorType: "invalid filesystem data", printedMessage: "Expected to parse an object from package.json", }); } const packages = { ...(includePeerDeps ? (obj.peerDependencies ?? {}) : {}), ...(obj.dependencies ?? {}), ...(obj.devDependencies ?? {}), }; return packages; } export async function ensureHasConvexDependency(ctx: Context, cmd: string) { const packages = await loadPackageJson(ctx, true); const hasConvexDependency = "convex" in packages; if (!hasConvexDependency) { return await ctx.crash({ exitCode: 1, errorType: "invalid filesystem data", printedMessage: `In order to ${cmd}, add \`convex\` to your package.json dependencies.`, }); } } /** Return a new array with elements of the passed in array sorted by a key lambda */ export const sorted = <T>(arr: T[], key: (el: T) => any): T[] => { const newArr = [...arr]; const cmp = (a: T, b: T) => { if (key(a) < key(b)) return -1; if (key(a) > key(b)) return 1; return 0; }; return newArr.sort(cmp); }; export function functionsDir( configPath: string, projectConfig: ProjectConfig, ): string { return path.join(path.dirname(configPath), projectConfig.functions); } function convexName() { // Use a different directory for config files generated for tests if (process.env.CONVEX_PROVISION_HOST) { const port = process.env.CONVEX_PROVISION_HOST.split(":")[2]; if (port === undefined || port === "8050") { return `convex-test`; } else { return `convex-test-${port}`; } } return "convex"; } export function rootDirectory(): string { return path.join(os.homedir(), `.${convexName()}`); } export function cacheDir() { const name = convexName(); const platform = process.platform; if (platform === "win32") { // On Windows, `LOCALAPPDATA` is usually set, but fall back to // `USERPROFILE` if not, and fall back to homedir if all else fails. if (process.env.LOCALAPPDATA) { return path.join(process.env.LOCALAPPDATA, name); } if (process.env.USERPROFILE) { return path.join(process.env.USERPROFILE, "AppData", "Local", name); } return path.join(os.homedir(), "AppData", "Local", name); } return path.join(os.homedir(), ".cache", name); } /** * Fetch with appropriate headers for the Convex Management API. * * This fetch() also has retries and throws if the response is not ok. */ export async function bigBrainFetch(ctx: Context): Promise<typeof fetch> { const authHeader = ctx.bigBrainAuth()?.header; const bigBrainHeaders: Record<string, string> = authHeader ? { Authorization: authHeader, "Convex-Client": `npm-cli-${version}`, } : { "Convex-Client": `npm-cli-${version}`, }; return (resource: RequestInfo | URL, options: RequestInit | undefined) => { const { headers: optionsHeaders, ...rest } = options || {}; const headers = { ...bigBrainHeaders, ...(optionsHeaders || {}), }; const opts = { retries: MAX_RETRIES, retryDelay, headers, ...rest, }; const url = resource instanceof URL ? resource.pathname : typeof resource === "string" ? new URL(resource, BIG_BRAIN_URL) : new URL(resource.url, BIG_BRAIN_URL); return throwingFetch(url, opts); }; } export async function bigBrainAPI<T = any>({ ctx, method, url, data, }: { ctx: Context; method: "GET" | "POST" | "HEAD"; url: string; data?: any; }): Promise<T> { const dataString = data === undefined ? undefined : typeof data === "string" ? data : JSON.stringify(data); try { return await bigBrainAPIMaybeThrows({ ctx, method, url, data: dataString, }); } catch (err: unknown) { return await logAndHandleFetchError(ctx, err); } } /** * Typed API client with a fetch() implemention that includes retries and crashes on errors. * It is always safe to call `.data!` on the response: any error would throw or crash. * * Pass { throw: true } to throw ThrowingFetchErrors instead of exiting the process. */ export function typedBigBrainClient( ctx: Context, options: { throw?: boolean } = {}, ) { const bigBrainClient = createClient<ManagementPaths>({ baseUrl: BIG_BRAIN_URL, fetch: async ( resource: Request, options?: RequestInit, ): Promise<Response> => { const fetch = await bigBrainFetch(ctx); return fetch(resource, options); }, }); // Wrap the client with error handling - go back to proxy since middleware doesn't catch parsing errors return new Proxy(bigBrainClient, { get(target, prop) { const originalMethod = target[prop as keyof typeof target]; if ( prop === "GET" || prop === "POST" || prop === "HEAD" || prop === "OPTIONS" || prop === "PUT" || prop === "DELETE" || prop === "PATCH" || prop === "TRACE" ) { return async (...args: any[]) => { try { return await (originalMethod as Function).apply(target, args); } catch (err: unknown) { if (options.throw) { // eslint-disable-next-line no-restricted-syntax throw err; } return await logAndHandleFetchError(ctx, err); } }; } return originalMethod; }, }); } export async function bigBrainAPIMaybeThrows({ ctx, method, url, data, }: { ctx: Context; method: "GET" | "POST" | "HEAD"; url: string; data?: any; }): Promise<any> { const fetch = await bigBrainFetch(ctx); const dataString = data === undefined ? method === "POST" ? JSON.stringify({}) : undefined : typeof data === "string" ? data : JSON.stringify(data); const res = await fetch(url, { method, ...(dataString ? { body: dataString } : {}), headers: method === "POST" ? { "Content-Type": "application/json", } : {}, }); deprecationCheckWarning(ctx, res); if (res.status === 200) { return await res.json(); } } /** * Polls an arbitrary function until a condition is met. * * @param fetch Function performing a fetch, returning resulting data. * @param condition This function will terminate polling when it returns `true`. * @param waitMs How long to wait in between fetches. * @returns The resulting data from `fetch`. */ export const poll = async function <Result>( fetch: () => Promise<Result>, condition: (data: Result) => boolean, waitMs = 1000, ) { let result = await fetch(); while (!condition(result)) { await wait(waitMs); result = await fetch(); } return result; }; const wait = function (waitMs: number) { return new Promise((resolve) => { setTimeout(resolve, waitMs); }); }; export function waitForever() { // This never resolves return new Promise((_) => { // ignore }); } // Returns a promise and a function that resolves the promise. export function waitUntilCalled(): [Promise<unknown>, () => void] { let onCalled: (v: unknown) => void; const waitPromise = new Promise((resolve) => (onCalled = resolve)); return [waitPromise, () => onCalled(null)]; } // We can eventually switch to something like `filesize` for i18n and // more robust formatting, but let's keep our CLI bundle small for now. export function formatSize(n: number): string { if (n < 1024) { return `${n} B`; } if (n < 1024 * 1024) { return `${(n / 1024).toFixed(1)} KB`; } if (n < 1024 * 1024 * 1024) { return `${(n / 1024 / 1024).toFixed(1)} MB`; } return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; } export function formatDuration(ms: number): string { const twoDigits = (n: number, unit: string) => `${n.toLocaleString("en-US", { maximumFractionDigits: 2 })}${unit}`; if (ms < 1e-3) { return twoDigits(ms * 1e9, "ns"); } if (ms < 1) { return twoDigits(ms * 1e3, "µs"); } if (ms < 1e3) { return twoDigits(ms, "ms"); } const s = ms / 1e3; if (s < 60) { return twoDigits(ms / 1e3, "s"); } return twoDigits(s / 60, "m"); } export function getCurrentTimeString() { const now = new Date(); const hours = String(now.getHours()).padStart(2, "0"); const minutes = String(now.getMinutes()).padStart(2, "0"); const seconds = String(now.getSeconds()).padStart(2, "0"); return `${hours}:${minutes}:${seconds}`; } // We don't allow running commands in project subdirectories yet, // but we can provide better errors if we look around. export async function findParentConfigs(ctx: Context): Promise<{ parentPackageJson: string; parentConvexJson?: string | undefined; }> { const parentPackageJson = findUp(ctx, "package.json"); if (!parentPackageJson) { return await ctx.crash({ exitCode: 1, errorType: "invalid filesystem data", printedMessage: "No package.json found. To create a new project using Convex, see https://docs.convex.dev/home#quickstarts", }); } const candidateConvexJson = parentPackageJson && path.join(path.dirname(parentPackageJson), "convex.json"); const parentConvexJson = candidateConvexJson && ctx.fs.exists(candidateConvexJson) ? candidateConvexJson : undefined; return { parentPackageJson, parentConvexJson, }; } /** * Finds a file in the current working directory or a parent. * * @returns The absolute path of the first file found or undefined. */ function findUp(ctx: Context, filename: string): string | undefined { let curDir = path.resolve("."); let parentDir = curDir; do { const candidate = path.join(curDir, filename); if (ctx.fs.exists(candidate)) { return candidate; } curDir = parentDir; parentDir = path.dirname(curDir); } while (parentDir !== curDir); return; } /** * Returns whether there's an existing project config. Throws * if this is not a valid directory for a project config. */ export async function isInExistingProject(ctx: Context) { const { parentPackageJson, parentConvexJson } = await findParentConfigs(ctx); if (parentPackageJson !== path.resolve("package.json")) { return await ctx.crash({ exitCode: 1, errorType: "invalid filesystem data", printedMessage: "Run this command from the root directory of a project.", }); } return !!parentConvexJson; } // `spawnAsync` is the async version of Node's `spawnSync` (and `spawn`). // // By default, this returns the produced `stdout` and `stderror` and // an error if one was encountered (to mirror `spawnSync`). // // If `stdio` is set to `"inherit"`, pipes `stdout` and `stderror` ( // pausing the spinner if one is running) and rejects the promise // on errors (to mirror `execFileSync`). export function spawnAsync( ctx: Context, command: string, args: ReadonlyArray<string>, ): Promise<{ stdout: string; stderr: string; status: null | number; error?: Error | undefined; }>; export function spawnAsync( ctx: Context, command: string, args: ReadonlyArray<string>, options: { stdio: "inherit"; shell?: boolean }, ): Promise<void>; export function spawnAsync( _ctx: Context, command: string, args: ReadonlyArray<string>, options?: { stdio: "inherit"; shell?: boolean }, ) { return new Promise((resolve, reject) => { const child = spawn(command, args, { shell: options?.shell }); let stdout = ""; let stderr = ""; const pipeOutput = options?.stdio === "inherit"; if (pipeOutput) { child.stdout.on("data", (text) => logMessage(text.toString("utf-8").trimEnd()), ); child.stderr.on("data", (text) => logError(text.toString("utf-8").trimEnd()), ); } else { child.stdout.on("data", (data) => { stdout += data.toString("utf-8"); }); child.stderr.on("data", (data) => { stderr += data.toString("utf-8"); }); } const completionListener = (code: number | null) => { child.removeListener("error", errorListener); const result = pipeOutput ? { status: code } : { stdout, stderr, status: code }; if (code !== 0) { const argumentString = args && args.length > 0 ? ` ${args.join(" ")}` : ""; const error = new Error( `\`${command}${argumentString}\` exited with non-zero code: ${code}`, ); if (pipeOutput) { reject({ ...result, error }); } else { resolve({ ...result, error }); } } else { resolve(result); } }; const errorListener = (error: Error) => { child.removeListener("exit", completionListener); child.removeListener("close", completionListener); if (pipeOutput) { reject({ error, status: null }); } else { resolve({ error, status: null }); } }; if (pipeOutput) { child.once("exit", completionListener); } else { child.once("close", completionListener); } child.once("error", errorListener); }); } const IDEMPOTENT_METHODS = ["GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"]; function retryDelay( attempt: number, _error: Error | null, _response: Response | null, ): number { // immediate, 1s delay, 2s delay, 4s delay, etc. const delay = attempt === 0 ? 1 : 2 ** (attempt - 1) * 1000; const randomSum = delay * 0.2 * Math.random(); return delay + randomSum; } function deploymentFetchRetryOn( onError?: (err: any, attempt: number) => void, method?: string, ) { const shouldRetry = function ( _attempt: number, error: Error | null, response: Response | null, ): { kind: "retry"; error: any } | { kind: "stop" } { // Retry on network errors. if (error !== null) { // TODO filter out all SSL errors // https://github.com/nodejs/node/blob/8a41d9b636be86350cd32847c3f89d327c4f6ff7/src/crypto/crypto_common.cc#L218-L245 return { kind: "retry", error: error }; } // Retry on 404s since these can sometimes happen with newly created // deployments for POSTs. if (response?.status === 404) { return { kind: "retry", error: `Received response with status ${response.status}`, }; } // Whatever the error code it doesn't hurt to retry idempotent requests. if ( response && !response.ok && method && IDEMPOTENT_METHODS.includes(method.toUpperCase()) ) { // ...but it's a bit annoying to wait for things we know won't succced if ( [ 400, // Bad Request 401, // Unauthorized 402, // PaymentRequired 403, // Forbidden 405, // Method Not Allowed 406, // Not Acceptable 412, // Precondition Failed 413, // Payload Too Large 414, // URI Too Long 415, // Unsupported Media Type 416, // Range Not Satisfiable ].includes(response.status) ) { return { kind: "stop", }; } return { kind: "retry", error: `Received response with status ${response.status}`, }; } return { kind: "stop" }; }; return function ( attempt: number, error: Error | null, response: Response | null, ) { const result = shouldRetry(attempt, error, response); if (result.kind === "retry") { onError?.(result.error, attempt); } if (attempt >= MAX_RETRIES) { // Stop retrying if we've exhausted all retries, but do this after we've // called `onError` so that the caller can still log the error. return false; } return result.kind === "retry"; }; } /** * Unlike `deploymentFetch`, this does not add on any headers, so the caller * must supply any headers. */ export function bareDeploymentFetch( _ctx: Context, options: { deploymentUrl: string; onError?: (err: any) => void; }, ): typeof throwingFetch { const { deploymentUrl, onError } = options; const onErrorWithAttempt = (err: any, attempt: number) => { onError?.(err); if (attempt >= RETRY_LOG_THRESHOLD) { logMessage( chalk.gray(`Retrying request (attempt ${attempt}/${MAX_RETRIES})...`), ); } }; return (resource: RequestInfo | URL, options: RequestInit | undefined) => { const url = resource instanceof URL ? resource.pathname : typeof resource === "string" ? new URL(resource, deploymentUrl) : new URL(resource.url, deploymentUrl); const func = throwingFetch(url, { retryDelay, retryOn: deploymentFetchRetryOn(onErrorWithAttempt, options?.method), ...options, }); return func; }; } /** * This returns a `fetch` function that will fetch against `deploymentUrl`. * * It will also set the `Authorization` header, `Content-Type` header, and * the `Convex-Client` header if they are not set in the `fetch`. */ export function deploymentFetch( _ctx: Context, options: { deploymentUrl: string; adminKey: string; onError?: (err: any) => void; }, ): typeof throwingFetch { const { deploymentUrl, adminKey, onError } = options; const onErrorWithAttempt = (err: any, attempt: number) => { onError?.(err); if (attempt >= RETRY_LOG_THRESHOLD) { logMessage( chalk.gray(`Retrying request (attempt ${attempt}/${MAX_RETRIES})...`), ); } }; return (resource: RequestInfo | URL, options: RequestInit | undefined) => { const url = resource instanceof URL ? resource.pathname : typeof resource === "string" ? new URL(resource, deploymentUrl) : new URL(resource.url, deploymentUrl); const headers = new Headers(options?.headers || {}); if (!headers.has("Authorization")) { headers.set("Authorization", `Convex ${adminKey}`); } if (!headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } if (!headers.has("Convex-Client")) { headers.set("Convex-Client", `npm-cli-${version}`); } const func = throwingFetch(url, { retryDelay, retryOn: deploymentFetchRetryOn(onErrorWithAttempt, options?.method), ...options, headers, }); return func; }; } /** * Whether this is likely to be a WebContainer, * WebContainers can't complete the WorkOS login but where that login flow * fails has changed with the environment. */ export function isWebContainer(): boolean { // Dynamic require as used here doesn't work with tsx if (process.env.CONVEX_RUNNING_LIVE_IN_MONOREPO) { return false; } const dynamicRequire = require; if (process.versions.webcontainer === undefined) { return false; } let blitzInternalEnv: unknown; try { blitzInternalEnv = dynamicRequire("@blitz/internal/env"); // totally fine for this require to fail // eslint-disable-next-line no-empty } catch {} return blitzInternalEnv !== null && blitzInternalEnv !== undefined; } // For (rare) special behaviors based on package.json details. export async function currentPackageHomepage( ctx: Context, ): Promise<string | null> { const { parentPackageJson: packageJsonPath } = await findParentConfigs(ctx); let packageJson: any; try { const packageJsonString = ctx.fs.readUtf8File(packageJsonPath); packageJson = JSON.parse(packageJsonString); } catch (error: any) { return await ctx.crash({ exitCode: 1, errorType: "invalid filesystem data", printedMessage: `Couldn't parse "${packageJsonPath}". Make sure it's a valid JSON. Error: ${error}`, }); } const name = packageJson["homepage"]; if (typeof name !== "string") { // wrong type or missing return null; } return name; }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/get-convex/convex-backend'

If you have feedback or need assistance with the MCP directory API, please join our Discord server