Elicitation enables your MCP server tools to request additional information from users during execution. This creates interactive workflows where tools can dynamically gather structured data or direct users to external URLs for sensitive operations.
Overview
When a tool needs user input, it can use ctx.elicit() to send a request to the client. The client presents this request to the user and returns their response. This enables dynamic, interactive tool behavior that adapts based on user input.
Two Modes Available:
- Form Mode: Collect structured data with JSON schema validation (for non-sensitive information)
- URL Mode: Direct users to external URLs (MUST be used for sensitive data like credentials, OAuth)
Basic Usage
Use the elicit() method on the context object within tool callbacks. The API automatically detects the mode from your parameters:
import { createMCPServer, text } from 'mcp-use/server';
import { z } from 'zod';
const server = createMCPServer('my-server', {
version: '1.0.0',
});
server.tool({
name: 'collect-user-info',
description: 'Collect user information',
}, async (params, ctx) => {
// Simplified API: ctx.elicit(message, zodSchema)
// Mode is automatically inferred from the Zod schema
const result = await ctx.elicit(
'Please provide your contact details',
z.object({
name: z.string().default('Anonymous'),
email: z.string().email(),
})
);
// result.data is automatically typed as { name: string, email: string }
if (result.action === 'accept')
return text(`Thank you, ${result.data.name}! We'll contact you at ${result.data.email}`);
else if (result.action === 'decline')
return text('No information provided');
else
return text('Operation cancelled');
});
Form mode allows you to collect structured data from users with Zod schema validation. Use this for non-sensitive information only.
Simplified API
const result = await ctx.elicit(message, zodSchema);
// Mode is automatically inferred as "form" from the Zod schema parameter
// result.data is automatically typed based on the Zod schema
Zod Schema Support
Use Zod schemas to define the structure and validation rules for user input. Benefits:
- Type Safety: Return type is automatically inferred from your schema
- Server-Side Validation: Data is validated against the Zod schema before being returned (mcp-use feature)
- Validation: Built-in Zod validators (
.min(), .max(), .email(), etc.)
- Descriptions: Use
.describe() for field labels
- Defaults: Use
.default() for default values
- Optional fields: Use
.optional() for optional properties
Server-Side Validation
Automatic Validation with mcp-use: When using the mcp-use server libraryโs simplified API with Zod schemas (ctx.elicit(message, zodSchema)), the server automatically validates the returned data against your schema. This is a convenience feature provided by mcp-use. Invalid data is rejected with clear error messages before reaching your tool logic.
The validation flow:
- Schema Conversion: Zod schema is converted to JSON Schema and sent to the client
- User Input: Client collects data from the user
- Client Validation: Client validates against JSON Schema (optional)
- Data Return: Client returns data to server
- Server Validation: Server validates data against the original Zod schema
- Type Safety: Validated data is returned with correct TypeScript types
What Gets Validated
The server validates:
- Data Types: Strings, numbers, booleans must match schema
- Ranges: Min/max values for numbers
- String Constraints: MinLength, maxLength, patterns
- Formats: Email, URL, date formats
- Required Fields: Missing required fields are caught
- Enums: Values must be from allowed options
- Custom Validators: Any Zod refinements and transforms
Validation Error Handling
When validation fails, the error is caught and can be handled:
server.tool({
name: 'create-account',
description: 'Create account with validation'
}, async (params, ctx) => {
try {
const result = await ctx.elicit(
'Create your account',
z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
age: z.number().min(18).max(120),
})
);
if (result.action === 'accept') {
// Data is guaranteed to be valid here
return text(`Account created for ${result.data.username}`);
}
return text('Account creation cancelled');
} catch (error) {
// Validation error or other failure
return error(`Validation failed: ${error.message}`);
}
});
Validation Examples
Valid Data:
// User provides: { username: "john_doe", email: "john@example.com", age: 25 }
// โ
Passes validation - all fields valid
Invalid Age:
// User provides: { username: "john_doe", email: "john@example.com", age: 200 }
// โ Fails: "Too big: expected number to be <=120"
Invalid Email:
// User provides: { username: "john_doe", email: "not-an-email", age: 25 }
// โ Fails: "Invalid email address"
Missing Required Field:
// User provides: { username: "john_doe", age: 25 }
// โ Fails: "Invalid input: expected string, received undefined" (email missing)
Wrong Type:
// User provides: { username: "john_doe", email: "john@example.com", age: "twenty-five" }
// โ Fails: "Invalid input: expected number, received string"
Default Values
Fields with .default() are automatically filled if missing:
const result = await ctx.elicit(
'Enter preferences',
z.object({
theme: z.enum(['light', 'dark']).default('light'),
notifications: z.boolean().default(true),
})
);
// If user provides: {}
// result.data will be: { theme: 'light', notifications: true }
import { z } from 'zod';
import { text } from 'mcp-use/server';
server.tool({
name: 'register-user',
description: 'Register a new user',
}, async (params, ctx) => {
// Simplified API with Zod schema
const result = await ctx.elicit(
'Please complete your registration',
z.object({
name: z.string().min(2).describe('Your full name'),
email: z.string().email().describe('Your email address'),
age: z.number().min(18).max(120).describe('Your age'),
newsletter: z.boolean().default(false).describe('Subscribe to newsletter'),
})
);
// result.data is typed as:
// { name: string, email: string, age: number, newsletter: boolean }
if (result.action === 'accept') {
const user = result.data;
// TypeScript knows the exact shape of user data
return text(`Welcome, ${user.name}! Registration successful.`);
}
return text('Registration cancelled');
});
Example: With Enums and Arrays
import { z } from 'zod';
import { text, error } from 'mcp-use/server';
server.tool({
name: 'create-task',
description: 'Create a task with user input',
}, async (params, ctx) => {
const result = await ctx.elicit(
'Enter task details',
z.object({
title: z.string().min(1).describe('Task title'),
priority: z
.enum(['low', 'medium', 'high', 'urgent'])
.default('medium')
.describe('Priority level'),
tags: z
.array(z.enum(['bug', 'feature', 'docs', 'refactor']))
.optional()
.describe('Task tags'),
})
);
// result.data is typed as:
// { title: string, priority: 'low' | 'medium' | 'high' | 'urgent', tags?: ('bug' | 'feature' | 'docs' | 'refactor')[] }
if (result.action === 'accept') {
const task = result.data;
return text(`Created ${task.priority} priority task: ${task.title}`);
}
return text('Task creation cancelled');
});
URL Mode - External Navigation
URL mode directs users to external URLs for sensitive operations. This mode MUST be used for:
- Authentication credentials
- API keys and tokens
- OAuth authorization flows
- Payment information
- Any sensitive personal data
Security Requirement: Never use form mode for sensitive data. URL mode ensures credentials and sensitive information do not pass through the MCP client, maintaining proper security boundaries per the MCP specification.
Simplified API
const result = await ctx.elicit(message, urlString);
// Mode is automatically inferred as "url" from the URL string parameter
// elicitationId is automatically generated
Example: OAuth Authorization
server.tool({
name: 'connect-github',
description: 'Authorize GitHub access',
}, async (params, ctx) => {
// Generate OAuth URL (in a real app, this would come from your OAuth provider)
const authUrl = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&state=${generateState()}`;
// Simplified API: ctx.elicit(message, url)
// Mode and elicitationId are handled automatically
const result = await ctx.elicit(
'Please authorize GitHub access to continue',
authUrl
);
if (result.action === 'accept') {
// User completed authorization
// In a real implementation, you would:
// 1. Receive the callback with authorization code
// 2. Exchange code for access token
// 3. Store tokens securely on the server
return text('โ
GitHub authorization successful! You can now use GitHub tools.');
} else if (result.action === 'decline') {
return error('โ GitHub authorization declined. Some features will be unavailable.');
} else {
return error('Authorization cancelled');
}
});
Example: Service Connection
import { text } from 'mcp-use/server';
server.tool({
name: 'setup-api-credentials',
description: 'Set up API credentials for a service',
}, async ({ serviceName }, ctx) => {
// Generate a secure credential collection URL
const credentialUrl = `https://your-server.com/setup/${serviceName}?session=${generateSecureToken()}`;
// Simplified URL mode API
const result = await ctx.elicit(
`Configure your ${serviceName} API credentials securely`,
credentialUrl
);
if (result.action === 'accept') {
return text(`โ
${serviceName} credentials configured successfully`);
}
return text(`${serviceName} setup not completed`);
});
Response Handling
The elicit() method returns an ElicitResult object:
interface ElicitResult {
action: 'accept' | 'decline' | 'cancel';
data?: any; // Only present for 'accept' action in form mode
}
Response Actions
| Action | Description | When to Use |
|---|
| accept | User provided valid input or completed the action | Check result.data for form mode input |
| decline | User explicitly declined the request | User chose not to provide information |
| cancel | User cancelled without making a choice | User dismissed or closed the request |
Handling All Response Types
Always handle all three response actions appropriately:
server.tool({
name: 'example-tool',
description: 'Example showing response handling',
}, async (params, ctx) => {
const result = await ctx.elicit('Do you want to proceed?', z.object({ confirm: z.boolean().default(false) }));
// Handle all three cases
switch (result.action) {
case 'accept':
// User provided data
if (result.data.confirm) {
return text('โ
Proceeding with action');
}
return text('โ Action not confirmed');
case 'decline':
// User explicitly declined
return text('๐ซ User declined to provide confirmation');
case 'cancel':
// User cancelled
return text('โ ๏ธ Operation cancelled by user');
default:
// Should never happen, but good practice
return error('โ Unknown response');
}
});
Timeout Configuration
By default, elicit() waits indefinitely for user response (no timeout). You can optionally specify a timeout:
import { z } from 'zod';
server.tool({
name: 'time-sensitive-input',
description: 'Collect input with timeout',
}, async (params, ctx) => {
try {
// Wait indefinitely (default)
const result1 = await ctx.elicit(
'Please provide information',
z.object({ data: z.string() })
);
// With 60-second timeout
const result2 = await ctx.elicit(
'Quick! Provide information',
z.object({ data: z.string() }),
{ timeout: 60000 } // 1 minute timeout
);
// URL mode with timeout
const result3 = await ctx.elicit(
'Authorize (2 minute limit)',
'https://example.com/oauth',
{ timeout: 120000 } // 2 minute timeout
);
if (result2.action === 'accept') {
return text(`Received: ${result2.data.data}`);
}
return text('No response');
} catch (error) {
// Timeout or validation error
return error(`Request failed: ${error.message}`);
}
});
Default Behavior: Like ctx.sample(), elicitation has no timeout by default and waits indefinitely for user response. This prevents premature failures for users who may take time to complete forms or authorizations.
Error Handling
Wrap elicitation calls in try-catch blocks to handle errors gracefully:
server.tool({
name: 'safe-elicitation',
description: 'Tool with error handling',
}, async (params, ctx) => {
try {
const result = await ctx.elicit('Please provide information', z.object({ data: z.string() }));
if (result.action === 'accept') {
return text(`Received: ${result.data.data}`);
}
return text('No data provided');
} catch (error: unknown) {
// Client doesn't support elicitation or request failed
return error(`Error: ${error instanceof Error ? error.message : String(error)}. Please ensure your client supports elicitation.`);
}
});
Security Best Practices
Critical Security Rules:
- Never collect sensitive data via form mode - Always use URL mode for passwords, API keys, tokens, or personal information
- Validate on the server - Donโt trust client-side validation alone
- Use HTTPS for URL mode - All URL mode elicitations should use secure HTTPS URLs
- Bind to user identity - Associate elicitation requests with authenticated user sessions
- Verify completion - For URL mode, verify the action was completed through a secure callback
DO NOT Do This โ
// WRONG: Collecting sensitive data via form mode
server.tool({
name: 'bad-auth-example',
}, async (params, ctx) => {
// โ NEVER DO THIS!
const result = await ctx.elicit('Enter your credentials', z.object({ password: z.string(), apiKey: z.string() }));
// This violates MCP security requirements
});
DO This Instead โ
// CORRECT: Using URL mode for sensitive data
server.tool({
name: 'good-auth-example',
}, async (params, ctx) => {
// โ
Correct approach
const result = await ctx.elicit('Please authenticate securely', 'https://your-secure-server.com/auth');
if (result.action === 'accept') {
// Verify completion server-side
// Tokens are stored on your server, never pass through client
}
});
Complete Example Server
Hereโs a complete example showing both form and URL mode with the simplified API:
import { createMCPServer } from 'mcp-use/server';
import { z } from 'zod';
import { text } from 'mcp-use/server';
const server = createMCPServer('elicitation-demo', {
version: '1.0.0',
description: 'Server demonstrating elicitation patterns',
});
// Form mode: Collect preferences with Zod schema
server.tool({
name: 'set-preferences',
description: 'Configure user preferences',
}, async (params, ctx) => {
// Simplified API: ctx.elicit(message, zodSchema)
const result = await ctx.elicit(
'Configure your preferences',
z.object({
theme: z.enum(['light', 'dark', 'auto']).default('auto').describe('UI theme'),
notifications: z.boolean().default(true).describe('Enable notifications'),
language: z.enum(['en', 'es', 'fr', 'de']).default('en').describe('Language'),
})
);
// result.data is typed: { theme: 'light' | 'dark' | 'auto', notifications: boolean, language: 'en' | 'es' | 'fr' | 'de' }
if (result.action === 'accept') {
const prefs = result.data;
return text(`Preferences saved: ${prefs.theme} theme, notifications ${prefs.notifications ? 'enabled' : 'disabled'}`);
}
return text('Preferences not updated');
});
// URL mode: OAuth authorization
server.tool({
name: 'authorize-service',
description: 'Authorize external service',
}, async ({ service }, ctx) => {
const authUrl = generateOAuthUrl(service);
// Simplified API: ctx.elicit(message, url)
const result = await ctx.elicit(`Authorize ${service} to access your data`, authUrl);
if (result.action === 'accept') {
return text(`โ
${service} authorized successfully`);
}
return text(`Authorization for ${service} not completed`);
});
await server.listen(3000);
Working Example
Check out the complete working example with form mode, URL mode, and conformance tests:
๐ examples/server/elicitation/
This example includes:
- Form mode with JSON schema validation
- URL mode for OAuth-like flows
- Conformance test tool
- Working test client
To run the example:
cd libraries/typescript/packages/mcp-use/examples/server/elicitation
pnpm install
pnpm dev
Server runs on http://localhost:3002 with MCP Inspector available.
Next Steps