25 releases (5 breaking)
Uses new Rust 2024
| new 0.7.0 | Feb 14, 2026 |
|---|---|
| 0.6.11 | Feb 9, 2026 |
| 0.5.0 | Feb 5, 2026 |
| 0.4.8 | Feb 5, 2026 |
| 0.1.0 | Dec 28, 2025 |
#92 in Asynchronous
Used in elicitation_rand
1.5MB
26K
SLoC
Elicitation
Teaching agents to think in types, not just fill in forms
The Problem: JSON Forms vs. Domain Languages
Most MCP servers follow a familiar pattern: expose domain objects as JSON schemas, let agents fill in forms. This works, but it's backwards:
// What most MCP servers do:
// "Here's a User form. Fill it in."
let user = agent.call_tool("create_user", json!({
"name": "Alice",
"email": "alice@example.com",
"age": 30
}));
The agent is stuck in JSON-land, translating between natural language and key-value pairs. No understanding of what a User actually is, no concept of validity beyond "did the JSON match?"
The Vision: Agents That Speak Your Domain
Elicitation flips the script. Instead of forms, you give agents the building blocks of your domain—the types, the constraints, the compositional rules—and let them construct values through conversation:
// What elicitation does:
// "Here's how to construct a valid User. Go."
#[derive(Elicit)]
struct User {
name: String,
email: Email, // Not String - Email!
age: u8, // Not any number - bounded!
}
// Agent now speaks in User-construction steps:
// 1. Select a name (String elicitation)
// 2. Construct a valid Email (format validation built-in)
// 3. Choose an age (0-255, type-guaranteed)
let user = User::elicit(&sampling_context).await?;
The difference? The agent understands the structure. It's not filling a form—it's building a User through a sequence of typed operations.
What Is Elicitation?
Elicitation is a Rust library that turns sampling interactions (calls to LLMs via MCP) into strongly-typed domain values. But it's not just type-safe JSON deserialization—it's a framework for teaching agents to:
- Think compositionally - Build complex types from simpler ones
- Respect constraints - Types encode validity (Email formats, bounded numbers)
- Follow processes - Multi-step construction with step-by-step guidance
- Verify formally - Contracts and composition rules checked at compile time
- Adapt contextually - Swap prompts/styles without changing types
Think of it as a DSL for agent-driven data construction, where the "syntax" is your Rust types and the "semantics" are guaranteed by the compiler.
Tutorial: From Simple Values to Complex Domains
Part 1: The Four Interaction Mechanics
Elicitation provides four fundamental ways agents construct values:
1. Select - Choose from finite options
Used for enums, where the agent picks one variant:
#[derive(Elicit)]
enum Priority {
Low,
Medium,
High,
Critical,
}
// Agent sees: "Select Priority: Low, Medium, High, Critical"
let priority = Priority::elicit(&ctx).await?;
When to use: Finite choice sets, enum variants, discriminated unions.
2. Affirm - Yes/no decisions
Used for booleans:
// Agent sees: "Affirm: Should this task be urgent? (yes/no)"
let urgent: bool = bool::elicit(&ctx).await?;
When to use: Binary decisions, flags, opt-in/opt-out.
3. Survey - Multi-field construction
Used for structs, where the agent builds each field in sequence:
#[derive(Elicit)]
struct Task {
title: String,
priority: Priority,
urgent: bool,
}
// Agent follows a 3-step process:
// 1. Provide title (String)
// 2. Select priority (Priority enum)
// 3. Affirm urgency (bool)
let task = Task::elicit(&ctx).await?;
When to use: Product types, records, multi-field structures.
4. Authorize - Permission policies (future)
For access control and capability-based security.
Why these four? They map to fundamental type constructors: sums (Select), booleans (Affirm), products (Survey), and effects (Authorize). Every Rust type decomposes into these primitives.
Part 2: Compositionality - Types All The Way Down
The power of elicitation is infinite composition. Every type that implements Elicitation can be nested in any other:
#[derive(Elicit)]
struct Project {
name: String,
tasks: Vec<Task>, // Nested: elicit multiple tasks
owner: User, // Nested: elicit a user
}
#[derive(Elicit)]
struct Organization {
projects: Vec<Project>, // Nested: elicit multiple projects
}
// Agent can construct an entire organization structure:
let org = Organization::elicit(&ctx).await?;
This works because:
Vec<T>implementsElicitationifTdoes (recursive elicitation)Option<T>implementsElicitationifTdoes (optional fields)- Your custom structs implement via
#[derive(Elicit)] - Primitives implement it built-in
No depth limit. Nest 10 levels deep, 100 fields wide—it composes.
Part 3: Validity Guarantees
Elicitation isn't just data entry—it's construction with guarantees. Types encode constraints that the agent must respect:
Type-Level Constraints
use elicitation::bounded::Bounded;
#[derive(Elicit)]
struct Port(
#[elicit(bounded(1024, 65535))]
u16
); // Must be in range 1024-65535
#[derive(Elicit)]
struct Email(
#[elicit(validator = is_valid_email)]
String
); // Must pass validation function
Contract System (Formal Verification)
Elicitation v0.5.0 introduced contracts: type-level proofs that operations maintain invariants.
use elicitation::contracts::{Prop, Established, And};
// Define propositions (contracts)
struct EmailValidated;
struct ConsentObtained;
impl Prop for EmailValidated {}
impl Prop for ConsentObtained {}
// Function requiring proofs
fn register_user(
email: String,
_proof: Established<And<EmailValidated, ConsentObtained>>
) {
// Compiler guarantees email was validated AND consent obtained
// No runtime checks needed!
}
// Compose workflow with proofs
let email_proof = validate_email(email)?;
let consent_proof = obtain_consent()?;
let both_proofs = both(email_proof, consent_proof);
register_user(email, both_proofs); // ✓ Compiles
register_user(email, email_proof); // ✗ Missing consent proof
Verified with Kani: 183 symbolic execution checks prove the contract system works correctly. Build multi-step agent workflows with mathematical guarantees.
Part 4: Style System - Context-Aware Prompts
Agents need context. The same Email type might be elicited differently in different scenarios:
use elicitation::{Style, Styled};
// Define custom styles for Email
#[derive(Style)]
enum EmailStyle {
Default,
WorkEmail,
PersonalEmail,
}
// Use different prompts based on style
let work_email = Email::elicit_styled(&ctx, EmailStyle::WorkEmail).await?;
// Prompt: "Provide work email address (e.g., name@company.com)"
let personal_email = Email::elicit_styled(&ctx, EmailStyle::PersonalEmail).await?;
// Prompt: "Provide personal email address"
Hot-swapping prompts without changing types. One Email type, multiple presentation contexts. Extensible: define custom styles for any type, including built-ins like String, i32, etc.
Part 5: Generators - Alternate Constructors
Sometimes you need to construct values in different ways. Elicitation provides generators for alternate construction paths.
Real-world example: std::time::Instant has a now() generator:
use std::time::Instant;
// Option 1: Agent provides manual timing (default elicitation)
let instant1 = Instant::elicit(&ctx).await?;
// Option 2: Use generator to capture current time
let instant2 = Instant::elicit_with_generator(&ctx, "now").await?;
// Equivalent to: Instant::now()
Why this matters: Some types have natural "smart constructors" that don't require user input:
Instant::now()- Current timestampSystemTime::now()- Current system timeUuid::new_v4()- Random UUID- Factory patterns with defaults
Custom generators:
#[derive(Elicit)]
#[elicit(generators = [from_template, from_env])]
struct Config {
host: String,
port: u16,
}
// Agent can choose:
// 1. from_template: Start with defaults
// 2. from_env: Load from environment variables
// 3. (default): Build each field manually
Use cases:
- Smart constructors (now(), random(), default())
- Environment-based initialization
- Template expansion
- Multi-stage construction
Part 6: Random Generation - Testing & Simulation
For testing, gaming, and simulation, you need random data. The #[derive(Rand)] macro generates contract-aware random values:
use elicitation::{Elicit, Rand, Generator};
#[derive(Elicit, Rand)]
#[rand(bounded(1, 6))]
struct D6(u32);
// Random dice rolls that respect the contract
let generator = D6::random_generator(42);
let roll = generator.generate(); // Always in [1, 6]
Perfect symmetry: If you can elicit it, you can randomly generate it.
Contract-Aware Generation
Contracts map to appropriate sampling strategies:
#[derive(Rand)]
#[rand(bounded(1, 100))]
struct Score(u32); // Uniform [1, 100]
#[derive(Rand)]
#[rand(positive)]
struct Health(i32); // Positive integers only
#[derive(Rand)]
#[rand(even)]
struct EvenId(u32); // Even numbers only
#[derive(Rand)]
#[rand(and(positive, bounded(10, 50)))]
struct Level(i32); // Positive AND bounded
Automatic Support for All Types
Works with primitives, third-party types, and custom types:
// Primitives
let gen = u32::rand_generator(seed);
let n = gen.generate();
// Third-party types
let gen = uuid::Uuid::rand_generator(seed);
let id = gen.generate();
let gen = url::Url::rand_generator(seed);
let url = gen.generate();
// Collections
let gen = VecGenerator::new(
String::rand_generator(seed),
0, 10 // Length bounds
);
let strings = gen.generate();
// Custom types with contracts
#[derive(Rand)]
struct Player {
name: String,
#[rand(bounded(1, 100))]
level: u32,
}
Use Cases
Testing:
// Property-based testing
for _ in 0..1000 {
let player = Player::random_generator(seed).generate();
assert!(player.level >= 1 && player.level <= 100);
}
Gaming:
// Agent as game master
let encounter = Encounter::random_generator(seed).generate();
let loot = LootTable::random_generator(seed).generate();
Simulation:
// Generate realistic test data
let users: Vec<User> = (0..100)
.map(|i| User::random_generator(i as u64).generate())
.collect();
Supported types:
- Primitives: u8-u128, i8-i128, f32, f64, bool, char
- Stdlib: String, PathBuf, Duration, SystemTime
- Third-party: DateTime (chrono), Timestamp (jiff), Uuid, Url
- Custom: Any type with
#[derive(Rand)] - Collections: Vec, HashMap, HashSet (via generators)
Part 7: Trait-Based MCP Tools (v0.6.0+)
For more complex systems, you might have trait-based APIs. Elicitation supports automatic tool generation from traits:
use elicitation::elicit_trait_tools_router;
#[async_trait]
trait TaskManager: Send + Sync {
async fn create_task(
&self,
params: Parameters<CreateTaskParams>,
) -> Result<Json<Task>, ErrorData>;
async fn list_tasks(
&self,
params: Parameters<ListParams>,
) -> Result<Json<Vec<Task>>, ErrorData>;
}
// Automatically generate MCP tools from trait methods
#[elicit_trait_tools_router(TaskManager, manager, [create_task, list_tasks])]
#[tool_router(router = task_tools)]
impl TaskService {}
Why this matters:
- Expose entire trait-based APIs as MCP tools
- 80-90% less boilerplate (no manual wrapper functions)
- Supports
async_traitfor object safety (trait objects work!) - Compose regular tools with elicitation tools seamlessly
The Complete Picture: Agent-Native Domain Languages
Here's what you get when you use elicitation:
-
Types as Specifications
- Your Rust types define what is valid
- The compiler checks correctness
- Agents see structured operations, not key-value forms
-
Compositionality as Architecture
- Build complex systems from simple pieces
- Nest types arbitrarily deep
- Reuse elicitation logic across your domain
-
Contracts as Guarantees
- Express invariants as type-level proofs
- Compose workflows with verified properties
- Catch logic errors at compile time, not runtime
-
Styles as Adaptation
- Same types, different contexts
- Hot-swap prompts without code changes
- Customize presentation per use case
-
Verification as Confidence
- Formally verified with Kani model checker
- 183 symbolic checks prove correctness
- Zero-cost abstractions (proofs compile away)
The result? Agents that don't just fill forms—they construct valid domain values through typed operations. They speak your domain language, follow your invariants, and produce verified outputs.
Quick Start
Installation
[dependencies]
elicitation = "0.6"
rmcp = "0.14" # Rust MCP SDK
tokio = { version = "1", features = ["full"] }
Basic Example
use elicitation::{Elicit, Rand, Generator};
use rmcp::client::Client;
#[derive(Debug, Elicit, Rand)]
enum Priority {
Low,
Medium,
High,
}
#[derive(Debug, Elicit, Rand)]
struct Task {
title: String,
priority: Priority,
urgent: bool,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Connect to MCP server (Claude Desktop, CLI, etc.)
let client = Client::stdio().await?;
// Elicit a task from the agent
let task = Task::elicit(&client).await?;
println!("Elicited task: {:?}", task);
// Or generate random tasks for testing
let generator = Task::random_generator(42);
let random_task = generator.generate();
println!("Random task: {:?}", random_task);
Ok(())
}
Run with Claude Desktop or CLI:
cargo run --example basic_task
# or
claude "Run the basic_task example"
Requirements and Constraints
Required Derives
All types using #[derive(Elicit)] must implement three traits:
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use elicitation::Elicit;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Elicit)]
pub struct Task {
title: String,
priority: Priority,
}
Why each derive is required:
Serialize- Convert Rust values to JSON for MCP responsesDeserialize- Parse agent selections back into Rust typesJsonSchema- Generate JSON schemas for MCP tool definitionsElicit- Generate the elicitation logic (our derive macro)
Optional but recommended:
Debug- For printing/logging during developmentClone- Many async patterns need cloneable values
Field Type Constraints
All field types in your structs must also implement Elicitation:
// ✅ VALID: All fields implement Elicitation
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
struct User {
name: String, // ✅ stdlib type
age: u8, // ✅ stdlib type
email: Option<String>, // ✅ Option<T> where T: Elicitation
tags: Vec<String>, // ✅ Vec<T> where T: Elicitation
}
// ❌ INVALID: CustomEmail doesn't implement Elicitation
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
struct User {
name: String,
email: CustomEmail, // ❌ Compile error!
}
// ✅ FIX: Derive Elicit for nested types
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
struct CustomEmail(String);
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
struct User {
name: String,
email: CustomEmail, // ✅ Now works!
}
Common Pitfalls
1. Missing JsonSchema on Nested Types
// ❌ BAD: Address missing JsonSchema
#[derive(Serialize, Deserialize)]
struct Address { /* ... */ }
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
struct User {
address: Address, // ❌ Compile error: no JsonSchema for Address
}
// ✅ GOOD: Add JsonSchema to all nested types
#[derive(Serialize, Deserialize, JsonSchema)]
struct Address { /* ... */ }
2. Generic Types Need Bounds
// ❌ BAD: Missing trait bounds
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
struct Container<T> {
value: T, // ❌ T might not implement required traits
}
// ✅ GOOD: Add proper bounds
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
struct Container<T>
where
T: Serialize + Deserialize + JsonSchema + Elicitation,
{
value: T, // ✅ Guaranteed to work
}
3. Enums Must Have Serde Attributes
// ❌ BAD: Complex enum variants without serde tags
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
enum Status {
Pending,
Active { since: String },
Completed { at: String, by: String },
}
// ✅ GOOD: Add serde tagging for complex enums
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
#[serde(tag = "type")]
enum Status {
Pending,
Active { since: String },
Completed { at: String, by: String },
}
4. PhantomData Needs Skip
// ✅ GOOD: Skip non-serializable fields
use std::marker::PhantomData;
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
struct TypedId<T> {
id: String,
#[serde(skip)]
_phantom: PhantomData<T>,
}
Trait Tools Requirements
When using #[elicit_trait_tools_router], parameter and result types need the same derives:
// Tool parameter types
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CreateTaskParams {
title: String,
priority: Priority,
}
// Tool result types
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CreateTaskResult {
id: String,
created: bool,
}
Note: These don't need Elicit derive (they're not elicited, just passed as JSON).
Async Requirements
Traits using #[elicit_trait_tools_router] need proper async signatures:
// Pattern 1: impl Future + Send (zero-cost)
trait MyTrait: Send + Sync {
fn method(&self, params: Parameters<P>)
-> impl Future<Output = Result<Json<R>, ErrorData>> + Send;
}
// Pattern 2: async_trait (object-safe)
#[async_trait]
trait MyTrait: Send + Sync {
async fn method(&self, params: Parameters<P>)
-> Result<Json<R>, ErrorData>;
}
See ELICIT_TRAIT_TOOLS_ROUTER.md for complete details.
Quick Checklist
Before deriving Elicit:
- Type has
Serialize + Deserialize + JsonSchema - All field types implement
Elicitation - Nested types have all required derives
- Generic types have proper bounds
- Complex enums have serde tagging
- PhantomData fields are marked
#[serde(skip)]
Integrating with rmcp Tool Routers
Elicitation tools compose seamlessly with regular rmcp tools using the #[tool_router] macro. This is the standard pattern for exposing both elicitation capabilities and domain-specific operations.
Basic Composition Pattern
use elicitation::{Elicit, elicit_tools};
use rmcp::{tool, tool_router, Json, Parameters, ErrorData};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
// 1. Define elicitable types
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Elicit)]
struct Config {
host: String,
port: u16,
}
// 2. Define regular tool types
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct StatusResponse {
healthy: bool,
uptime: u64,
}
// 3. Compose both in one server
struct MyServer;
#[elicit_tools(Config)] // Generate elicitation tools
#[tool_router] // Generate tool router
impl MyServer {
// Regular rmcp tools
#[tool(description = "Check server health")]
pub async fn status(
_peer: Peer<RoleServer>
) -> Result<Json<StatusResponse>, ErrorData> {
Ok(Json(StatusResponse {
healthy: true,
uptime: 12345,
}))
}
#[tool(description = "Restart server")]
pub async fn restart(
_peer: Peer<RoleServer>
) -> Result<Json<StatusResponse>, ErrorData> {
// Restart logic...
Ok(Json(StatusResponse {
healthy: true,
uptime: 0,
}))
}
// Elicitation tools are auto-generated:
// - elicit_config() - construct Config through conversation
}
impl ServerHandler for MyServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder()
.enable_tools()
.build(),
..Default::default()
}
}
}
Result: Server exposes 3 tools:
status- Regular toolrestart- Regular toolelicit_config- Elicitation tool (auto-generated)
Multiple Elicitation Types
You can generate tools for multiple types at once:
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
struct User { name: String }
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
struct Task { title: String }
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
struct Project { name: String, owner: User }
#[elicit_tools(User, Task, Project)] // Multiple types
#[tool_router]
impl MyServer {
// Regular tools...
// Auto-generated elicitation tools:
// - elicit_user()
// - elicit_task()
// - elicit_project()
}
Trait-Based Tool Composition
Combine #[elicit_trait_tools_router] with regular tools:
use elicitation::elicit_trait_tools_router;
#[async_trait]
trait TaskManager: Send + Sync {
async fn create_task(
&self,
params: Parameters<CreateTaskParams>,
) -> Result<Json<Task>, ErrorData>;
}
struct TaskService {
manager: Arc<dyn TaskManager>,
}
#[elicit_trait_tools_router(TaskManager, manager, [create_task])]
#[tool_router]
impl TaskService {
// Regular tools
#[tool(description = "List all tasks")]
pub async fn list_tasks(
&self
) -> Result<Json<Vec<Task>>, ErrorData> {
// Implementation...
}
// Trait tools auto-generated:
// - create_task() - delegates to self.manager.create_task()
}
Macro Ordering Rules
Critical: Macros must be applied in this order:
#[elicit_tools(Type1, Type2)] // 1. Generate elicitation methods
#[elicit_trait_tools_router(...)] // 2. Generate trait tool wrappers
#[tool_router] // 3. Discover all #[tool] methods
impl MyServer { }
Why? Each macro expands before the next one runs:
#[elicit_tools]adds methods with#[tool]attributes#[elicit_trait_tools_router]adds more methods with#[tool]attributes#[tool_router]discovers all methods marked with#[tool]
Tool Discovery
All tools are automatically discovered and registered:
// After macro expansion, you have:
let router = MyServer::tool_router();
let tools = router.list_all();
// Tools discovered:
// - Regular tools (marked with #[tool])
// - Elicitation tools (generated by #[elicit_tools])
// - Trait tools (generated by #[elicit_trait_tools_router])
println!("Server has {} tools", tools.len());
for tool in &tools {
println!(" - {}: {}", tool.name, tool.description);
}
Complete Server Example
Here's a full-featured server using all composition patterns:
use elicitation::{Elicit, elicit_tools, elicit_trait_tools_router};
use rmcp::*;
// Elicitable domain types
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
struct User { name: String, email: String }
#[derive(Serialize, Deserialize, JsonSchema, Elicit)]
struct Config { timeout: u32, retries: u8 }
// Trait for business logic
#[async_trait]
trait UserManager: Send + Sync {
async fn get_user(
&self,
params: Parameters<GetUserParams>,
) -> Result<Json<User>, ErrorData>;
}
// Server combining everything
struct AppServer {
user_manager: Arc<dyn UserManager>,
}
#[elicit_tools(User, Config)] // Elicitation tools
#[elicit_trait_tools_router(UserManager, user_manager, [get_user])] // Trait tools
#[tool_router] // Discover all
impl AppServer {
// Regular utility tools
#[tool(description = "Get server status")]
pub async fn status(&self) -> Result<Json<StatusResponse>, ErrorData> {
Ok(Json(StatusResponse { healthy: true }))
}
#[tool(description = "Get server version")]
pub async fn version(&self) -> Result<Json<VersionResponse>, ErrorData> {
Ok(Json(VersionResponse { version: "1.0.0".into() }))
}
}
impl ServerHandler for AppServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
name: "app-server".into(),
version: "1.0.0".into(),
capabilities: ServerCapabilities::builder()
.enable_tools()
.build(),
..Default::default()
}
}
}
// Server now exposes 5 tools:
// 1. status - Regular tool
// 2. version - Regular tool
// 3. elicit_user - Elicitation tool (auto-generated)
// 4. elicit_config - Elicitation tool (auto-generated)
// 5. get_user - Trait tool (auto-generated)
Benefits of Composition
Unified API: Agents see one consistent interface:
{
"tools": [
{"name": "status", "description": "Get server status"},
{"name": "elicit_user", "description": "Construct User through conversation"},
{"name": "get_user", "description": "Get user from database"}
]
}
Type Safety: All tools share the same type system:
- Regular tools: explicit implementations
- Elicitation tools: derived from domain types
- Trait tools: derived from trait methods
Composability: Mix and match freely:
- Add elicitation to existing servers
- Add regular tools to elicitation-focused servers
- Expose trait-based APIs alongside utilities
Common Patterns
Pattern 1: Configuration + Operations
#[elicit_tools(Config)] // Let agents configure
#[tool_router]
impl Server {
#[tool] async fn deploy() { } // Then operate
#[tool] async fn status() { }
}
Pattern 2: CRUD + Construction
#[elicit_tools(User, Task)] // Construct entities
#[tool_router]
impl Server {
#[tool] async fn list_users() { } // Read
#[tool] async fn update_user() { } // Update
#[tool] async fn delete_user() { } // Delete
}
Pattern 3: Trait API + Utilities
#[elicit_trait_tools_router(Api, api, [method1, method2])] // Core API
#[tool_router]
impl Server {
#[tool] async fn health() { } // Utilities
#[tool] async fn metrics() { }
}
See Also
- ELICIT_TRAIT_TOOLS_ROUTER.md - Trait tools guide
- TOOL_ROUTER_WARNINGS.md - Addressing rmcp warnings
- tests/composition_systematic_test.rs - Composition examples
Architecture
The Elicitation Trait
The core abstraction:
#[async_trait]
pub trait Elicitation: Sized {
/// Elicit a value through sampling interaction
async fn elicit(ctx: &SamplingContext) -> Result<Self, ElicitError>;
}
Every type that implements this trait can be constructed through agent interaction. The derive macro generates the implementation automatically.
How It Works
-
At compile time:
#[derive(Elicit)]generates:Elicitationtrait implementation- MCP tool definitions (JSON schemas)
- Prompt templates for each field
- Validation logic
-
At runtime: Agent calls
Type::elicit():- Library presents structured prompts to agent
- Agent responds with selections/values
- Library validates responses against type constraints
- Process repeats for nested types (recursively)
-
Result: Fully constructed, type-checked domain value.
Supported Types (100+ stdlib types)
Primitives: bool, i8-i128, u8-u128, f32, f64, char, String
Collections: Vec<T>, Option<T>, Result<T, E>, [T; N]
Network: IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr
Filesystem: PathBuf, Path
Time: Duration, SystemTime, Instant
DateTime: chrono, time, jiff (3 major datetime libraries)
Data: serde_json::Value (dynamic JSON construction)
Smart Pointers: Box<T>, Arc<T>, Rc<T>
...and more
Plus: Any custom type via #[derive(Elicit)]
Advanced Features
Feature Flags
Default: All third-party support enabled by default via the full feature.
[dependencies]
# Default: full feature bundle (all third-party support + rand)
elicitation = "0.6"
# Minimal build (opt-out of defaults)
elicitation = { version = "0.6", default-features = false }
# Custom feature selection
elicitation = { version = "0.6", default-features = false, features = [
"chrono", # chrono datetime types
"time", # time datetime types
"jiff", # jiff datetime types
"uuid", # UUID support
"url", # URL support
"regex", # Regex support
"rand", # Random generation
"serde_json", # JSON value elicitation
] }
Available features:
full(default) - All third-party support + randchrono-DateTime<Utc>,NaiveDateTimetime-OffsetDateTimejiff-Timestampuuid-Uuidurl-Urlregex-Regexrand- Random generation (see Random Generation section)serde_json-serde_json::Valueverification- Contract systemverify-kani- Kani formal verificationverify-creusot- Creusot verificationverify-prusti- Prusti verificationcli- CLI toolsdev- All features + CLI
JSON Schema Generation
All elicited types automatically generate JSON schemas for MCP:
use schemars::JsonSchema;
#[derive(Elicit, JsonSchema)]
struct Config {
timeout: u32,
}
// Schema is automatically registered with MCP server
Datetime Support
Three major datetime libraries supported:
// chrono
use chrono::{DateTime, Utc};
let timestamp: DateTime<Utc> = DateTime::elicit(&ctx).await?;
// time
use time::OffsetDateTime;
let time: OffsetDateTime = OffsetDateTime::elicit(&ctx).await?;
// jiff
use jiff::Timestamp;
let jiff_time: Timestamp = Timestamp::elicit(&ctx).await?;
Dynamic JSON Construction
Agents can build arbitrary JSON structures:
use serde_json::Value;
// Agent constructs JSON interactively
let json: Value = Value::elicit(&ctx).await?;
// Could be: {"name": "Alice", "scores": [95, 87, 92]}
Documentation
- API Docs - Complete API reference
- ELICIT_TRAIT_TOOLS_ROUTER.md - Trait-based tool generation guide
- TOOL_ROUTER_WARNINGS.md - Addressing rmcp warnings
- MIGRATION_0.5_to_0.6.md - Upgrade guide
- Examples - 20+ working examples
Why Elicitation?
For Library Authors
Expose your entire domain as agent-native operations:
- One
#[derive(Elicit)]per type → instant MCP tools - Agents construct domain values, not JSON blobs
- Type safety = correctness guarantees
- Composition = reusable building blocks
For Agent Developers
Stop wrestling with JSON forms:
- Structured operations > unstructured key-value
- Type-driven exploration (what's valid?)
- Multi-step processes with clear semantics
- Formal verification catches bugs the LLM can't
For System Architects
Build verified agent systems:
- Contracts express invariants precisely
- Composition rules checked at compile time
- Kani verification gives mathematical confidence
- Zero-cost abstractions = production-ready performance
Comparison: Before vs. After
Traditional MCP (JSON-Centric)
// Server exposes a form
let schema = json!({
"type": "object",
"properties": {
"title": {"type": "string"},
"priority": {"enum": ["Low", "Medium", "High"]},
"urgent": {"type": "bool"}
}
});
// Agent fills it in (one shot, hope for the best)
let response = agent.call_tool("create_task", json!({
"title": "Fix bug",
"priority": "Hgih", // Typo! Fails validation
"urgent": true
}));
Problems:
- Agent guesses field names/values
- Validation happens late (after submission)
- No guidance on nested structures
- No type safety, no composition
Elicitation (Type-Centric)
#[derive(Elicit)]
enum Priority { Low, Medium, High }
#[derive(Elicit)]
struct Task {
title: String,
priority: Priority,
urgent: bool,
}
// Agent constructs through typed operations
let task = Task::elicit(&ctx).await?;
// 1. Provide title (String elicitation)
// 2. Select priority from {Low, Medium, High} ← No typos possible
// 3. Affirm urgency (yes/no)
Benefits:
- Agent guided step-by-step
- Validation built into types
- Errors impossible to construct
- Composable, reusable, verified
Formal Verification
Elicitation's contract system is verified with Kani, Amazon's Rust model checker:
just verify-kani # Run 183 symbolic execution checks
What's verified:
- Contract composition (sequential and parallel)
- Proof forwarding and combination
- Type-level guarantee preservation
- Zero-cost abstraction (proofs compile to nothing)
See VERIFICATION_FRAMEWORK_DESIGN.md for details.
Contributing
We welcome contributions! Areas of interest:
- New stdlib type support - More types = more expressiveness
- Style system extensions - Custom styles for domain-specific contexts
- Verification coverage - More Kani proofs = more confidence
- Documentation - Examples, tutorials, guides
- MCP integration - Better tooling, better DX
See CONTRIBUTING.md for guidelines.
License
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT License (LICENSE-MIT)
at your option.
Acknowledgments
Built on:
- rmcp - Rust MCP SDK by Zed Industries
- Kani - Rust model checker by Amazon
- Model Context Protocol - Anthropic's agent communication standard
Special thanks to the Rust community for creating the type system that makes this possible.
Elicitation: Where types meet agents, and agents learn to think in types. 🎯
Dependencies
~13–21MB
~279K SLoC