TAI BUI
Back to curriculum
BuildPython

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.

TIME: ~45 minutesPREREQUISITES: Phase 13 · 07 (MCP server)

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.

Warm-up0/3
1. During the MCP initialize handshake, what does each side declare to the other?Warm-up
A client and a server exchanging an initialize request and an initialize result, with a dashed box under each message marked with a question mark for what that side declares.
Typed schematic.
2. In a basic MCP server, who sends the tools/call request that runs a tool?Warm-up
A model box, a client box and a server box offering a tool called notes_delete, joined by plain lines.
Typed schematic. The lines carry traffic in both directions.
3. A cleanup script is about to delete a file, but the user's request matched five files. What is the most sensible behavior for a careful tool?Warm-up
A terminal showing a cleanup command with a placeholder pattern that matched 5 files, five small cards for the matches, and a dashed box asking for the next step.
Illustrative. File names and the pattern are left out.
Check: The Problem0/2
1. Which two production failures motivate roots and elicitation in the Problem section?Explain
A user, Claude Desktop, a notes server and notes on disk joined by lines, with two dashed boxes labelled failure 1 and failure 2, each with a question mark.
Typed schematic.
2. Why does notes_delete ask the user instead of failing with 'ambiguous' or deleting every match?Explain
A notes_delete call for TPS report followed by three matching notes titled 2023, 2024 and 2025, each with a dashed delete box marked with a question mark.
Titles are from the lesson's Problem section.

The Concept

Roots

The client declares a root list at initialize:

json
{
  "capabilities": {"roots": {"listChanged": true}}
}

Server can then call roots/list:

json
{"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:

json
{
  "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:

json
{
  "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:

json
{
  "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.

Check: The Concept0/25
1. What is a root in MCP?Define
Three file URIs from the demo on Alice's machine, two tagged root and one without a tag, beside a notes server box.
URIs are from main.py. Tags are a typed marking.
2. What does a roots/list call return?Define
A server and a client with a roots/list request arrow from server to client, a result arrow back, and a dashed box asking for the result body.
Typed schematic.
3. What does a server do when it receives notifications/roots/list_changed?Define
A client and a server with a notifications/roots/list_changed arrow from client to server and a dashed box asking for the server's next move.
Typed schematic.
4. What is elicitation in MCP?Define
A chat line where the user asks to delete the old TPS report note, a running notes_delete call, and a client dialog with a blank note picker and an unchecked confirm box.
Illustrative screen. Field values are left blank.
5. What is elicitation/create?Define
A JSON-RPC message in a code box with jsonrpc 2.0, an id, the method elicitation/create and a params object whose contents are elided.
Typed. The id value is illustrative and the params are left out.
6. What is form-mode elicitation?Define
A client dialog titled client app with the message Multiple notes match 'TPS report', a blank note picker and an unchecked confirm box.
Illustrative screen. Field values are left blank.
7. In URL-mode elicitation (SEP-1036), what is sent in place of a requestedSchema?Define
Two elicitation parameter blocks side by side. The form-mode one has a message Sign in to GitHub and a requestedSchema. The URL-mode one has the same message and a dashed box with a question mark.
Typed from the lesson's two examples; fields trimmed.
8. Which three action values can a client return from an elicitation/create request?Define
A client dialog with a blank note picker and confirm box, an arrow to a JSON reply whose action value is three question marks and whose content is elided.
Typed schematic.
9. What structural limit applies to form-mode elicitation schemas in v1?Define
A server builds a schema, an SDK box marked v1 checks it, and a client shows a form, with a dashed box asking what limit applies to the schema.
Typed schematic.
10. In the context of elicitation, what is disambiguation?Define
A notes_delete call with the title TPS report and three matching notes titled 2023, 2024 and 2025, followed by a dashed box asking for the next step.
Titles are from the lesson's Problem section.
11. Who declares the root list at initialize, and why?Explain
A client and a server with a chip in the middle reading roots Notes, Scratch, joined by dashed lines to both sides, and a dashed box asking who declared it.
Typed schematic. Root names are from main.py.
12. Who actually enforces the root boundary on file operations?Explain
A chain of four boxes model, client, server and OS or disk, with a box listing declared roots Notes and Scratch, a box showing a write request for file:///tmp/outside.md, and a dashed box asking what checks the root boundary.
Typed schematic. The URI is the demo's note-100.
13. Which of these is a case where elicitation is the right tool?Explain
A tool call starting box, a dashed pause box and a tool call ending box in a row, with a dashed box under them asking what is missing.
Typed schematic.
14. Which two cases does the Concept section list as wrong uses of elicitation, besides arguments the model could ask for in prose?Explain
A card titled wrong uses of elicitation with one filled row about arguments the model could ask for in prose, and two dashed rows marked with question marks.
Typed from the stem.
15. How does decline differ from cancel in an elicitation response?Explain
Two JSON replies, one with action decline and one with action cancel, both with empty content, pointing to a paused notes_delete call, and a dashed box asking how they differ.
16. Why is URL mode a better fit than form mode for an OAuth sign-in?Explain
A card saying the notes server needs an OAuth sign-in to GitHub, and a dashed box asking whether form mode or URL mode fits better.
From the lesson's URL-mode example.
17. What drift risk does the Concept section flag for SEP-1036 URL mode?Explain
A URL-mode request showing the message Sign in to GitHub and a GitHub authorize url, with an arrow down to a dashed box asking about the reply from the client.
From the lesson's URL-mode example.
18. How do elicitation and sampling work together in a server's agent loop?Explain
A row of boxes step 1, step 2, a dashed pause box and step 3 for a server's agent loop, with two boxes below labelled elicitation and sampling.
Typed schematic. Step names are placeholders.
19. A server wrote a file to /tmp although the declared roots only list the Notes directory. A developer says the client should have blocked it. What is the wrong assumption?Analyze
Three log rows in order: the client declared roots listing only the Notes directory, the server wrote a file under /tmp, and the file now exists in /tmp.
Typed from the stem.
20. The user removes the Scratch root in the client, but the server keeps writing files there. Which step was missing?Analyze
A four-step timeline: the client declares roots Notes and Scratch, the user removes Scratch in the client, a dashed missing step, then the server keeps writing files under Scratch.
Typed from the stem.
21. A server fires an elicitation dialog for each of 200 files in a loop, and users complain about the interruptions. Which guideline was ignored?Analyze
A loop over 200 files with an elicitation dialog per file, three small dialog cards for files 1 to 3 and one for file 200, and a dashed box asking which guideline was ignored.
Illustrative. Dialog wording is typed.
22. An SDK rejects a form schema where an address property is an object holding street and city. What is the right fix?Analyze
A form schema with an address property that is an object holding street and city, and a red box saying the SDK rejects it.
Typed from the stem. The string types are illustrative.
23. A mail tool is missing a recipient, and the user's last message already names who it should go to. What is the better design?Design
A user message asking to send the draft to Dana, a send_mail call whose to field is marked missing, and a dashed box asking for the better design.
Illustrative. The names and the body are typed.
24. You must add a first-run sign-in to a third-party provider and ship it with a production SDK. Which design fits?Design
Three requirement lines: a first-run sign-in to a third-party provider, shipping with a production SDK, and running once at first connection, plus a dashed box asking for the design.
Typed from the stem. The last line restates 'first-run'.
25. A user opens a second project and wants the same notes server to reach its folder without any server code change. Which design fits?Design
A client's two roots, Notes and Scratch, a dashed box for a second project folder not in the list, a notes server with unchanged code, and a dashed box asking how the server reaches the folder.
Typed schematic. Root names are from main.py; the folder name is illustrative.

Use It

code/main.py extends the notes server with:

  • roots/list response that the server re-queries after root-list-changed notifications.
  • A notes_delete tool that uses elicitation/create to disambiguate when multiple notes match.
  • A notes_setup tool 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).

Check: Use It0/8
1. A user asks a notes server to delete 'TPS report', three notes match, and the user picks note-14 in the form. What should the server do before deleting?Apply
A flow: three notes match so the tool asks the user, the user's answer accepts note-14 with confirm true, a dashed box asks what happens before the delete, then a delete the note box.
The answer is main.py's simulated one.
2. With the demo's NOTES and ROOTS, what does notes_delete({"title": "shopping"}) return?Apply
The five NOTES entries and two ROOTS from the demo, a call to notes_delete with the title shopping, and a dashed box asking for the result.
Data is from main.py.
3. With the demo's NOTES and ROOTS, what does notes_delete({"title": "outside"}) return?Apply
The five NOTES entries and two ROOTS from the demo, a call to notes_delete with the title outside, and a dashed box asking for the result.
Data is from main.py.
4. In the TPS report scenario, suppose the simulated user answer were {"action": "decline", "content": {}}. What does the tool do?Apply
A terminal excerpt showing the elicit message for TPS report, an elided schema and a decline answer, followed by a dashed box asking for the result.
The message is from main.py. The answer line is the stem's variant, typed.
5. For the title 'TPS report', which values does the note_id enum in the generated schema contain?Apply
The five NOTES entries from the demo with ids, titles and URIs, and below them a code box with the note_id property type string and an enum whose value is three question marks.
Notes are from main.py. The title test is a case-insensitive contains.
6. At the end of the demo, ROOTS.pop() runs to simulate the user removing a root. Which root remains in ROOTS afterwards?Apply
The ROOTS list from main.py holding the Notes and Scratch entries, then a ROOTS.pop() call, and a dashed box asking what ROOTS holds afterwards.
ROOTS is copied from main.py.
7. A server checks roots with a plain startswith on the URI. A request for file:///Users/alice/Documents/Notes-old/x.md passes for the root .../Notes. What is the cause?Analyze
A root URI ending in Notes and a request URI under Notes-old/x.md, the check uri.startswith(root) reporting True, and a small listing of the Notes and Notes-old folders under Documents.
Strings are from the stem. The True result was computed with main.py's check.
8. You add notes_archive, which must confirm with the user every time, for a note id the server already knows. Which design fits?Design
A notes_archive tool taking a note_id the server already knows, two requirement lines saying the destructive hint is set and the user must confirm every time, and a dashed box asking for the design.
Typed from the stem.

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.

Author's Note

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

  1. Run code/main.py. Trigger the disambiguation path; confirm the simulated user answer gets routed back to the tool.

  2. Add a new tool notes_archive that requires elicitation confirmation every time (destructive hint). Check the UX: how does this compare to the model re-asking in text?

  3. Implement URL-mode elicitation for a first-run OAuth flow. Note the drift risk and add an SDK-version guard.

  4. Extend roots/list handling: when a notification arrives, the server should atomically re-read and rescan open file handles that might now be out of scope.

  5. Read the SEP-1036 issue discussion thread on GitHub. Identify one open question that affects how servers should handle URL-mode callbacks.

Key Terms

TermWhat people sayWhat 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

Code

Ships With