Skip to content

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

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

typescript
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

typescript
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 .data

Zero-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 any ctx parameter.
  • No overhead when unused: if .interactive() is not called, no AsyncLocalStorage context 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:

typescript
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:

typescript
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 annotations

Field Types

ask.string(description?)

typescript
ask.string()                           // plain string
ask.string('Your full name')           // with label
ask.string('Email').default('a@b.com') // with default

ask.number(description?)

typescript
ask.number()                      // numeric input
ask.number('Age').min(0).max(150) // with constraints
ask.number('Score').default(50)   // with default

ask.boolean(description?)

typescript
ask.boolean()                        // checkbox
ask.boolean('Accept terms')          // with label
ask.boolean('Subscribe').default(true) // pre-checked

ask.enum(values, description?)

typescript
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 default

TIP

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():

typescript
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:

typescript
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():

typescript
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:

typescript
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:

typescript
// ✘ 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; // ← safe

This prevents silent undefined propagation — a common bug in manual elicitation implementations.

Transport Compatibility

TransportElicitation SupportNotes
stdioBidirectional by nature
SSERequires MCP SDK with sendRequest
Streamable HTTPPer-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:

typescript
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)

typescript
function ask<T extends Record<string, AskField<any>>>(
    message: string,
    fields: T,
): Promise<AskResponse<InferAskFields<T>>>
ParameterTypeDescription
messagestringPrompt message shown to the user
fieldsRecord<string, AskField>Field descriptors defining the form

Returns AskResponse<T> where T is inferred from the fields.

ask.redirect(message, url)

typescript
function redirect(
    message: string,
    url: string,
): Promise<AskResponse<void>>
ParameterTypeDescription
messagestringPrompt message shown to the user
urlstringHTTPS URL to redirect the user to

AskResponse<T>

PropertyTypeDescription
actionElicitationActionRaw action string
acceptedbooleanUser submitted the form
declinedbooleanUser explicitly refused
cancelledbooleanUser dismissed without choosing
dataTSubmitted data (throws if not accepted)

.interactive()

typescript
.interactive(): this

Call on any FluentToolBuilder or FluentRouter to enable elicitation for the tool (or all tools in the router):

typescript
// Single tool
f.mutation('deploy.start').interactive();

// All tools in a router
f.router('admin').interactive();

Error Classes

ClassThrown When
ElicitationUnsupportedErrorask() called outside .interactive() handler or client lacks support
ElicitationDeclinedError.data accessed on a declined/cancelled response

Best Practices

1. Always Check Before Accessing .data

typescript
// ✘ 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

typescript
// ✔ 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:

typescript
// ✔ 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

typescript
// ✘ 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

typescript
await ask('Preferences:', {
    theme:    ask.enum(['light', 'dark'] as const).default('dark'),
    fontSize: ask.number('Font size').min(10).max(24).default(14),
});