Elicitation
What is Elicitation?
Elicitation lets a server ask the connected client for additional information mid-request. The MCP spec defines two modes:
- Form mode — present a structured form to the user and validate the response against a schema you define.
- URL mode (spec 2025-11-25) — redirect the user to an external page (sign-in, payment, account verification, …) and resume once they come back.
Add elicitation to my Nuxt MCP server (@nuxtjs/mcp-toolkit).
- Use useMcpElicitation() inside a tool handler (auto-imported)
- Form mode: pass a Zod raw shape via `schema` and a human-readable `message`
- The shape must be flat — primitives, single-/multi-select enums only (spec restriction)
- Always check the action: 'accept' | 'decline' | 'cancel' before reading content
- URL mode: pass a `url` and `message`; client opens the URL and reports back
- Use `confirm(message)` for a quick yes/no prompt
- Use `supports('form' | 'url')` before calling to gate cleanly when the client doesn't support it
- Wrap in try/catch and check for McpElicitationError (codes: 'unsupported', 'invalid-schema', 'invalid-response')
Docs: https://mcp-toolkit.nuxt.dev/advanced/elicitation
When to Use Elicitation
| Use Case | Mode |
|---|---|
| Disambiguate input ("which project did you mean?") | form with an enum |
| Confirm a destructive action before running it | confirm |
| Collect missing required parameters interactively | form |
| Gather optional metadata (priority, tags, …) before submitting | form |
| Prompt for sign-in or payment via an external page | url |
| Trigger an OAuth consent flow | url |
useMcpElicitation()
Auto-imported. Must be called inside a tool, resource, or prompt handler.
import { z } from 'zod'
import { defineMcpTool } from '@nuxtjs/mcp-toolkit/server'
export default defineMcpTool({
name: 'create_release',
description: 'Create a new release after asking for the channel',
inputSchema: {
name: z.string(),
},
handler: async ({ name }) => {
const elicit = useMcpElicitation()
const result = await elicit.form({
message: `Pick a release channel for "${name}"`,
schema: {
channel: z.enum(['stable', 'beta', 'canary']).describe('Release channel'),
notify: z.boolean().default(true).describe('Notify subscribers'),
},
})
if (result.action !== 'accept') {
return `Release cancelled (${result.action}).`
}
return `Created "${name}" on ${result.content.channel} (notify=${result.content.notify}).`
},
})
The Zod shape is converted to the spec-restricted JSON Schema, the response is validated against the same shape, and result.content is fully typed.
API
| Method | Description |
|---|---|
form({ message, schema }) | Ask for structured input. schema is a Zod raw shape (same format as inputSchema). Returns { action, content? }. |
url({ message, url }) | Open an external URL. Returns { action }. |
confirm(message) | Convenience yes/no prompt. Returns boolean (true only when the user accepts and confirms). |
| `supports('form' | 'url')` |
The action is one of 'accept' | 'decline' | 'cancel'. The content field is only present when action === 'accept'.
Schema Restrictions (Form Mode)
The MCP spec restricts elicitation requests to flat objects with primitive properties so any client can render them as a form. The toolkit enforces this at request time and throws McpElicitationError('invalid-schema') when you violate it.
Allowed:
z.string(),z.number(),z.boolean(),z.string().email(),z.number().int()z.enum([...])— single-select dropdownz.array(z.enum([...]))— multi-select.describe(...),.default(...),.optional()
Not allowed:
- Nested
z.object({ ... }) z.array(z.number())or any array of non-string-enumsz.record(...),z.tuple(...),z.union(...),z.discriminatedUnion(...)
Need richer input? Split into multiple elicitation calls or take the data via the regular inputSchema.
Confirm Helper
import { z } from 'zod'
import { defineMcpTool } from '@nuxtjs/mcp-toolkit/server'
export default defineMcpTool({
name: 'delete_project',
description: 'Delete a project after confirming with the user',
inputSchema: { id: z.string() },
handler: async ({ id }) => {
const elicit = useMcpElicitation()
if (!await elicit.confirm(`Permanently delete project ${id}?`)) {
return 'Aborted.'
}
await deleteProject(id)
return `Deleted ${id}.`
},
})
confirm() builds on form() with a single confirm: z.boolean() field, so it inherits the same capability checks and decline handling.
URL Mode
URL mode is opt-in per the spec — clients must declare elicitation.url in their capabilities. Use supports('url') to branch cleanly:
import { defineMcpTool } from '@nuxtjs/mcp-toolkit/server'
export default defineMcpTool({
name: 'connect_github',
description: 'Connect the user GitHub account',
inputSchema: {},
handler: async () => {
const elicit = useMcpElicitation()
if (!elicit.supports('url')) {
return 'Open https://app.example.com/settings/github to connect your account, then try again.'
}
const result = await elicit.url({
message: 'Authorize the integration',
url: 'https://app.example.com/oauth/github/start',
})
return result.action === 'accept'
? 'GitHub connected.'
: `User did not complete the flow (${result.action}).`
},
})
Capability Checks
Always handle the case where the client doesn't declare the elicitation capability — many clients (and self-hosted relays) advertise tools without it.
const elicit = useMcpElicitation()
if (!elicit.supports('form')) {
return 'This tool needs an interactive client (Cursor, Claude Desktop, …).'
}
When you call form() or url() against an unsupported client, the composable throws McpElicitationError with code: 'unsupported' so you can recover gracefully:
import { McpElicitationError } from '@nuxtjs/mcp-toolkit/server'
try {
await elicit.form({ message: '…', schema: { … } })
}
catch (err) {
if (err instanceof McpElicitationError && err.code === 'unsupported') {
return 'Client does not support elicitation — falling back to defaults.'
}
throw err
}
Requirements
useMcpElicitation() requires:nitro.experimental.asyncContextset totrue(default since Nuxt 3.8+)- A client that declared the
elicitationcapability during initialization