Stateless MCP elicitation arrives in laravel/mcp
Contributed by pushpak1300
Part of the mcp v1.0.2 release
Elicitation — pausing a tool, prompt, or resource mid-request to ask the human a question — now works in laravel/mcp without a live bidirectional session. PR #328 introduces Request::ask(), an ElicitResponse value object, and the wire protocol needed to suspend a request, hand a question to the client, and resume when the answer returns. It follows the resultType: input_required flow from MCP spec revision 2026-07-28: the server answers once, the client collects the reply, then retries the same call with it. Two plain requests, no sockets held open. The change is almost entirely additive, but there are a handful of behavioral details worth knowing before you upgrade from v1.0.1.
Asking with ask()
$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']}");
The response is an ElicitResponse wrapping the client's reply: an action plus content matching the schema you asked for. You get accepted(), declined(), and cancelled() probes, array access to content fields, get('name', 'anonymous') with a fallback, and validate([...]) for extra rules. The object is read-only, and reading content when the action is not accept throws — check accepted() or declined() first.
Schemas are validated up front, too. Form elicitations must declare an object root whose properties are primitives or arrays of enum values; nested objects throw an InvalidArgumentException immediately.
The stateless round trip
When ask() runs and no answer is on file, the server short-circuits the request. An InputRequiredException is converted into a JSON-RPC result carrying resultType: input_required, the pending inputRequests, and an opaque requestState blob. The client renders the question, collects answers, and retries the same method call with those answers attached.
On retry, your handler re-runs from the top. Each earlier ask() call finds its answer in the carried state and resolves instantly, so sequential questions compose naturally — ask more than once and each answer arrives in turn. The flip side: anything before an ask() runs again on every round, so keep it idempotent. Request::remember($key, fn () => ...) is a memoization helper for exactly this, though remembered values must be JSON-serializable.
The flow is available in tools, prompts, resources, and generator tools, for the tools/call, prompts/get, and resources/read methods.
Capability checks and state integrity
The server will not ask a client that never offered to answer. Each pending input request is checked against the client's declared capabilities — elicitation.form for form elicitations, plus sampling and roots for other input kinds — and an undeclared capability fails with a MISSING_REQUIRED_CLIENT_CAPABILITY error listing what is missing. Call $request->canAsk() first if you want to fall back gracefully.
The requestState payload is encrypted with your application key, bound to the originating request's scope, and expires after one hour. Tampered, stale, or cross-request state is rejected as failing integrity verification, so answers cannot be forged or replayed elsewhere.
Testing and the tool-search limit
ExampleServer::tool(SignUpTool::class)
->assertElicits('Your GitHub username')
->respond(['name' => 'octocat'])
->assertSee('Hi octocat');
assertElicits() confirms a question was issued, respond() supplies the answer and replays the request, and assertSee() checks the final output. decline(), cancel(), respondWith(), assertInputRequired(), and assertErrorCode() cover the other paths, while withClientCapabilities() simulates a client that cannot answer. Test requests now send client capabilities by default, so these flows work out of the box.
One deliberate limitation: tools invoked through tool search (execute_tools) cannot ask. A batch has no way to replay the calls that already completed, so a round trip would re-run their side effects. Such tools fail with Tool [name] requested user input, which is not supported through tool search.
Upgrading from v1.0.1
- Existing handlers are unaffected; elicitation is opt-in via
ask(). - Set a working
APP_KEYin production and your test environment — encrypted request state depends on it. - Clients must declare elicitation capabilities during initialization, or the server refuses to ask.
- Keep pre-
ask()code idempotent, or wrap it inremember(). - Avoid
ask()in tools reachable through tool search. - If you implement the
Methodcontract by hand, note thathandle()may now throwInputRequiredException.
Takeaways
$request->ask()lets any tool, prompt, or resource solicit user input statelessly via theinput_requiredresult type.- Handlers re-run on each round with earlier answers filled in; idempotency (or
remember()) is your responsibility. - Client capability declarations and an encrypted, hour-limited
requestStatekeep the exchange safe — a validAPP_KEYis now a hard requirement for servers using this. - Tool search cannot support asking; expect a clean, descriptive failure there.
- The new testing API (
assertElicits(),respond(), and friends) makes multi-round flows easy to exercise.