Skip to main content
Glama

Convex MCP server

Official
by get-convex
FunctionTester.tsx24.2 kB
import { EnterFullScreenIcon, ExitFullScreenIcon, QuestionMarkCircledIcon, ViewHorizontalIcon, ViewVerticalIcon, } from "@radix-ui/react-icons"; import classNames from "classnames"; import { UserIdentityAttributes } from "convex/browser"; import { ConvexReactClient } from "convex/react"; import { ValidatorJSON, Value } from "convex/values"; import isEqual from "lodash/isEqual"; import Link from "next/link"; import { useCallback, useContext, useState } from "react"; import { useDebounce } from "react-use"; import { ZodError, z } from "zod"; import { generateErrorMessage } from "zod-error"; import { UNDEFINED_PLACEHOLDER } from "system-udfs/convex/_system/frontend/lib/values"; import { itemIdentifier, useModuleFunctions, } from "@common/lib/functions/FunctionsProvider"; import { Button } from "@ui/Button"; import { Tooltip } from "@ui/Tooltip"; import { ClosePanelButton } from "@ui/ClosePanelButton"; import { Combobox, Option } from "@ui/Combobox"; import { FunctionNameOption } from "@common/elements/FunctionNameOption"; import { displayName, functionIdentifierFromValue, functionIdentifierValue, } from "@common/lib/functions/generateFileTree"; import { ModuleFunction } from "@common/lib/functions/types"; import { defaultValueForValidator } from "@common/lib/defaultValueForValidator"; import { ObjectEditor } from "@common/elements/ObjectEditor/ObjectEditor"; import { NENT_APP_PLACEHOLDER, useNents } from "@common/lib/useNents"; import { NentNameOption } from "@common/elements/NentSwitcher"; import { CustomQuery, findFirstWritingFunction, findFunction, useGlobalRunnerSelectedItem, useHideGlobalRunner, useIsGlobalRunnerShown, } from "@common/features/functionRunner/lib/functionRunner"; import { useFunctionEditor } from "@common/features/functionRunner/components/FunctionEditor"; import { useFunctionResult } from "@common/features/functionRunner/components/FunctionResult"; import { QueryResult } from "@common/features/functionRunner/components/QueryResult"; import { RunHistory, RunHistoryItem, useImpersonatedUser, useIsImpersonating, } from "@common/features/functionRunner/components/RunHistory"; import { useGlobalReactClient } from "@common/features/functionRunner/lib/client"; import { DeploymentInfoContext } from "@common/lib/deploymentContext"; const CUSTOM_TEST_QUERY_PLACEHOLDER = "__CONVEX_PLACEHOLDER_custom_test_query_1255035852"; const impersonatedUserSchema = z.object({ subject: z.string(), issuer: z.string(), name: z.string().optional(), givenName: z.string().optional(), familyName: z.string().optional(), nickname: z.string().optional(), preferredUsername: z.string().optional(), profileUrl: z.string().optional(), pictureUrl: z.string().optional(), email: z.string().optional(), emailVerified: z.boolean().optional(), gender: z.string().optional(), birthday: z.string().optional(), timezone: z.string().optional(), language: z.string().optional(), phoneNumber: z.string().optional(), phoneNumberVerified: z.boolean().optional(), address: z.string().optional(), updatedAt: z.string().optional(), customClaims: z.record(z.any()).optional(), }); const SUPPORTED_FUNCTION_TYPES = new Set(["Query", "Mutation", "Action"]); export function GlobalFunctionTester({ isVertical, setIsVertical, isExpanded, setIsExpanded, }: { isVertical: boolean; setIsVertical: (v: boolean) => void; isExpanded: boolean; setIsExpanded: (v: boolean) => void; }) { const isShowing = useIsGlobalRunnerShown(); const hideGlobalRunner = useHideGlobalRunner(); const [selectedItem, setSelectedItem] = useGlobalRunnerSelectedItem(); const { nents } = useNents(); const moduleFunctions = useModuleFunctions(); let options: Option<string>[] = moduleFunctions .filter( (value) => value.componentId === selectedItem?.componentId && SUPPORTED_FUNCTION_TYPES.has(value.udfType), ) .map((value) => ({ // Since you choose the component in a separate combobox, we don't need // to show the component tooltip. label: functionIdentifierValue(value.identifier), value: itemIdentifier(value), })); const customTestQueryOption: Option<string> = { label: functionIdentifierValue("Custom test query"), value: functionIdentifierValue( CUSTOM_TEST_QUERY_PLACEHOLDER, undefined, selectedItem?.componentId ?? undefined, ), }; options = [customTestQueryOption, ...options]; const selectedFunction = selectedItem?.fn.type !== "customQuery" ? ((selectedItem && // Get the most up to date version of this module. Important if the udf type changes. findFunction( moduleFunctions, selectedItem.fn.identifier, selectedItem.componentId, )) ?? null) : null; const argsValidator = selectedFunction?.args && JSON.parse(selectedFunction.args); const initialArgs = argsValidator ? (defaultValueForValidator(argsValidator) as Record<string, Value>) : undefined; const [runHistoryItem, setRunHistoryItem] = useState<RunHistoryItem>(); const { args, result, button } = useFunctionTester({ moduleFunction: selectedFunction, initialArgs, argsValidator, runHistoryItem, setRunHistoryItem, }); const { queryEditor, customQueryResult, runCustomQueryButton } = useFunctionEditor( selectedItem?.fn.type === "customQuery" ? selectedItem.fn.table : null, selectedItem?.componentId ?? null, runHistoryItem, setRunHistoryItem, ); const { useLogDeploymentEvent } = useContext(DeploymentInfoContext); const log = useLogDeploymentEvent(); const logInfo = { function: selectedFunction !== null && { udfType: selectedFunction.udfType, visibility: selectedFunction?.visibility, identifier: selectedFunction.identifier, }, customQuery: selectedItem?.fn.type === "customQuery", }; return ( <aside key={isVertical.toString() + isExpanded.toString()} className={classNames( "bg-background-secondary shadow-sm", "gap-6 z-30 relative overflow-auto", isExpanded ? "h-full w-full border-l" : isVertical ? "border-l flex-col h-full grow min-w-[32rem] max-w-[32rem] " : "min-h-[24rem] max-h-[24rem] border-t shrink", isShowing ? "flex" : "hidden", )} > <div className={classNames("absolute right-4 z-40 flex gap-1", "top-1.5")} > <Button size="xs" onClick={() => { log( `${isExpanded ? "collapse" : "expand"} function runner`, logInfo, ); setIsExpanded(!isExpanded); }} inline className="bg-background-primary/50 backdrop-blur-[2px]" variant="neutral" icon={isExpanded ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />} tip={isExpanded ? "Collapse" : "Expand"} tipSide={isExpanded || isVertical ? "bottom" : "top"} /> <Button key={isVertical.toString()} tipSide={isVertical ? "bottom" : "top"} tip={`Align ${isVertical ? "horizontally" : "vertically"}`} size="xs" onClick={() => setIsVertical(!isVertical)} inline className="bg-background-primary/50 backdrop-blur-[2px]" variant="neutral" icon={isVertical ? <ViewHorizontalIcon /> : <ViewVerticalIcon />} /> <Tooltip key={`close-${isVertical.toString()}`} side={isVertical ? "bottom" : "top"} tip="Close panel" wrapsButton > <ClosePanelButton onClose={() => hideGlobalRunner("click")} className="bg-background-primary/50 backdrop-blur-[2px]" /> </Tooltip> </div> <div className="flex h-full w-full flex-col"> <div className={classNames( "flex h-full w-full items-start", isVertical && "flex-col", )} > <div className={classNames( "flex flex-col gap-2 w-full", !isExpanded && "max-w-[40rem]", !isVertical && "border-r h-full", )} > <div className="sticky top-0 z-10 flex w-full items-center gap-4 border-y bg-background-primary px-4 py-2"> <h4 className="text-xs whitespace-nowrap text-content-secondary"> Function Input </h4> </div> <div className={classNames( "flex w-full flex-col gap-2 px-4 mt-4 min-w-[24rem]", isExpanded && "max-w-[32rem]", )} > {nents && nents.length > 1 && ( <Combobox buttonProps={{ tip: "Switch between components installed in this deployment.", tipSide: "right", }} label="Select component" className="w-full" buttonClasses="w-full" optionsWidth="full" selectedOption={nents.find( (nent) => nent.id === selectedItem?.componentId, )} unknownLabel={(nent) => nent ? `Deleted component: ${nent.path}` : `No component selected` } Option={NentNameOption} setSelectedOption={(component) => { const customQuery: CustomQuery = { type: "customQuery", table: null, }; void setSelectedItem({ componentId: component?.id ?? null, fn: selectedItem?.fn.type === "customQuery" ? customQuery : findFirstWritingFunction( moduleFunctions, component?.id ?? null, ) || customQuery, }); }} searchPlaceholder="Search components..." options={[ { label: NENT_APP_PLACEHOLDER, value: undefined, }, ...nents .filter((n) => n.name !== null) .map((nent) => ({ label: nent.path, value: nent, })), ]} /> )} <div className="flex w-full items-center gap-4"> <Combobox buttonProps={{ tip: "Select a function to run.", tipSide: "right", }} label="Select function" unknownLabel={(f) => `Deleted function: ${displayName(functionIdentifierFromValue(f).identifier)}` } className="w-full" buttonClasses="w-full" optionsWidth="full" searchPlaceholder="Search functions..." selectedOption={ selectedItem?.fn.type === "customQuery" ? functionIdentifierValue( CUSTOM_TEST_QUERY_PLACEHOLDER, undefined, selectedItem.componentId ?? undefined, ) : selectedItem ? itemIdentifier(selectedItem.fn) : null } setSelectedOption={(option) => { if (option === null) { return; } const { identifier, componentId } = functionIdentifierFromValue(option); if (identifier === CUSTOM_TEST_QUERY_PLACEHOLDER) { setSelectedItem({ componentId: componentId ?? null, fn: { type: "customQuery", table: null, }, }); return; } const fn = findFunction( moduleFunctions, identifier, componentId ?? null, ); if (fn !== undefined) { setSelectedItem({ componentId: componentId ?? null, fn }); } }} options={options} Option={(props) => ( <FunctionNameOption {...{ ...props, disableTruncation: true }} /> )} processFilterOption={(option) => { const id = functionIdentifierFromValue(option); return id.componentPath ? `${id.componentPath}/${id.identifier}` : id.identifier; }} /> </div> </div> {!isVertical && ( <> {selectedItem?.fn.type === "customQuery" ? ( <div className="mb-2 flex grow flex-col gap-4 px-4 pt-2"> {queryEditor} {runCustomQueryButton} </div> ) : selectedItem !== null ? ( <div className="mb-2 flex h-full flex-col gap-2 overflow-y-auto"> {args} {button} </div> ) : null} </> )} </div> {isVertical && ( <> {selectedItem?.fn.type === "customQuery" ? ( <div className="flex h-full w-full flex-col gap-4 px-4 pt-4 pb-6"> {queryEditor} {runCustomQueryButton} </div> ) : selectedItem !== null ? ( <div className="flex h-fit w-full flex-col gap-2 pt-4 pb-6"> {args} {button} </div> ) : null} </> )} <div className={classNames( "w-full h-full overflow-y-auto scrollbar max-w-full", )} > {selectedItem?.fn.type === "customQuery" ? ( <div className="flex h-full">{customQueryResult}</div> ) : selectedItem !== null ? ( <div className="flex h-full">{result}</div> ) : null} </div> </div> </div> </aside> ); } // This is a hook because we want to return composable components that can be arranged // vertically or horizontally. export function useFunctionTester({ moduleFunction, initialArgs, argsValidator, impersonation = true, runHistoryItem, setRunHistoryItem, }: { moduleFunction: ModuleFunction | null; initialArgs?: Record<string, Value>; argsValidator?: ValidatorJSON; impersonation?: boolean; runHistoryItem?: RunHistoryItem; setRunHistoryItem?: (item?: RunHistoryItem) => void; }) { const [parameters, setParameters] = useState<Record<string, Value>>( initialArgs || {}, ); const [prevDefaults, setPrevDefaults] = useState([ moduleFunction?.identifier, initialArgs, ]); // Reset the parameters when the function changes if (!isEqual([moduleFunction?.identifier, initialArgs], prevDefaults)) { setPrevDefaults([moduleFunction?.identifier, initialArgs]); setParameters(initialArgs || {}); } const [hasError, setHasError] = useState(false); const [isInvalidObject, setIsInvalidObject] = useState(false); const onFirstParameterError = useCallback((errors: string[]) => { setHasError(!!errors.length); }, []); const [isImpersonating, setIsImpersonating] = useIsImpersonating(); const [impersonatedUser, setImpersonatedUser] = useImpersonatedUser(); const [impersonatedUserDebounced, setImpersonatedUserDebounced] = useState<UserIdentityAttributes>(); const [impersonatedUserError, setImpersonatedUserError] = useState<string>(); useDebounce(() => setImpersonatedUserDebounced(impersonatedUser), 200, [ impersonatedUser, ]); const [reactClient] = useGlobalReactClient( isImpersonating ? impersonatedUserDebounced : undefined, ); const onImpersonatedUserChange = useCallback( (v: Value) => { try { if (v === UNDEFINED_PLACEHOLDER) { setIsImpersonating(false); return; } const user = impersonatedUserSchema.parse(v); const { customClaims, ...rootClaims } = user; const flattenedUser = { ...rootClaims, ...(customClaims || {}), }; setImpersonatedUser(flattenedUser); setImpersonatedUserError(undefined); } catch (e: any) { if (e instanceof ZodError) { setImpersonatedUserError( generateErrorMessage(e.issues, { delimiter: { error: " ", component: ", ", }, }), ); } } }, [setImpersonatedUser, setIsImpersonating], ); const onImpersonatedUserError = useCallback((errors: string[]) => { setImpersonatedUserError( errors.length ? "This user object is invalid. Fix the errors above to continue." : undefined, ); }, []); const onChange = useCallback( (v: Value) => { if (v && Object.getPrototypeOf(v) === Object.prototype) { // Equals check to prevent infinite effect loops. if (!isEqual(v, parameters)) { // This cast is ok because of the protoype check above. setParameters(v as Record<string, Value>); } setIsInvalidObject(false); } else { setIsInvalidObject(true); } }, [parameters], ); const disabled = hasError || isInvalidObject || !!impersonatedUserError; const client = (reactClient as ConvexReactClient)?.sync; const onSubmit = useCallback(() => { if (!moduleFunction || !client) { return { requestFilter: null, runFunctionPromise: null }; } const requestFilter = { clientRequestCounter: client.nextRequestId, sessionId: client.sessionId, }; const { componentPath } = moduleFunction; const runFunctionPromise = moduleFunction.udfType === "Action" ? client.actionInternal( moduleFunction.displayName, parameters, componentPath ?? undefined, ) : client.mutationInternal( moduleFunction.displayName, parameters, {}, componentPath ?? undefined, ); return { requestFilter, runFunctionPromise, }; }, [client, moduleFunction, parameters]); const { button, result: functionResult } = useFunctionResult({ onSubmit, disabled, udfType: moduleFunction?.udfType, functionIdentifier: moduleFunction?.identifier, componentId: moduleFunction?.componentId || null, args: parameters, runHistoryItem, }); const queryResult = moduleFunction && reactClient && moduleFunction.udfType === "Query" && ( <QueryResult paused={disabled} module={moduleFunction} parameters={parameters} reactClient={reactClient} /> ); const { useLogDeploymentEvent } = useContext(DeploymentInfoContext); const log = useLogDeploymentEvent(); const args = ( <div className="scrollbar grid h-full gap-2 overflow-y-auto"> <div className="px-4"> <div className="flex max-w-[48rem] items-end justify-between"> <h5 className="text-xs text-content-secondary">Arguments</h5> {setRunHistoryItem && moduleFunction?.udfType !== "Query" && ( <RunHistory functionIdentifier={moduleFunction?.identifier || ""} componentId={moduleFunction?.componentId ?? null} selectItem={(item) => { (!item.type || item.type === "arguments") && onChange(item.arguments); setRunHistoryItem(item); }} /> )} </div> </div> <div className="relative min-h-28 grow overflow-y-auto px-4"> <ObjectEditor className="max-w-[48rem] animate-fadeInFromLoading" key={ (moduleFunction?.identifier || "") + (moduleFunction?.componentId || "") + (runHistoryItem?.startedAt.toString() || "") } fullHeight defaultValue={parameters} onChange={onChange} path={`arguments-${moduleFunction?.identifier}-${moduleFunction?.componentId}`} onError={onFirstParameterError} validator={argsValidator} shouldSurfaceValidatorErrors showTableNames mode="editDocument" /> </div> {impersonation && ( <div className="flex items-start gap-4 px-4"> <label htmlFor="actAsUser" className="flex h-9 items-center gap-2 pt-0.5 text-xs whitespace-nowrap accent-util-accent" > <input data-testid="actAsUser" id="actAsUser" type="checkbox" checked={isImpersonating} className="hover:cursor-pointer" onChange={() => { setIsImpersonating(!isImpersonating); setRunHistoryItem && setRunHistoryItem(undefined); log("toggle act as user", { actAsUser: isImpersonating, function: moduleFunction && { udfType: moduleFunction.udfType, visibility: moduleFunction.visibility, identifier: moduleFunction.identifier, }, }); }} /> <span className="flex gap-1 select-none"> Act as a user{" "} <Tooltip tip={ <> Run authenticated functions by acting as a user.{" "} <Link href="https://docs.convex.dev/dashboard/deployments/functions#assuming-a-user-identity" passHref className="underline" target="_blank" > Learn more </Link> . </> } > <QuestionMarkCircledIcon /> </Tooltip> </span> </label> <div className="flex max-h-[8rem] w-full flex-col gap-1"> {isImpersonating && ( <ObjectEditor key={runHistoryItem?.startedAt || ""} className="w-full max-w-[48rem] animate-fadeInFromLoading" defaultValue={ runHistoryItem?.type === "arguments" ? runHistoryItem.user : impersonatedUser } disableFind onChange={onImpersonatedUserChange} onError={onImpersonatedUserError} path={`userAuth${runHistoryItem?.startedAt || ""}`} mode="editField" /> )} {impersonatedUserError && ( <p className="mt-1 h-4 text-xs break-words text-content-errorSecondary" role="alert" > {impersonatedUserError} </p> )} </div> </div> )} </div> ); return { args, result: functionResult || queryResult, button, }; }

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