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

  • Automatic parameter collection - Request missing required fields from users
  • Type-safe - Full TypeScript support with schema validation
  • Flexible UI - Support for text, select, multiselect, and custom input types
  • Seamless integration - Works with existing @Tool decorators
  • Smart defaults - Automatically generates UI from schema constraints

Installation

npm install @leanmcp/elicitation

Peer Dependencies

npm install @leanmcp/core

Quick Start

1. Define Input Schema with UI Hints

import { SchemaConstraint, Optional } from "@leanmcp/core";
import { UIHint } from "@leanmcp/elicitation";

class SendEmailInput {
  @SchemaConstraint({
    description: 'Recipient email address',
    format: 'email'
  })
  @UIHint({ type: 'text', placeholder: '[email protected]' })
  to!: string;

  @SchemaConstraint({
    description: 'Email subject',
    minLength: 1
  })
  @UIHint({ type: 'text' })
  subject!: string;

  @SchemaConstraint({
    description: 'Email body',
    minLength: 1
  })
  @UIHint({ type: 'textarea', rows: 5 })
  body!: string;

  @Optional()
  @SchemaConstraint({
    description: 'Priority level',
    enum: ['low', 'normal', 'high'],
    default: 'normal'
  })
  @UIHint({ 
    type: 'select',
    options: [
      { label: 'Low', value: 'low' },
      { label: 'Normal', value: 'normal' },
      { label: 'High', value: 'high' }
    ]
  })
  priority?: string;
}

2. Add Elicitation to Your Tool

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

export class EmailService {
  @Elicitation()
  @Tool({ 
    description: 'Send an email',
    inputClass: SendEmailInput
  })
  async sendEmail(input: SendEmailInput) {
    // All required fields are guaranteed to be present
    console.log(`Sending email to ${input.to}`);
    console.log(`Subject: ${input.subject}`);
    console.log(`Priority: ${input.priority || 'normal'}`);
    
    return {
      success: true,
      messageId: 'msg-123'
    };
  }
}

3. How It Works

When a user calls the tool without all required parameters:
// User calls: sendEmail({ to: '[email protected]' })
// Missing: subject, body
The @Elicitation decorator automatically:
  1. Detects missing required fields (subject, body)
  2. Returns an elicitation request to the client
  3. Client displays UI to collect missing fields
  4. User provides the missing information
  5. Tool executes with complete parameters

@UIHint Decorator

The @UIHint decorator provides UI rendering hints for input fields.

Text Input

@UIHint({ 
  type: 'text',
  placeholder: 'Enter value...',
  label: 'Custom Label'
})
fieldName!: string;

Textarea

@UIHint({ 
  type: 'textarea',
  rows: 5,
  placeholder: 'Enter long text...'
})
description!: string;

Select (Single Choice)

@UIHint({ 
  type: 'select',
  options: [
    { label: 'Option 1', value: 'opt1' },
    { label: 'Option 2', value: 'opt2' }
  ]
})
choice!: string;

Multiselect (Multiple Choices)

@UIHint({ 
  type: 'multiselect',
  options: [
    { label: 'Tag 1', value: 'tag1' },
    { label: 'Tag 2', value: 'tag2' }
  ]
})
tags!: string[];

Number Input

@UIHint({ 
  type: 'number',
  min: 0,
  max: 100,
  step: 1
})
age!: number;

Date Input

@UIHint({ 
  type: 'date',
  min: '2024-01-01',
  max: '2024-12-31'
})
eventDate!: string;

UI Hint Options

Common Options

All UI hints support these common options:
interface BaseUIHint {
  type: 'text' | 'textarea' | 'select' | 'multiselect' | 'number' | 'date';
  label?: string;        // Custom label (defaults to field name)
  placeholder?: string;  // Placeholder text
  helpText?: string;     // Additional help text
}

Type-Specific Options

Text/Textarea

{
  type: 'text' | 'textarea',
  placeholder?: string,
  rows?: number,        // For textarea only
  maxLength?: number
}

Select/Multiselect

{
  type: 'select' | 'multiselect',
  options: Array<{
    label: string,
    value: string | number
  }>
}

Number

{
  type: 'number',
  min?: number,
  max?: number,
  step?: number
}

Date

{
  type: 'date',
  min?: string,  // ISO date string
  max?: string   // ISO date string
}

Elicitation Flow

1. Initial Tool Call (Missing Parameters)

{
  "method": "tools/call",
  "params": {
    "name": "sendEmail",
    "arguments": {
      "to": "[email protected]"
    }
  }
}

2. Elicitation Response

{
  "type": "elicitation",
  "fields": [
    {
      "name": "subject",
      "description": "Email subject",
      "required": true,
      "schema": { "type": "string", "minLength": 1 },
      "uiHint": { "type": "text" }
    },
    {
      "name": "body",
      "description": "Email body",
      "required": true,
      "schema": { "type": "string", "minLength": 1 },
      "uiHint": { "type": "textarea", "rows": 5 }
    }
  ]
}

3. Client Displays UI

The client renders a form based on the elicitation response, collecting the missing fields.

4. Complete Tool Call

{
  "method": "tools/call",
  "params": {
    "name": "sendEmail",
    "arguments": {
      "to": "[email protected]",
      "subject": "Hello",
      "body": "This is the email body"
    }
  }
}

5. Tool Execution

The tool now executes with all required parameters.

Advanced Examples

Complex Form with Validation

class CreateUserInput {
  @SchemaConstraint({
    description: 'User email',
    format: 'email'
  })
  @UIHint({ 
    type: 'text',
    placeholder: '[email protected]',
    helpText: 'Must be a valid email address'
  })
  email!: string;

  @SchemaConstraint({
    description: 'User age',
    minimum: 18,
    maximum: 120
  })
  @UIHint({ 
    type: 'number',
    min: 18,
    max: 120,
    helpText: 'Must be 18 or older'
  })
  age!: number;

  @SchemaConstraint({
    description: 'User role',
    enum: ['admin', 'user', 'guest']
  })
  @UIHint({ 
    type: 'select',
    options: [
      { label: 'Administrator', value: 'admin' },
      { label: 'Regular User', value: 'user' },
      { label: 'Guest', value: 'guest' }
    ]
  })
  role!: string;

  @Optional()
  @SchemaConstraint({
    description: 'User interests',
    items: { type: 'string' }
  })
  @UIHint({ 
    type: 'multiselect',
    options: [
      { label: 'Technology', value: 'tech' },
      { label: 'Sports', value: 'sports' },
      { label: 'Music', value: 'music' }
    ]
  })
  interests?: string[];
}

export class UserService {
  @Elicitation()
  @Tool({ 
    description: 'Create a new user',
    inputClass: CreateUserInput
  })
  async createUser(input: CreateUserInput) {
    return {
      id: 'user-123',
      email: input.email,
      age: input.age,
      role: input.role,
      interests: input.interests || []
    };
  }
}

Conditional Fields

class BookingInput {
  @SchemaConstraint({
    description: 'Booking type',
    enum: ['hotel', 'flight', 'car']
  })
  @UIHint({ 
    type: 'select',
    options: [
      { label: 'Hotel', value: 'hotel' },
      { label: 'Flight', value: 'flight' },
      { label: 'Car Rental', value: 'car' }
    ]
  })
  type!: string;

  @SchemaConstraint({ description: 'Check-in date' })
  @UIHint({ type: 'date' })
  startDate!: string;

  @SchemaConstraint({ description: 'Check-out date' })
  @UIHint({ type: 'date' })
  endDate!: string;

  // Conditional field - only for hotel bookings
  @Optional()
  @SchemaConstraint({ description: 'Number of guests' })
  @UIHint({ 
    type: 'number',
    min: 1,
    max: 10,
    helpText: 'Required for hotel bookings'
  })
  guests?: number;
}

With Authentication

import { Authenticated, AuthContext } from "@leanmcp/auth";

class SendMessageInput {
  @SchemaConstraint({ description: 'Recipient user ID' })
  @UIHint({ type: 'text' })
  recipientId!: string;

  @SchemaConstraint({ description: 'Message content' })
  @UIHint({ type: 'textarea', rows: 3 })
  message!: string;
}

export class MessageService {
  @Authenticated()
  @Elicitation()
  @Tool({ 
    description: 'Send a message',
    inputClass: SendMessageInput
  })
  async sendMessage(input: SendMessageInput, authContext: AuthContext) {
    return {
      success: true,
      from: authContext.userId,
      to: input.recipientId,
      message: input.message
    };
  }
}

Best Practices

Use descriptive description fields in @SchemaConstraint to help users understand what each field is for.
@SchemaConstraint({
  description: 'The email address where the invoice will be sent'
})
Choose the right UI hint type for each field:
  • text for short strings
  • textarea for long text
  • select for single choice from predefined options
  • multiselect for multiple choices
  • number for numeric input
  • date for date selection
Provide additional context with helpText in UI hints.
@UIHint({ 
  type: 'text',
  helpText: 'Use your company email address'
})
Use @Optional() with default values for non-critical fields.
@Optional()
@SchemaConstraint({
  description: 'Notification preference',
  enum: ['email', 'sms', 'none'],
  default: 'email'
})
Use schema constraints to validate input:
  • minLength, maxLength for strings
  • minimum, maximum for numbers
  • format for specific formats (email, url, etc.)
  • enum for predefined values

Client Implementation

Clients need to handle elicitation responses and display appropriate UI. Here’s a basic example:
async function callTool(name: string, args: any) {
  const response = await fetch('/mcp', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      method: 'tools/call',
      params: { name, arguments: args }
    })
  });

  const result = await response.json();

  if (result.type === 'elicitation') {
    // Display form to collect missing fields
    const missingFields = await showElicitationForm(result.fields);
    
    // Retry with complete arguments
    return callTool(name, { ...args, ...missingFields });
  }

  return result;
}