Skip to main content

@leanmcp/elicitation

Structured user input collection for LeanMCP tools using the MCP elicitation protocol. The @Elicitation decorator automatically intercepts tool calls to request missing required parameters from users.

Features

@Elicitation Decorator

Automatically collect missing user inputs before tool execution

Fluent Builder API

Programmatic form creation with ElicitationFormBuilder

Multiple Strategies

Form and multi-step elicitation strategies

Built-in Validation

min/max, pattern matching, custom validators

Installation

npm install @leanmcp/elicitation @leanmcp/core

Quick Start

Simple Form Elicitation

import { Tool } from "@leanmcp/core";
import { Elicitation } from "@leanmcp/elicitation";

class SlackService {
  @Tool({ description: "Create a new Slack channel" })
  @Elicitation({
    title: "Create Channel",
    description: "Please provide channel details",
    fields: [
      {
        name: "channelName",
        label: "Channel Name",
        type: "text",
        required: true,
        validation: {
          pattern: "^[a-z0-9-]+$",
          errorMessage: "Must be lowercase alphanumeric with hyphens"
        }
      },
      {
        name: "isPrivate",
        label: "Private Channel",
        type: "boolean",
        defaultValue: false
      }
    ]
  })
  async createChannel(args: { channelName: string; isPrivate: boolean }) {
    return { success: true, channelName: args.channelName };
  }
}

How It Works

  1. Client calls tool with missing required fields
  2. Decorator intercepts and checks for missing fields
  3. Elicitation request returned with form definition
  4. Client displays form to collect user input
  5. Client calls tool again with complete arguments
  6. Method executes normally

Fluent Builder API

For more complex forms, use ElicitationFormBuilder:
import { Tool } from "@leanmcp/core";
import { Elicitation, ElicitationFormBuilder, validation } from "@leanmcp/elicitation";

class UserService {
  @Tool({ description: "Create user account" })
  @Elicitation({
    builder: () => new ElicitationFormBuilder()
      .title("User Registration")
      .description("Create a new user account")
      .addEmailField("email", "Email Address", { required: true })
      .addTextField("username", "Username", {
        required: true,
        validation: validation()
          .minLength(3)
          .maxLength(20)
          .pattern("^[a-zA-Z0-9_]+$")
          .build()
      })
      .addSelectField("role", "Role", [
        { label: "Admin", value: "admin" },
        { label: "User", value: "user" }
      ])
      .build()
  })
  async createUser(args: any) {
    return { success: true, email: args.email };
  }
}

Builder Methods

MethodDescription
title(string)Set form title
description(string)Set form description
condition(fn)Set condition for elicitation
addTextField(name, label, opts?)Add text input
addTextAreaField(name, label, opts?)Add textarea
addNumberField(name, label, opts?)Add number input
addBooleanField(name, label, opts?)Add checkbox
addSelectField(name, label, options, opts?)Add dropdown
addMultiSelectField(name, label, options, opts?)Add multi-select
addEmailField(name, label, opts?)Add email input
addUrlField(name, label, opts?)Add URL input
addDateField(name, label, opts?)Add date picker
addCustomField(field)Add custom field
build()Build final config

Conditional Elicitation

Only ask for inputs when needed:
@Tool({ description: "Send message to Slack" })
@Elicitation({
  condition: (args) => !args.channelId,
  title: "Select Channel",
  fields: [
    {
      name: "channelId",
      label: "Channel",
      type: "select",
      required: true,
      options: [
        { label: "#general", value: "C12345" },
        { label: "#random", value: "C67890" }
      ]
    }
  ]
})
async sendMessage(args: { channelId?: string; message: string }) {
  // Only elicits if channelId is missing
}

Multi-Step Elicitation

Break input collection into sequential steps:
@Tool({ description: "Deploy application" })
@Elicitation({
  strategy: "multi-step",
  builder: () => [
    {
      title: "Step 1: Environment",
      fields: [
        {
          name: "environment",
          label: "Environment",
          type: "select",
          required: true,
          options: [
            { label: "Production", value: "prod" },
            { label: "Staging", value: "staging" }
          ]
        }
      ]
    },
    {
      title: "Step 2: Configuration",
      fields: [
        {
          name: "replicas",
          label: "Replicas",
          type: "number",
          defaultValue: 3
        }
      ],
      condition: (prev) => prev.environment === "prod"
    }
  ]
})
async deployApp(args: any) {
  // Implementation
}

Field Types

TypeDescription
textSingle-line text input
textareaMulti-line text area
numberNumeric input
booleanCheckbox
selectDropdown (single choice)
multiselectMulti-select
emailEmail input
urlURL input
dateDate picker

Validation

Built-in Validation

{
  name: "username",
  label: "Username",
  type: "text",
  validation: {
    minLength: 3,
    maxLength: 20,
    pattern: "^[a-zA-Z0-9_]+$",
    errorMessage: "Username must be 3-20 alphanumeric characters"
  }
}

Using ValidationBuilder

import { validation } from "@leanmcp/elicitation";

validation()
  .minLength(8)
  .maxLength(100)
  .pattern("^[a-zA-Z0-9]+$")
  .customValidator((value) => value !== "admin")
  .errorMessage("Invalid input")
  .build()

Elicitation Flow

Request/Response Cycle

First Call (Missing Fields):
// Request
{
  "method": "tools/call",
  "params": {
    "name": "createChannel",
    "arguments": {}
  }
}

// Response (Elicitation Request)
{
  "type": "elicitation",
  "title": "Create Channel",
  "fields": [
    {
      "name": "channelName",
      "label": "Channel Name",
      "type": "text",
      "required": true
    }
  ]
}
Second Call (Complete Fields):
// Request
{
  "method": "tools/call",
  "params": {
    "name": "createChannel",
    "arguments": {
      "channelName": "my-channel",
      "isPrivate": false
    }
  }
}

// Response (Tool Result)
{
  "content": [{"type": "text", "text": "{\"success\": true}"}]
}

API Reference

ElicitationConfig

interface ElicitationConfig {
  strategy?: 'form' | 'multi-step';
  title?: string;
  description?: string;
  fields?: ElicitationField[];
  condition?: (args: any) => boolean;
  builder?: (context: ElicitationContext) => ElicitationRequest | ElicitationStep[];
}

ElicitationField

interface ElicitationField {
  name: string;
  label: string;
  type: 'text' | 'number' | 'boolean' | 'select' | 'multiselect' | 'date' | 'email' | 'url' | 'textarea';
  description?: string;
  required?: boolean;
  defaultValue?: any;
  options?: Array<{ label: string; value: any }>;
  validation?: FieldValidation;
  placeholder?: string;
  helpText?: string;
}

FieldValidation

interface FieldValidation {
  min?: number;
  max?: number;
  minLength?: number;
  maxLength?: number;
  pattern?: string;
  customValidator?: (value: any) => boolean | string;
  errorMessage?: string;
}

Best Practices

Only ask when truly needed using the condition option:
@Elicitation({
  condition: (args) => !args.channelId,
  // ...
})
Reduce user input burden with defaultValue:
{
  name: "priority",
  type: "select",
  defaultValue: "normal",
  options: [...]
}
The fluent API is more maintainable:
builder: () => new ElicitationFormBuilder()
  .addTextField("name", "Name", { required: true })
  .addSelectField("role", "Role", [...])
  .build()
Use helpText and placeholder to guide users:
{
  name: "email",
  type: "email",
  placeholder: "[email protected]",
  helpText: "We'll send confirmation here"
}