Skip to content
LaravelBlog

Revert of stateless MCP elicitation support

LaravelBlogBot
LaravelBlogBot

Contributed by pushpak1300

Part of the mcp v1.0.2 release

What changed

Pull request #356 reverts #328, "Add stateless MCP elicitation support," removing the entire stateless elicitation subsystem from the package. The stateless elicitation feature let a tool, prompt, or resource handler pause mid-request and ask the connected MCP client for more input via $request->ask(), carrying answers and arbitrary state across separate JSON-RPC requests by encrypting a requestState blob into the response (with a one-hour TTL) instead of relying on a server-side session.

This revert deletes all of it:

  • Request::ask(), remember(), canAsk(), clientSupports(), inputResponses(), state(), shareStateWith(), and setInputResponses()
  • InputRequiredException, the input_required result type, and the @throws declaration on the Method contract
  • The ElicitResponse class (including accepted(), declined(), get(), schema assertions, and validation rules) and the ElicitationAction enum
  • The inputResponses and requestState params on JsonRpcRequest, including encodeRequestState() and its decryption and scope-integrity checks
  • ToolSearch's special handling for tools that request user input
  • Testing helpers: withClientCapabilities(), assertInputRequired(), assertElicits(), respond(), respondWith(), decline(), cancel(), and assertErrorCode()

As housekeeping, phpunit.xml.dist no longer injects an APP_KEY, since nothing in the test suite encrypts request state anymore.

Why it was pulled back

Stateless elicitation was an ambitious piece of machinery. It involved client capability negotiation (elicitation.form, sampling, roots, and friends), deterministically hashed elicitation keys, encrypted scope-bound state passed back and forth with the client, and exception-driven control flow bubbling through runMethodHandle() and the response concerns. In short, it touched nearly every layer of the server request lifecycle.

The revert message doesn't include a detailed rationale, but the breadth of the removal signals that the team wants this feature redesigned before it stabilizes. Practically, the package returns to a simpler, synchronous request/response model: a tool's inputs come entirely from its declared schema and arguments, and every response is reported with resultType: 'complete'.

Upgrade impact

How this affects you depends on your path from v1.0.1:

  • Upgrading directly from v1.0.1: there is nothing to migrate. None of these APIs existed in v1.0.1, so the package surface you knew is restored.
  • Coming from the release that shipped #328: this is a breaking change. Calls to $request->ask() will fail with "Call to undefined method", use statements for the removed classes will be fatal errors, and tests using the removed assertions will error out.

If you implemented the Method contract yourself or constructed TestResponse manually, note that its constructor no longer accepts the $server and $request arguments.

Updating your code

A tool written against the stateless elicitation API looked like this:

class BookingTool extends Tool
{
    public function handle(Request $request): Response
    {
        $answer = $request->ask(
            'Which email should receive the booking confirmation?',
            fn (JsonSchema $schema) => $schema->string('email')->required(),
        );

        if (! $answer->accepted()) {
            return Response::text('Booking cancelled.');
        }

        return Response::text("Confirmation sent to {$answer->get('email')}.");
    }
}

After the revert, collect everything up front through the tool's schema instead:

use Laravel\Mcp\Support\JsonSchema;

class BookingTool extends Tool
{
    public function schema(): JsonSchema
    {
        return JsonSchema::object()
            ->string('email', 'Email address for the confirmation.')
            ->required();
    }

    public function handle(Request $request): Response
    {
        $email = $request->string('email');

        return Response::text("Confirmation sent to {$email}.");
    }
}

Updating your tests

The multi-step elicit-then-respond test flow is gone. Tests that previously chained assertElicits() and respond() should simply invoke the tool with complete arguments:

it('sends the confirmation', function () {
    BookingServer::tool(BookingTool::class, ['email' => '[email protected]'])
        ->assertOk()
        ->assertSee('Confirmation sent to [email protected].');
});

If you used assertErrorCode() for general error assertions, replace it with direct assertions on the JSON-RPC error payload.

Takeaways

  • #356 fully reverts #328; all stateless elicitation APIs (ask(), remember(), InputRequiredException, ElicitResponse, ElicitationAction) are removed.
  • Upgrading straight from v1.0.1 is a no-op for this change; upgraders who adopted #328's APIs must remove that code.
  • The encrypted requestState / inputResponses transport params no longer exist, so clients must not send them.
  • Testing helpers like withClientCapabilities(), assertInputRequired(), assertElicits(), and respond() are gone — assert on complete tool calls instead.
  • Expect a reworked elicitation implementation in a future release before adopting any interactive-input patterns.

Sources

More from this release

Related Articles

laravel/ai: Fix terminal Responses API stream handling

Format the changelog article according to the following specification: Format Requirements: - Markdown only - no prose explanations outside the article, no code fences, no code fences containing code fences, no code fences containing horizontal rules. The article must be a standalone Markdown document. *Frontmatter (YAML, wrapped in `---` ... wait, the spec says no horizontal rules? — actual spec:

LaravelBlogBot
LaravelBlogBot