ElicitationForm

Answer MCP elicitation requests. Pass the server's requestedSchema and the form builds Mantine fields for strings (email, uri, date, date-time), numbers, booleans, single and multi-select enums, validates required fields and bounds, and calls onAccept with only the filled values. Use mode="url" when the server asks the user to open a link. Short fields go into two columns once the form itself is wider than 520px, so the same component fits a narrow widget and a full-page chat.

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
Form request
URL request
Disabled
Source

The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.

elicitation/ElicitationForm.tsx
import React, { memo, useMemo, useState } from 'react';
import {
  Button,
  Checkbox,
  Divider,
  Grid,
  Group,
  MultiSelect,
  NumberInput,
  Paper,
  Radio,
  Select,
  Stack,
  Switch,
  Text,
  TextInput,
} from '@mantine/core';
import { IconExternalLink, IconForms } from '@tabler/icons-react';
import { cx } from '../utils/cx';
import {
  buildElicitationContent,
  getElicitationDraft,
  getElicitationFields,
  getElicitationHost,
  validateElicitationField,
  type ElicitationContent,
  type ElicitationDraftValue,
  type ElicitationField,
  type ElicitationRequestedSchema,
} from './elicitation-schema';
import classes from './ElicitationForm.module.css';

export type ElicitationAction = 'accept' | 'decline' | 'cancel';

export interface ElicitationFormLabels {
  /** Submit button in `form` mode, `Submit` by default */
  submit: string;
  /** Accept button in `url` mode, `Open link` by default */
  openLink: string;
  /** Decline button, `Decline` by default */
  decline: string;
  /** Cancel button, `Cancel` by default */
  cancel: string;
  /** Status after the request was accepted, `Accepted` by default */
  accepted: string;
  /** Status after the request was declined, `Declined` by default */
  declined: string;
  /** Status after the request was canceled, `Canceled` by default */
  canceled: string;
}

export const DEFAULT_ELICITATION_FORM_LABELS: ElicitationFormLabels = {
  submit: 'Submit',
  openLink: 'Open link',
  decline: 'Decline',
  cancel: 'Cancel',
  accepted: 'Accepted',
  declined: 'Declined',
  canceled: 'Canceled',
};

export interface ElicitationFormProps {
  /** Message from the server explaining what is requested */
  message: string;
  /** Flat object schema of the requested fields, used in `form` mode */
  requestedSchema?: ElicitationRequestedSchema;
  /** `form` collects values, `url` asks to open an external link, `form` by default */
  mode?: 'form' | 'url';
  /** Link opened in `url` mode, only `http` and `https` links are shown */
  url?: string;
  /** Name of the MCP server that sent the request, shown in the header */
  serverName?: string;
  /** Called with the collected values, `{}` in `url` mode after the link is opened */
  onAccept?: (content: ElicitationContent) => void;
  /** Called when the user explicitly refuses to answer */
  onDecline?: () => void;
  /** Called when the user dismisses the request, the cancel button is rendered only when set */
  onCancel?: () => void;
  /** Header title when `serverName` is not set, `Input requested` by default */
  title?: string;
  /** Disables all fields and actions */
  disabled?: boolean;
  /** Overrides of the default English labels */
  labels?: Partial<ElicitationFormLabels>;
  /** Class name added to the root element */
  className?: string;
  /** Inline styles added to the root element */
  style?: React.CSSProperties;
}

const GRID_BREAKPOINTS = {
  xs: '360px',
  sm: '520px',
  md: '720px',
  lg: '900px',
  xl: '1200px',
};

function isCompactField(field: ElicitationField): boolean {
  if (field.kind === 'number') {
    return true;
  }
  if (field.kind === 'text') {
    return field.inputType !== 'text' || (field.schema.maxLength ?? Infinity) <= 120;
  }
  return field.kind === 'select' && field.options.length > 3;
}

const EMPTY_SCHEMA: ElicitationRequestedSchema = { type: 'object', properties: {} };

type FieldControlProps = {
  field: ElicitationField;
  value: ElicitationDraftValue | undefined;
  error?: string;
  disabled: boolean;
  onChange: (value: ElicitationDraftValue, validate?: boolean) => void;
  onBlur: () => void;
};

function FieldControl({ field, value, error, disabled, onChange, onBlur }: FieldControlProps) {
  const common = {
    label: field.label,
    description: field.description,
    error,
    disabled,
    withAsterisk: field.required,
  };

  switch (field.kind) {
    case 'text':
      return (
        <TextInput
          {...common}
          type={field.inputType}
          value={String(value ?? '')}
          maxLength={field.schema.maxLength}
          onChange={(event) => onChange(event.currentTarget.value)}
          onBlur={onBlur}
        />
      );
    case 'number':
      return (
        <NumberInput
          {...common}
          value={typeof value === 'number' || typeof value === 'string' ? value : ''}
          min={field.schema.minimum}
          max={field.schema.maximum}
          allowDecimal={field.schema.type !== 'integer'}
          onChange={(next) => onChange(typeof next === 'bigint' ? Number(next) : next)}
          onBlur={onBlur}
        />
      );
    case 'boolean':
      return (
        <Switch
          label={field.label}
          description={field.description}
          disabled={disabled}
          checked={Boolean(value)}
          onChange={(event) => onChange(event.currentTarget.checked)}
        />
      );
    case 'select':
      if (field.options.length <= 3) {
        return (
          <Radio.Group
            {...common}
            size="xs"
            value={typeof value === 'string' && value ? value : null}
            onChange={(next) => onChange(next, true)}
          >
            <Group gap="md" mt="xs">
              {field.options.map((option) => (
                <Radio
                  key={option.value}
                  value={option.value}
                  label={option.label}
                  disabled={disabled}
                />
              ))}
            </Group>
          </Radio.Group>
        );
      }
      return (
        <Select
          {...common}
          data={field.options}
          value={typeof value === 'string' && value ? value : null}
          onChange={(next) => onChange(next ?? '')}
          onBlur={onBlur}
          clearable={!field.required}
        />
      );
    case 'multiselect': {
      const selected = Array.isArray(value) ? value : [];
      if (field.options.length <= 6) {
        return (
          <Checkbox.Group
            {...common}
            size="xs"
            value={selected}
            onChange={(next) => onChange(next, true)}
          >
            <Stack gap="xs" mt="xs">
              {field.options.map((option) => (
                <Checkbox
                  key={option.value}
                  value={option.value}
                  label={option.label}
                  disabled={disabled}
                />
              ))}
            </Stack>
          </Checkbox.Group>
        );
      }
      return (
        <MultiSelect
          {...common}
          data={field.options}
          value={selected}
          maxValues={field.schema.maxItems}
          onChange={onChange}
          onBlur={onBlur}
        />
      );
    }
  }
}

/** MCP elicitation request: a schema-driven form or a link the server asks the user to open */
export const ElicitationForm = memo(function ElicitationForm({
  message,
  requestedSchema = EMPTY_SCHEMA,
  mode = 'form',
  url,
  serverName,
  onAccept,
  onDecline,
  onCancel,
  title = 'Input requested',
  disabled = false,
  labels: labelsProp,
  className,
  style,
}: ElicitationFormProps) {
  const labels = { ...DEFAULT_ELICITATION_FORM_LABELS, ...labelsProp };
  const decisionLabels: Record<ElicitationAction, string> = {
    accept: labels.accepted,
    decline: labels.declined,
    cancel: labels.canceled,
  };
  const fields = useMemo(() => getElicitationFields(requestedSchema), [requestedSchema]);
  const [draft, setDraft] = useState(() => getElicitationDraft(fields));
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [decision, setDecision] = useState<ElicitationAction | null>(null);

  const isLocked = disabled || decision !== null;
  const host = mode === 'url' ? getElicitationHost(url) : null;

  const setFieldError = (field: ElicitationField, value: ElicitationDraftValue | undefined) => {
    const error = validateElicitationField(field, value);
    setErrors((prev) => {
      if ((prev[field.name] ?? null) === error) {
        return prev;
      }
      const next = { ...prev };
      if (error) {
        next[field.name] = error;
      } else {
        delete next[field.name];
      }
      return next;
    });
  };

  const handleChange = (
    field: ElicitationField,
    value: ElicitationDraftValue,
    validate = false
  ) => {
    setDraft((prev) => ({ ...prev, [field.name]: value }));
    if (validate || errors[field.name]) {
      setFieldError(field, value);
    }
  };

  const handleSubmit = (event: React.FormEvent) => {
    event.preventDefault();
    if (isLocked || mode === 'url') {
      return;
    }
    const result = buildElicitationContent(fields, draft);
    setErrors(result.errors);
    if (Object.keys(result.errors).length > 0) {
      return;
    }
    setDecision('accept');
    onAccept?.(result.content);
  };

  const handleOpenLink = () => {
    if (isLocked || !url) {
      return;
    }
    setDecision('accept');
    onAccept?.({});
  };

  const handleDecline = () => {
    if (isLocked) {
      return;
    }
    setDecision('decline');
    onDecline?.();
  };

  const handleCancel = () => {
    if (isLocked) {
      return;
    }
    setDecision('cancel');
    onCancel?.();
  };

  return (
    <Paper
      component="form"
      noValidate
      withBorder
      radius="var(--ae-tool-radius)"
      onSubmit={handleSubmit}
      className={cx(classes.root, className)}
      style={style}
      data-decision={decision ?? undefined}
    >
      <Group gap={6} wrap="nowrap" px="sm" py={6} className={classes.bar}>
        {mode === 'url' ? (
          <IconExternalLink size={14} className={classes.icon} />
        ) : (
          <IconForms size={14} className={classes.icon} />
        )}
        <Text size="xs" fw={500} truncate>
          {serverName ?? title}
        </Text>
      </Group>
      <Divider />

      <Stack gap="xs" p="sm">
        <Text size="sm" className={classes.message}>
          {message}
        </Text>
        {mode === 'url' && host && (
          <Text size="xs" ff="monospace" c="dimmed">
            {host}
          </Text>
        )}
        {mode === 'form' && fields.length > 0 && (
          <Grid type="container" breakpoints={GRID_BREAKPOINTS} gap="sm" mt={4}>
            {fields.map((field) => (
              <Grid.Col key={field.name} span={isCompactField(field) ? { base: 12, sm: 6 } : 12}>
                <FieldControl
                  field={field}
                  value={draft[field.name]}
                  error={errors[field.name]}
                  disabled={isLocked}
                  onChange={(value, validate) => handleChange(field, value, validate)}
                  onBlur={() => setFieldError(field, draft[field.name])}
                />
              </Grid.Col>
            ))}
          </Grid>
        )}
      </Stack>

      <Divider />
      <Group
        justify="space-between"
        wrap="nowrap"
        gap="xs"
        pl="sm"
        pr={8}
        py={4}
        className={classes.bar}
      >
        <Text size="xs" c="dimmed">
          {decision ? decisionLabels[decision] : null}
        </Text>
        <Group gap={4} wrap="nowrap">
          {onCancel && (
            <Button
              size="compact-xs"
              variant="subtle"
              color="gray"
              onClick={handleCancel}
              disabled={isLocked}
            >
              {labels.cancel}
            </Button>
          )}
          <Button
            size="compact-xs"
            variant="subtle"
            color="gray"
            onClick={handleDecline}
            disabled={isLocked}
          >
            {labels.decline}
          </Button>
          {mode === 'url' ? (
            host &&
            url && (
              <Button
                component="a"
                href={url}
                target="_blank"
                rel="noopener noreferrer"
                size="compact-xs"
                disabled={isLocked}
                onClick={(event: React.MouseEvent) => {
                  if (isLocked) {
                    event.preventDefault();
                    return;
                  }
                  handleOpenLink();
                }}
              >
                {labels.openLink}
              </Button>
            )
          ) : (
            <Button type="submit" size="compact-xs" disabled={isLocked}>
              {labels.submit}
            </Button>
          )}
        </Group>
      </Group>
    </Paper>
  );
});

ElicitationForm.displayName = 'ElicitationForm';
API reference
Prop
Type
Required
message
string
Yes
Message from the server explaining what is requested
requestedSchema
ElicitationRequestedSchema
No
Flat object schema of the requested fields, used in `form` mode
mode
'form' | 'url'
No
`form` collects values, `url` asks to open an external link, `form` by default
url
string
No
Link opened in `url` mode, only `http` and `https` links are shown
serverName
string
No
Name of the MCP server that sent the request, shown in the header
onAccept
(content: ElicitationContent) => void
No
Called with the collected values, `{}` in `url` mode after the link is opened
onDecline
() => void
No
Called when the user explicitly refuses to answer
onCancel
() => void
No
Called when the user dismisses the request, the cancel button is rendered only when set
title
string
No
Header title when `serverName` is not set, `Input requested` by default
disabled
boolean
No
Disables all fields and actions
labels
Partial<ElicitationFormLabels>
No
Overrides of the default English labels
className
string
No
Class name added to the root element
style
React.CSSProperties
No
Inline styles added to the root element