Skip to content

Add stateless MCP elicitation support - #328

Draft
pushpak1300 wants to merge 18 commits into
mainfrom
feat/elicitation
Draft

Add stateless MCP elicitation support#328
pushpak1300 wants to merge 18 commits into
mainfrom
feat/elicitation

Conversation

@pushpak1300

@pushpak1300 pushpak1300 commented Aug 18, 2026

Copy link
Copy Markdown
Member

Asking the user something mid-request used to need a live bidirectional session, and we don't have one — every request is handled on its own.

Elicitation in revision 2026-07-28 is stateless. The server answers with resultType: input_required, the client collects the answer, then retries the same call with it. Two plain requests.

So a handler can now ask for something:

$github = $request->ask('Your GitHub username', fn (JsonSchema $schema): array => [
    'name' => $schema->string()->required(),
]);

if ($github->declined()) {
    return Response::error('No problem, skipping.');
}

return Response::text("Hi {$github['name']}");
What $github is

An ElicitResponse wrapping the client's reply — an action, plus content matching the schema you asked for:

{"action": "accept", "content": {"name": "octocat"}}
$github->accepted();               // submitted, so content is populated
$github->declined();               // said no outright, offer an alternative
$github->cancelled();              // dismissed without choosing, fine to ask again later

$github['name'];                   // a field from content
$github->get('name', 'anonymous'); // with a fallback
$github->validate(['name' => 'required|string']);

It is read only, and reading content when the action is not accept throws.

Ask more than once and each answer arrives in turn:

$name = $request->ask('Your name', fn (JsonSchema $schema): array => [
    'name' => $schema->string()->required(),
]);

$colour = $request->ask('Favourite colour', fn (JsonSchema $schema): array => [
    'colour' => $schema->string()->required(),
]);

return Response::text("{$name['name']} likes {$colour['colour']}");

An ask() with no answer yet ends the request and asks the client. The handler re-runs from the top on the retry with the earlier answers already filled in, so $name is populated by the time the second round reaches $colour. Anything before an ask() runs on every round — keep it idempotent.

Works in tools, prompts, resources and generator tools. Reading a declined or cancelled response throws, so check accepted() or declined() first.

Not supported through tool search. A catalog tool reached via execute_tools that calls ask() fails with Tool [name] requested user input, which is not supported through tool search. — a batch has no way to replay the calls that already completed, so a round trip would re-run their side effects.

Testing:

ExampleServer::tool(SignUpTool::class)
    ->assertElicits('Your GitHub username')
    ->respond(['name' => 'octocat'])
    ->assertSee('Hi octocat');

@pushpak1300
pushpak1300 force-pushed the feat/elicitation branch 3 times, most recently from a4a8ada to e8c28f7 Compare August 19, 2026 04:58
Drops elicitUrl() and the url client capability, leaving form mode only.

Fixes found while reviewing:

- Merge input responses with array_replace so numeric keys survive
- Key default elicitations per call site, stable across replays
- Throw when reading content off a declined or cancelled response
- Report boolean client capabilities as declared
- Serialize empty input request params as an object
- Forward input responses to nested catalog tools
- Keep cache hints off input required results
- Surface the capability error for prompts and resources
- Set APP_KEY for the test suite so the request state can be encrypted

Adds the four missing conformance fixtures, which empties the input
required section of the expected failures baseline.
Reads the input requests through typed accessors instead of repeating
the result lookup, and swaps the manual search in assertElicits for a
collection contains.
Adds ElicitationAction for the three actions the specification defines
and reads the response action through it, so accepted, declined and
cancelled compare enum cases instead of repeating string literals.
ElicitResponse now takes the action and content it holds rather than the
raw response array, with a from factory parsing the wire shape once at
the boundary instead of on every accessor call.
@pushpak1300
pushpak1300 marked this pull request as ready for review August 19, 2026 08:14
@pushpak1300
pushpak1300 marked this pull request as draft August 19, 2026 09:27
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