Elicitation — Human-in-the-Loop
LLMs can't ask follow-up questions. When a tool needs user confirmation, a deployment region, or an OAuth token, the handler is stuck — guess, fail, or hard-code.
MCP Elicitation fixes this. Vurb.ts wraps it in a zero-boilerplate DSL: one import, one call, full type inference.
IMPORTANT
The first MCP framework where tools can pause, ask the human, and resume — with zero context injection. No ctx.ask(). No request.elicit(). Just await ask(...), anywhere.
- Quick Start
- How It Works
- The
askDSL - Field Types
- Multi-Step Wizards
- URL Mode (OAuth / Sensitive Data)
- Handling Responses
- Transport Compatibility
- Testing
- API Reference
- Best Practices
Quick Start
Prerequisites
Install Vurb.ts before following this guide: npm install @vurb/core @modelcontextprotocol/sdk zod — or scaffold a project with vurb create.
Step 1 — Mark the tool as interactive
import { initVurb, ask } from '@vurb/core';
interface AppContext { db: PrismaClient; userId: string }
const f = initVurb<AppContext>();
const deploy = f.mutation('infra.deploy')
.describe('Deploy an application to production')
.withString('app_id', 'Application ID')
.interactive() // ← enables elicitation
.handle(async (input, ctx) => {
const prefs = await ask('Confirm deployment settings:', {
region: ask.enum(['us-east-1', 'eu-west-1', 'ap-south-1'] as const, 'Region'),
confirm: ask.boolean('I confirm this deployment'),
});
if (prefs.declined) {
return f.error('CANCELLED', 'Deployment aborted by user.');
}
await ctx.db.deployments.create({
data: { appId: input.app_id, region: prefs.data.region },
});
return { deployed: true, region: prefs.data.region };
});Step 2 — Register and attach
const registry = f.registry();
registry.register(deploy);
registry.attachToServer(server, {
contextFactory: (extra) => createAppContext(extra),
});That's it. When the LLM calls infra.deploy, the handler pauses, the MCP client renders a form to the user, and the handler resumes with the user's response. Zero configuration. Zero context injection.
TIP
The MCP client must declare { capabilities: { elicitation: {} } } during initialization. Major clients (Claude Desktop, Cursor, VS Code Copilot) already support this.
How It Works
Developer writes: await ask('message', { name: ask.string() })
│
▼
ask.ts ← Reads transport from AsyncLocalStorage
│
▼
ServerAttachment ← _elicitStore.run(sink, handler)
│
▼
MCP SDK ← extra.sendRequest({ method: 'elicitation/create', ... })
│
▼
MCP Client ← Renders form → User fills → Returns response
│
▼
Handler resumes ← AskResponse<T> with typed .dataZero-Overhead Architecture
ask uses AsyncLocalStorage — the same mechanism Node.js uses for cls-hooked, Fastify's request context, and OpenTelemetry propagation. The transport is bound once per request in ServerAttachment and read by ask() anywhere in the call stack.
- No context pollution: handlers, middleware, and utilities call
ask()without anyctxparameter. - No overhead when unused: if
.interactive()is not called, noAsyncLocalStoragecontext is created. - Transport-agnostic: works identically on stdio, SSE, and Streamable HTTP.
The ask DSL
ask is a Callable Namespace — it's both a function and an object with factory methods:
import { ask } from '@vurb/core';
// As a function — send a form to the user
const result = await ask('Choose your preferences:', {
name: ask.string('Full name'),
plan: ask.enum(['free', 'pro', 'enterprise'] as const, 'Plan'),
age: ask.number('Age').min(18).max(120),
notify: ask.boolean('Enable notifications').default(true),
});
// As a namespace — access field factories
ask.string() // → AskStringField
ask.number() // → AskNumberField
ask.boolean() // → AskBooleanField
ask.enum() // → AskEnumField
// URL mode — for OAuth/sensitive data
await ask.redirect('Authenticate with GitHub:', 'https://github.com/login/oauth');Full Type Inference
The return type of ask() is fully inferred from the field descriptors — no manual generics:
const result = await ask('Setup:', {
name: ask.string(), // string
plan: ask.enum(['free', 'pro'] as const), // 'free' | 'pro'
age: ask.number(), // number
ok: ask.boolean(), // boolean
});
// result.data is { name: string; plan: 'free' | 'pro'; age: number; ok: boolean }
// ↑ fully typed — zero manual annotationsField Types
ask.string(description?)
ask.string() // plain string
ask.string('Your full name') // with label
ask.string('Email').default('a@b.com') // with defaultask.number(description?)
ask.number() // numeric input
ask.number('Age').min(0).max(150) // with constraints
ask.number('Score').default(50) // with defaultask.boolean(description?)
ask.boolean() // checkbox
ask.boolean('Accept terms') // with label
ask.boolean('Subscribe').default(true) // pre-checkedask.enum(values, description?)
ask.enum(['us', 'eu', 'ap'] as const) // dropdown
ask.enum(['free', 'pro'] as const, 'Plan') // with label
ask.enum(['light', 'dark'] as const).default('dark') // with defaultTIP
Always use as const with enum arrays — it enables literal type inference ('free' | 'pro' instead of string).
Chaining
All field types support .describe() and .default():
ask.number()
.describe('Team size')
.min(1)
.max(100)
.default(5)Multi-Step Wizards
Sequential ask() calls create wizard flows — each step can use data from previous steps:
const onboard = f.action('user.onboard')
.interactive()
.handle(async () => {
// Step 1 — Basic info
const step1 = await ask('Welcome! Tell us about yourself:', {
name: ask.string('Full name'),
role: ask.enum(['developer', 'designer', 'manager'] as const, 'Role'),
});
if (step1.declined) return f.error('CANCELLED', 'Onboarding aborted.');
// Step 2 — Role-specific preferences (uses step1 data)
const step2 = await ask(`Great, ${step1.data.name}! One more thing:`, {
theme: ask.enum(['light', 'dark'] as const, 'Preferred theme'),
newsletter: ask.boolean('Subscribe to newsletter').default(true),
});
if (step2.declined) return f.error('CANCELLED', 'Onboarding aborted.');
return {
name: step1.data.name,
role: step1.data.role,
theme: step2.data.theme,
newsletter: step2.data.newsletter,
};
});Each await ask(...) pauses the handler and returns when the user submits. The MCP connection stays alive throughout.
URL Mode (OAuth / Sensitive Data)
For authentication flows or sensitive data (passwords, tokens, API keys), use ask.redirect():
const connectGithub = f.action('auth.connect_github')
.interactive()
.handle(async () => {
const result = await ask.redirect(
'Authenticate with GitHub to continue:',
'https://github.com/login/oauth/authorize?scope=repo',
);
if (result.declined) {
return f.error('AUTH_REQUIRED', 'GitHub authentication is required.');
}
return { connected: true };
});WARNING
Never use ask() form fields for passwords, tokens, or secrets. Form-mode data is JSON and may be logged. Use ask.redirect() to send users to a secure HTTPS endpoint instead.
Handling Responses
ask() returns an AskResponse<T> with boolean guards:
const result = await ask('Confirm:', {
name: ask.string('Name'),
});
// Boolean guards — no string comparisons needed
result.accepted // true when the user submitted the form
result.declined // true when the user explicitly refused
result.cancelled // true when the user dismissed without choosing
// Typed data access
result.data // { name: string } — only safe when accepted
result.action // raw string: 'accept' | 'decline' | 'cancel'Fail-Fast .data
Accessing .data on a declined or cancelled response throws ElicitationDeclinedError:
// ✘ Dangerous — will throw if user declined
const name = result.data.name;
// ✔ Safe — check first, then access
if (result.declined) {
return f.error('CANCELLED', 'User declined.');
}
const name = result.data.name; // ← safeThis prevents silent undefined propagation — a common bug in manual elicitation implementations.
Transport Compatibility
| Transport | Elicitation Support | Notes |
|---|---|---|
| stdio | ✅ | Bidirectional by nature |
| SSE | ✅ | Requires MCP SDK with sendRequest |
| Streamable HTTP | ✅ | Per-request AsyncLocalStorage — perfect isolation |
ask() is transport-agnostic. The AsyncLocalStorage context is bound per-request, so concurrent HTTP requests never interfere with each other.
Testing
Test elicitation flows by injecting a mock transport via _elicitStore:
import { describe, it, expect, vi } from 'vitest';
import { _elicitStore } from '@vurb/core/elicitation';
import { ask } from '@vurb/core';
describe('deploy tool — elicitation', () => {
it('deploys to user-selected region', async () => {
const mockTransport = vi.fn().mockResolvedValue({
action: 'accept',
content: { region: 'eu-west-1', confirm: true },
});
const result = await _elicitStore.run(mockTransport, () =>
registry.routeCall(ctx, 'infra', { action: 'deploy', app_id: 'app_1' }),
);
expect(result.isError).toBeFalsy();
expect(mockTransport).toHaveBeenCalledOnce();
const call = mockTransport.mock.calls[0][0];
expect(call.method).toBe('elicitation/create');
});
it('handles user decline gracefully', async () => {
const mockTransport = vi.fn().mockResolvedValue({
action: 'decline',
});
const result = await _elicitStore.run(mockTransport, () =>
registry.routeCall(ctx, 'infra', { action: 'deploy', app_id: 'app_1' }),
);
expect(result.isError).toBe(true);
});
});API Reference
ask(message, fields)
function ask<T extends Record<string, AskField<any>>>(
message: string,
fields: T,
): Promise<AskResponse<InferAskFields<T>>>| Parameter | Type | Description |
|---|---|---|
message | string | Prompt message shown to the user |
fields | Record<string, AskField> | Field descriptors defining the form |
Returns AskResponse<T> where T is inferred from the fields.
ask.redirect(message, url)
function redirect(
message: string,
url: string,
): Promise<AskResponse<void>>| Parameter | Type | Description |
|---|---|---|
message | string | Prompt message shown to the user |
url | string | HTTPS URL to redirect the user to |
AskResponse<T>
| Property | Type | Description |
|---|---|---|
action | ElicitationAction | Raw action string |
accepted | boolean | User submitted the form |
declined | boolean | User explicitly refused |
cancelled | boolean | User dismissed without choosing |
data | T | Submitted data (throws if not accepted) |
.interactive()
.interactive(): thisCall on any FluentToolBuilder or FluentRouter to enable elicitation for the tool (or all tools in the router):
// Single tool
f.mutation('deploy.start').interactive();
// All tools in a router
f.router('admin').interactive();Error Classes
| Class | Thrown When |
|---|---|
ElicitationUnsupportedError | ask() called outside .interactive() handler or client lacks support |
ElicitationDeclinedError | .data accessed on a declined/cancelled response |
Best Practices
1. Always Check Before Accessing .data
// ✘ Will throw on decline
const name = (await ask('Name?', { name: ask.string() })).data.name;
// ✔ Check the guard first
const result = await ask('Name?', { name: ask.string() });
if (!result.accepted) return f.error('CANCELLED', 'User cancelled.');
const name = result.data.name;2. Use .interactive() on Routers for Consistent UX
// ✔ All admin tools can ask for confirmation
const admin = f.router('admin').interactive();3. Keep Forms Small
LLM clients render these forms in a constrained UI. Keep them to 2-4 fields maximum:
// ✔ Focused — one question per step
await ask('Choose region:', { region: ask.enum(['us', 'eu'] as const) });
// ✘ Overwhelming — too many fields
await ask('Configure everything:', {
region: ask.enum([...]),
theme: ask.enum([...]),
language: ask.enum([...]),
timezone: ask.enum([...]),
currency: ask.enum([...]),
notifications: ask.boolean(),
newsletter: ask.boolean(),
});4. Never Elicit Sensitive Data via Forms
// ✘ Dangerous — API key in JSON form data
await ask('Enter token:', { token: ask.string('API Token') });
// ✔ Secure — redirect to HTTPS endpoint
await ask.redirect('Authenticate:', 'https://auth.example.com/token');5. Provide Defaults for Optional Preferences
await ask('Preferences:', {
theme: ask.enum(['light', 'dark'] as const).default('dark'),
fontSize: ask.number('Font size').min(10).max(24).default(14),
});