Roots and Elicitation — Scoping and Mid-Flight User Input
Hard-coded paths break the moment a user opens a different project, and pre-filled tool arguments break when the user under-specifies. Roots scope the server to a user-controlled set of URIs. Elicitation pauses a tool call mid-flight and asks the user for structured input through a form or a URL. Those are two client primitives for two common MCP failure modes. URL-mode elicitation (SEP-1036, 2025-11-25) is experimental through H1 2026, so check your SDK version before depending on it.
Learning Objectives
- Declare `roots` and respond to `notifications/roots/list_changed`.
- Restrict server file operations to URIs inside the declared root set.
- Use `elicitation/create` to ask the user for a confirmation or structured input mid-tool-call.
- Choose between form-mode and URL-mode elicitation (the latter is experimental; drift-risk noted).
The Problem
A notes MCP server hits two concrete failures in production.
Broken path assumption. The server is written against ~/notes. A user on another machine keeps notes in ~/Documents/Notes, and the tool call either fails silently (no file found) or writes to the wrong place.
Missing argument the user would know. The user asks "delete the old TPS report note". The model calls notes_delete(title: "TPS report"), but three notes match, from 2023, 2024, and 2025. The tool cannot guess. Failing with "ambiguous" is annoying, and running on all three is worse.
Roots fix the first. The client declares at initialize the set of URIs the server may touch. Elicitation fixes the second: the server pauses the tool call and sends elicitation/create so the user picks which note to delete.
The Concept
Roots
The client declares a root list at initialize:
{
"capabilities": {"roots": {"listChanged": true}}
}Server can then call roots/list:
{"roots": [{"uri": "file:///Users/alice/Documents/Notes", "name": "Notes"}]}Servers must treat roots as the boundary, and a file read or write outside the root set gets rejected. The client does not enforce this (the server is still code the user chose to trust), so it only holds when the server honors it, which spec-compliant servers do.
When the user adds or removes a root, the client sends notifications/roots/list_changed. The server re-calls roots/list and updates its boundary.
Why roots are a client primitive
The client declares roots because they encode the user's consent. The user told Claude Desktop "give this notes server access to these two directories", and the server has no way to widen that scope.
Elicitation: the form-mode default
elicitation/create takes a form schema plus a natural-language prompt:
{
"method": "elicitation/create",
"params": {
"message": "Delete 'TPS report'? Multiple notes match; pick one.",
"requestedSchema": {
"type": "object",
"properties": {
"note_id": {
"type": "string",
"enum": ["note-3", "note-7", "note-14"]
},
"confirm": {"type": "boolean"}
},
"required": ["note_id", "confirm"]
}
}
}Client renders a form, collects the user's answer, returns:
{
"action": "accept",
"content": {"note_id": "note-14", "confirm": true}
}Three possible actions: accept (user filled it), decline (user closed it), cancel (user aborted the whole tool call).
Form schemas are flat. Nested objects are not supported in v1, and SDKs typically reject anything deeper than a single layer.
Elicitation: URL mode (SEP-1036, experimental)
New in 2025-11-25. Instead of a schema, the server sends a URL:
{
"method": "elicitation/create",
"params": {
"message": "Sign in to GitHub",
"url": "https://github.com/login/oauth/authorize?client_id=..."
}
}The client opens the URL in a browser, waits for completion, and returns when the user comes back. This suits OAuth flows, payment authorization, and document signing, where a form is not enough.
The SEP-1036 response shape is still settling. Some SDKs return the callback URL and others return a completion token, so read your SDK's release notes before using URL mode in production.
When elicitation is the right tool
- User confirmation before destructive actions (destructive hint + elicitation).
- Disambiguation (pick one of N matches).
- First-run setup (API keys, directories, preferences).
- OAuth-style flows (URL mode).
When elicitation is wrong
- Filling a tool's required arguments that the model could have asked for in prose. Use a normal re-prompt, not an elicitation dialog.
- High-frequency calls. Elicitation interrupts the conversation; do not fire it inside a loop.
- Anything the server could validate after the fact. Validate, return an error, let the model ask the user in text.
Human-in-the-loop bridge
Elicitation and sampling together give MCP its human-in-the-loop model. A server's agent loop can pause for user input (elicitation) or for model reasoning (sampling). Phase 13 · 11 covered sampling and this lesson covers elicitation. Combine them and the server controls the loop from the inside.
Use It
code/main.py extends the notes server with:
roots/listresponse that the server re-queries after root-list-changed notifications.- A
notes_deletetool that useselicitation/createto disambiguate when multiple notes match. - A
notes_setuptool that uses URL-mode elicitation to open a first-run config page (simulated). - A boundary check that refuses operations on URIs outside the declared roots.
The demo runs four scenarios: happy path (one match), disambiguation (three matches, elicitation fires), out-of-root-write (rejected), and URL-mode setup (simulated).
Ship It
This step produces outputs/skill-elicitation-form-designer.md. Given a tool that might need user confirmation or disambiguation, the skill designs the elicitation form schema and the message template.
I'd read main.py with one question in mind: where does the root check run? uri_in_roots is called inside tool_notes_delete, once on the single-match path and again after the elicitation answer comes back. That second call is the one I care about. The user picked note-14 from a form, and the server still re-verifies it against the roots before deleting, because a user's answer is input like any other.
The check itself is a plain startswith on the URI string. That is enough to show the idea, but it does not normalize paths, so file:///Users/alice/Documents/Notes-old/x.md would pass for the root .../Notes. A real server should parse the URI and compare path components.
I'd also keep the stand-ins in view. ROOTS is a hard-coded list, and FAKE_USER_ANSWERS plays the user. The demo has a fourth scenario for URL mode, and the root-change step at the end only pops Scratch off the list and prints a reminder. There is no roots/list call and no handle cleanup, which is what exercise 4 asks you to build.
Exercises
-
Run
code/main.py. Trigger the disambiguation path; confirm the simulated user answer gets routed back to the tool. -
Add a new tool
notes_archivethat requires elicitation confirmation every time (destructive hint). Check the UX: how does this compare to the model re-asking in text? -
Implement URL-mode elicitation for a first-run OAuth flow. Note the drift risk and add an SDK-version guard.
-
Extend
roots/listhandling: when a notification arrives, the server should atomically re-read and rescan open file handles that might now be out of scope. -
Read the SEP-1036 issue discussion thread on GitHub. Identify one open question that affects how servers should handle URL-mode callbacks.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| Root | "Consent boundary" | URI the client has allowed the server to touch |
roots/list | "Server asks for scope" | Client returns the current root set |
notifications/roots/list_changed | "User changed scope" | Client signals the root set has mutated |
| Elicitation | "Ask the user mid-call" | Server-initiated request for structured user input |
elicitation/create | "The method" | JSON-RPC method for elicitation requests |
| Form mode | "Schema-driven form" | Flat JSON Schema rendered as a form in the client UI |
| URL mode | "Browser redirect" | SEP-1036 experimental; opens a URL and waits |
accept / decline / cancel | "User response outcomes" | Three branches the server handles |
| Disambiguation | "Pick one" | Common elicitation use case when a tool has N candidates |
| Flat form | "Top-level properties only" | Elicitation schemas cannot nest |
Further Reading
- MCP: Client roots spec: canonical roots reference
- MCP: Client elicitation spec: canonical elicitation reference
- Cisco: What's new in MCP elicitation, structured content, OAuth enhancements: 2025-11-25 additions walk-through
- MCP: GitHub SEP-1036: URL-mode elicitation proposal (experimental, drift-risk)
- The New Stack: How elicitation brings human-in-the-loop to AI tools: UX walkthrough
Code
- main.py (python, 156 lines)