Skip to content

feat(ask-demo): an example toolset whose tool asks the user a question - #48

Merged
ciaransweet merged 5 commits into
mainfrom
feat/ask-demo
Nov 10, 2026
Merged

ciaransweet merged 5 commits into
mainfrom
feat/ask-demo

Conversation

@ciaransweet

@ciaransweet ciaransweet commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

An example toolset, ask-demo, whose tools stop mid-call to ask the user a question. It shows the pattern developmentseed/mcp-toolsets-runtime#201 adds for toolset authors:

class WhichYear(BaseModel):
    year: int = Field(ge=1940, le=2026, description="The year to download")
    notify: EmailStr | None = Field(None, description="An address to email when it is ready")

@tool
async def download(dataset: str) -> ToolResult:
    """Download one year of a dataset, asking the user which year."""
    chosen = ask(f"Which year of {dataset}?", WhichYear)
    if chosen is None:  # the user declined
        ...
    return ToolResult(message=f"Downloaded {dataset} for {chosen.year}.")

ask ends the call with the question (the model's JSON schema) the first time; the agent puts it to the user and calls the tool again, from the start, and ask returns the reply as an instance of the model. Four tools between them use every kind of field a question can have: a bounded integer and an optional email (download), a Literal select with a default, a date and a checkbox (subscribe), a multiple select (export), and an HttpUrl with a pattern-checked label (register_webhook). EmailStr needs pydantic[email], which the toolset declares. The tests reach the second half with answered, the way credential-demo's give a credential with header_context.

Scaffolded with uv run mcp-toolset new ask-demo; the README lists it beside hello and credential-demo.

Draft until the runtime releases

mcp_runtime.questions is on the runtime's #201, not yet released. Against the pinned 0.11.0 the contract test fails on ask-demo with ModuleNotFoundError. Against the branch, tests/test_contract.py and the toolset's tests pass (27 passed). Once #201 is merged and released, bump the pin here and this is ready.

🤖 Generated with Claude Code

@ciaransweet

Copy link
Copy Markdown
Contributor Author

Model invoked interrupt

image

Tool invoked interrupts

image image image image image

ciaransweet added a commit to developmentseed/mcp-toolsets-runtime that referenced this pull request Oct 20, 2026
Part 2 of #166: a toolset tool can stop mid-call and ask the user a
question. One call, `ask`, with a pydantic model of the answer:

```python
from pydantic import BaseModel, Field

from mcp_runtime.questions import ask

class Year(BaseModel):
    year: int = Field(ge=1940, le=2026, description="The year to download")

@tool
async def pick_year() -> ToolResult:
    """Pick a year, asking the user which."""
    chosen = ask("Which year?", Year)  # an instance, or None when the user declined
    return ToolResult(message=f"picked {chosen.year}" if chosen else "no year chosen")
```

The first time through, `ask` ends the call with the question. The agent
puts it to the user, calls the tool again from the start, and this time
`ask` returns the answer. It's the pattern the agent's own tools use
with LangGraph's `interrupt()`.

## `ask`

- **The model is the question.** Its JSON schema goes to the client, so
`Field` bounds, lengths, patterns and descriptions, `Literal` choices,
optionals, defaults, `date` and `HttpUrl` all reach it as the schema
they imply. The reply is validated back into an instance, so a tool
reads `chosen.year`. A JSON schema dict in place of the model is
answered as a dict.
- **How it works.** `ask` raises `Asked`, which the bridge catches and
returns as the SDK's `InputRequiredResult` (`convert_result` passes it
through). On the retry the bridge takes the SDK context
(`context_kwarg`) and publishes its `input_responses` through a
contextvar, the way `credentials.py` publishes headers. A tool never
sees the exception; a test does, with `pytest.raises(Asked)`, and
reaches the second half with `answered(content)`, as `header_context`
gives a credential.
- **One `ask` per call, form requests only.** Several things go in one
model, since the SDK hands back the answers for a single round. A `url`
request is something the agent can relay, but no toolset needs one yet.

## Getting the answer back

#166 assumed a tool that elicits "would already raise an interrupt
through our stack". It didn't: the turn crashed with `MCPError:
Elicitation not supported`, because the agent's clients were no longer
armed for elicitation once `list_tools` stopped going through
`MCPAdapter`, and each call built its tool on a client that hadn't
connected, so `langchain.mcp` never saw a protocol version that allows a
pause. `with_credential_support` now arms each client
(`MCPAdapter(client).client`), and `_on_its_own_session` connects before
it builds.

The run then pauses on `langchain.mcp`'s own value and waits for a
resume in its shape. `question` and `answer` in `mcp_agent.interrupts`
translate to and from the shape the `interrupt` tool already uses, so
every surface handles both the same way: `{reason, message,
responseSchema}` with the tool's name under `metadata.tool`, applied by
`pending()` so `TurnResult`, the AG-UI outcome and `GET /threads/{id}`
all carry it, and the API's existing 422 check covers it. A skip
(`cancelled`) **declines**, since MCP's `cancel` means dismissed without
a choice and skipping is a choice; so does a request the payload leaves
out. Several requests in one round become one question with each schema
under its key; a `url` request puts its address in the message.
`INPUT_REQUIRED` moved from `interrupt_gate` to `interrupts`, where
`question` needs it.

## Limits, on purpose

- **Only the 2026-07-28 protocol is supported.** Arming tells every
server the agent can answer questions. A server on that revision asks by
returning the question as a result, which works. A server on an older
revision asks by calling the client back mid-call, which the agent
cannot answer, so that tool call fails. A server that would have skipped
the question for a client that can't answer will now ask instead.
`docs/CONSUMING.md` §5g says so.
- **The question has no `toolCallId`.** What pauses the run names the
tool, not the call.
- **The tool runs again before it gets the answer.** On resume the call
starts from the beginning: the tool is called without the answer, asks
again, and is then called with it. That's how `langchain.mcp` works. §2
tells authors to ask before any work that should happen once.
- **The web client draws a form request; the terminal doesn't.** One
native input per property of `responseSchema`: text, number, email, URL
or date box, checkbox, select for an enum, multiple select for an array
of one. `default` and `description` are used, and `required`, `minimum`,
`maximum`, `minLength`, `maxLength`, `pattern` and `format` are checked
by the browser before the answer is sent (the API checks it again). An
optional field comes as pydantic's `anyOf: [type, null]`, which the form
unwraps. The form sits under the tool call that asked, paired by tool
name with the call of that tool that has no result yet, so an error
afterwards lands below it rather than pushing it down. A round that
asked several things at once nests an object per request, which the form
doesn't draw, so it offers only **skip**; before this a tool's question
left the thread unusable, with nothing shown and sending blocked. The
terminal cancels a question that isn't a choice, without prompting.

## Checked

- `tests/mcp_agent/test_elicitation.py` runs a real MCP server over
streamable HTTP whose tool is a LangChain tool through `to_mcp_tool`, so
the round trip crosses the bridge, `with_credential_support`,
`list_tools` and the state-wired agent. It checks that the run stops on
the tool's question (with its tool name), that an answer reaches the
tool, and that a skip reaches it as a decline. Unit tests cover:
- the bridge returning the question, and the answers reaching the tool
on the retry, accepted and declined;
  - several requests in one round;
  - `url` requests, alone and together;
  - a request the answer leaves out being declined;
  - the AG-UI interrupt that goes out;
  - the API refusing an answer that doesn't fit with 422.
- Live, against Mistral, through the bundled app, over the four tools of
developmentseed/mcp-toolsets#48: each question pauses the run with
`metadata.tool` and draws as a form under its call; answers round-trip
to the tool (`Downloaded era5 for 2024. Told a@b.c.`, `Subscribed to
era5: monthly from 2026-11-02, as every change.`); a skip reaches the
tool as `decline`; the browser refuses a bad email or label before
sending.
- `./scripts/lint`, `./scripts/test` (603 passed), `./scripts/build-js`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@ciaransweet
ciaransweet force-pushed the feat/ask-demo branch 2 times, most recently from e005179 to 5c4d40e Compare November 10, 2026 14:01
@ciaransweet
ciaransweet marked this pull request as ready for review November 10, 2026 14:02
ciaransweet and others added 5 commits November 10, 2026 16:01
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ciaransweet
ciaransweet merged commit 3621a0b into main Nov 10, 2026
12 checks passed
@ciaransweet
ciaransweet deleted the feat/ask-demo branch November 10, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant