Skip to content

Latest commit

 

History

History
318 lines (265 loc) · 14 KB

File metadata and controls

318 lines (265 loc) · 14 KB
shape how-to

Elicitation

A tool handler asks the end user a question mid-call with ctx.mcpReq.elicitInput — the connected client puts the question in front of them and the promise resolves with their answer.

Ask for input with a form

Form mode carries a message and a requestedSchema: a flat JSON Schema of primitive fields the client renders as a form.

import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const server = new McpServer({ name: 'feedback', version: '1.0.0' });

server.registerTool(
    'collect-feedback',
    {
        description: 'Ask the user how something went',
        inputSchema: z.object({ topic: z.string() })
    },
    async ({ topic }, ctx) => {
        const result = await ctx.mcpReq.elicitInput({
            mode: 'form',
            message: `How was ${topic}?`,
            requestedSchema: {
                type: 'object',
                properties: {
                    rating: { type: 'number', title: 'Rating (1-5)', minimum: 1, maximum: 5 },
                    comment: { type: 'string', title: 'Comment' }
                },
                required: ['rating']
            }
        });
        if (result.action !== 'accept') {
            return { content: [{ type: 'text', text: `Feedback ${result.action}.` }] };
        }
        return { content: [{ type: 'text', text: `Recorded: ${JSON.stringify(result.content)}` }] };
    }
);

result.action records what the end user did — accept, decline, or cancel — and result.content carries the submitted fields on accept only. The SDK validates accepted content against requestedSchema before elicitInput resolves, so the fields you read match the schema you sent.

::: info On a 2026-07-28 connection elicitInput throws — a handler returns the request instead; see Input required and Protocol versions. :::

The answer comes from the connected client's elicitation/create handler. Every call on this page uses an in-memory client whose handler stands in for a real host's UI — Handle requests from the server covers the client side in full.

const client = new Client({ name: 'feedback-host', version: '1.0.0' }, { capabilities: { elicitation: { form: {}, url: {} } } });

client.setRequestHandler('elicitation/create', async request => {
    if (request.params.mode === 'url') {
        // Open request.params.url in the user's browser; answer when they finish.
        return { action: 'accept' };
    }
    // Render request.params.requestedSchema as a form; return what the user typed.
    return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } };
});

Call collect-feedback and the elicitation round-trips through that handler inside the one tool call.

const result = await client.callTool({ name: 'collect-feedback', arguments: { topic: 'the new editor' } });
console.log(result.content);

The handler resumes with the submitted fields and returns:

[
  {
    type: 'text',
    text: 'Recorded: {"rating":5,"comment":"Smooth setup"}'
  }
]

Handle every action

Return a distinct result for each action so the model knows whether the end user confirmed, refused, or never answered.

server.registerTool(
    'delete-dataset',
    {
        description: 'Delete a dataset after the user confirms',
        inputSchema: z.object({ name: z.string() })
    },
    async ({ name }, ctx) => {
        const result = await ctx.mcpReq.elicitInput({
            mode: 'form',
            message: `Delete ${name}? This cannot be undone.`,
            requestedSchema: {
                type: 'object',
                properties: { confirm: { type: 'boolean', title: 'Yes, delete it' } },
                required: ['confirm']
            }
        });
        switch (result.action) {
            case 'accept':
                if (result.content?.confirm !== true) {
                    return { content: [{ type: 'text', text: 'Box left unchecked - nothing deleted.' }] };
                }
                return { content: [{ type: 'text', text: `Deleted ${name}.` }] };
            case 'decline':
                return { content: [{ type: 'text', text: 'Declined - nothing deleted.' }] };
            case 'cancel':
                return { content: [{ type: 'text', text: 'Dismissed - ask again later.' }] };
        }
    }
);

result.content is end-user input: schema-valid, still untrusted — the accept branch checks that the box was actually ticked before acting. Decline the form and the tool answers from the decline branch:

[ { type: 'text', text: 'Declined - nothing deleted.' } ]

Prefill a field with a default

Set default on a field and the client renders the form with that value already filled in.

server.registerTool(
    'export-report',
    {
        description: 'Export a report after the user picks a format',
        inputSchema: z.object({ name: z.string() })
    },
    async ({ name }, ctx) => {
        const result = await ctx.mcpReq.elicitInput({
            mode: 'form',
            message: `Export ${name} as which format?`,
            requestedSchema: {
                type: 'object',
                properties: { format: { type: 'string', title: 'Format', enum: ['pdf', 'csv'], default: 'pdf' } },
                required: ['format']
            }
        });
        if (result.action !== 'accept') {
            return { content: [{ type: 'text', text: `Export ${result.action}.` }] };
        }
        return { content: [{ type: 'text', text: `Exported ${name} as ${result.content?.format}.` }] };
    }
);

requestedSchema reaches the client unchanged, default included; the end user submits the prefilled pdf or picks csv. An accept with format left out still returns:

[ { type: 'text', text: 'Exported quarterly-sales as pdf.' } ]

::: info A client that declares elicitation: { form: { applyDefaults: true } } — an SDK flag, not a protocol capability — fills defaulted fields the end user leaves out before the accept reaches your handler; the output above is that case. :::

Send the end user to a URL

URL mode replaces the form with a browser flow: pass url and a unique elicitationId instead of requestedSchema.

server.registerTool(
    'link-account',
    {
        description: 'Link a billing account through a hosted sign-in flow',
        inputSchema: z.object({ provider: z.string() })
    },
    async ({ provider }, ctx) => {
        const result = await ctx.mcpReq.elicitInput({
            mode: 'url',
            message: `Sign in to ${provider} to link your account`,
            url: `https://billing.example.com/connect/${encodeURIComponent(provider)}`,
            elicitationId: crypto.randomUUID()
        });
        if (result.action !== 'accept') {
            return { content: [{ type: 'text', text: `Sign-in ${result.action}.` }] };
        }
        return { content: [{ type: 'text', text: `Linked ${provider}.` }] };
    }
);

The client opens the URL and answers once the end user finishes there; whatever the page collects — credentials, payment details, API keys — stays in the browser and never crosses the MCP connection. The handler's url branch above accepts, so link-account returns:

[ { type: 'text', text: 'Linked github.' } ]

Signal that the URL flow finished

The client learns that the end user finished at the URL from a notifications/elicitation/complete notification that carries the same elicitationId. server.server.createElicitationCompletionNotifier returns the function that sends it — keep it where your callback endpoint can reach it, and pass relatedRequestId so the notification rides the in-flight tool call. Raise the request timeout too — the default is 60 seconds, and a person is on the other end of this one — and forward ctx.mcpReq.signal so a cancelled tool call also cancels the parked elicitation.

const pendingFlows = new Map<string, () => Promise<void>>();

server.registerTool(
    'connect-calendar',
    {
        description: 'Connect a calendar through a hosted consent flow',
        inputSchema: z.object({ provider: z.string() })
    },
    async ({ provider }, ctx) => {
        const elicitationId = crypto.randomUUID();
        pendingFlows.set(
            elicitationId,
            server.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: ctx.mcpReq.id })
        );
        try {
            const result = await ctx.mcpReq.elicitInput(
                {
                    mode: 'url',
                    message: `Grant ${provider} calendar access`,
                    url: `https://calendar.example.com/consent/${encodeURIComponent(provider)}?state=${elicitationId}`,
                    elicitationId
                },
                // a person is on the other end (the default timeout is 60 s); the signal
                // cancels the parked elicitation if the tool call itself is cancelled
                { timeout: 10 * 60_000, signal: ctx.mcpReq.signal }
            );
            if (result.action !== 'accept') {
                return { content: [{ type: 'text', text: `Consent ${result.action}.` }] };
            }
            return { content: [{ type: 'text', text: `Connected ${provider}.` }] };
        } finally {
            pendingFlows.delete(elicitationId);
        }
    }
);

// The hosted flow redirects back to your server with the id in `state`; that
// endpoint sends the notification.
async function completeFlow(elicitationId: string): Promise<void> {
    await pendingFlows.get(elicitationId)?.();
}

On the client, hold the elicitation/create answer until the notification names the elicitationId the request carried, and let ctx.mcpReq.signal release it when the server cancels — a timed-out or abandoned flow must not leave the handler waiting.

const finished = new Map<string, () => void>();

client.setNotificationHandler('notifications/elicitation/complete', notification => {
    console.log('URL flow finished:', notification.params.elicitationId);
    finished.get(notification.params.elicitationId)?.();
    finished.delete(notification.params.elicitationId);
});

client.setRequestHandler('elicitation/create', async (request, ctx) => {
    if (request.params.mode === 'url') {
        // Open request.params.url in the user's browser; answer once the server signals completion.
        const { elicitationId } = request.params;
        const done = await new Promise<'complete' | 'cancelled'>(resolve => {
            finished.set(elicitationId, () => resolve('complete'));
            ctx.mcpReq.signal.addEventListener('abort', () => {
                finished.delete(elicitationId);
                resolve('cancelled');
            });
        });
        return { action: done === 'complete' ? 'accept' : 'cancel' };
    }
    return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } };
});

The host's own tools/call has the same 60-second default, so the caller raises it as well:

const connecting = client.callTool({ name: 'connect-calendar', arguments: { provider: 'google' } }, { timeout: 10 * 60_000 });

Let the callback endpoint run completeFlow with the id from state, and the client logs the notification before the tool result arrives (the id is fresh on every run):

URL flow finished: c9a7bcfc-acc9-494c-8ce5-44c921232ea6
[ { type: 'text', text: 'Connected google.' } ]

::: info This notification exists on 2025-11-25 connections only — the 2026-07-28 input-required flow has no elicitationId and no completion signal; see Protocol versions. :::

Keep secrets out of forms

Form answers travel back through the client and land in the model's context like any other tool result.

::: warning Never collect sensitive information — passwords, API keys, payment details — through form elicitation. Use URL mode or an out-of-band flow instead. :::

Require the elicitation capability

Elicitation only works against a client that declared the elicitation capability — per mode: form, url — when it connected. Against a client without it, elicitInput throws before anything reaches the wire, and the thrown message comes back as an ordinary isError tool result:

{
  content: [
    { type: 'text', text: 'Client does not support form elicitation.' }
  ],
  isError: true
}

Recap

  • ctx.mcpReq.elicitInput sends an elicitation/create request mid-handler and resolves with the end user's answer.
  • Form mode carries a message and a flat JSON-Schema requestedSchema; the SDK validates accepted content against it.
  • result.action is accept, decline, or cancel; result.content is present only on accept.
  • default on a requestedSchema field prefills the form; a client that declares applyDefaults fills the field in when the end user leaves it out.
  • URL mode hands the end user a browser flow — use it for anything sensitive; createElicitationCompletionNotifier returns the function that sends notifications/elicitation/complete so the client can answer.
  • Calls against a client that never declared the elicitation capability fail before reaching the wire.