Skip to content

[Server] Handler fibers suspended by an event loop are resumed by the server with null #615

Open

Description

@webagil-kevin

The server runs every request handler in a \Fiber and resumes that fiber itself whenever it suspends. Under an event loop (Revolt, which AMPHP builds on; ReactPHP's async), the handler's own I/O suspends the same fiber for the loop. The server then resumes it with null as if it had suspended for the server:

  • the handler's await returns early, or throws Must call resume() or throw() before calling suspend() again on the next await;
  • the loop later resumes a fiber that has moved on, and under Revolt the event loop terminates.

So a tool that awaits anything (a database, an HTTP API, a sleep) cannot be served from an event-loop runtime such as amphp/http-server, and it takes the loop, the whole server process, down with it.

This is the server-side twin of #504 (fixed client-side by #599). It shares a root with #583: the SDK owns handler fibers, and integrations have no seam into them.

Reproduction

Only mcp/sdk, revolt/event-loop and nyholm/psr7. The request is served from a fiber the loop owns, as an event-loop HTTP server runs it:

use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Server;
use Mcp\Server\Stateless\RequestMeta;
use Mcp\Server\Transport\StreamableHttpTransport;
use Nyholm\Psr7\Factory\Psr17Factory;
use Revolt\EventLoop;

function sleepOnTheLoop(float $seconds): void // what any async I/O does under Revolt
{
    $suspension = EventLoop::getSuspension();
    EventLoop::delay($seconds, static fn () => $suspension->resume());
    $suspension->suspend();
}

$server = Server::builder()
    ->setServerInfo('event-loop-repro', '1.0.0')
    ->addTool(static function (): string {
        $start = hrtime(true);
        sleepOnTheLoop(0.2);
        $first = (hrtime(true) - $start) / 1e6;
        sleepOnTheLoop(0.1);

        return sprintf('slept %.0f ms, then %.0f ms', $first, (hrtime(true) - $start) / 1e6 - $first);
    }, name: 'sleep', description: 'Sleeps 200 ms then 100 ms on the event loop')
    ->build();

$factory = new Psr17Factory();
$request = $factory->createServerRequest('POST', 'http://localhost/')
    ->withHeader('Host', 'localhost')
    ->withHeader('Content-Type', 'application/json')
    ->withHeader('Accept', 'application/json, text/event-stream')
    ->withHeader('MCP-Protocol-Version', ProtocolVersion::V2026_07_28->value)
    ->withHeader('Mcp-Method', 'tools/call')
    ->withHeader('Mcp-Name', 'sleep')
    ->withBody($factory->createStream(json_encode([
        'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call',
        'params' => ['name' => 'sleep', 'arguments' => new stdClass(), '_meta' => [
            RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
            RequestMeta::CLIENT_CAPABILITIES => new stdClass(),
        ]],
    ], JSON_THROW_ON_ERROR)));

EventLoop::queue(static function () use ($server, $request, $factory): void {
    $response = $server->run(new StreamableHttpTransport($request, $factory, $factory));
    echo 'answer: ', (string) $response->getBody(), "\n";
});
EventLoop::run();

On v0.9.0 (PHP 8.5, revolt/event-loop 1.0.9):

answer: {"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Error while executing tool"}}

Fatal error: Uncaught Error: Interrupt from event loop must throw an exception: Revolt\EventLoop\Internal\DriverSuspension::{closure:Revolt\EventLoop\Internal\DriverSuspension::resume():70} defined in vendor/revolt/event-loop/src/EventLoop/Internal/DriverSuspension.php:70
Stack trace:
#0 vendor/revolt/event-loop/src/EventLoop.php(411): Revolt\EventLoop\Internal\AbstractDriver->run()

The handshake era (initialize, then tools/call on the session) behaves the same. StreamableHttpTransport::createStreamedResponse() and StdioTransport::resumeFiber() resume a suspended handler fiber that has no pending client request just as StatelessProtocol::run() does.

Proposal

The server must not resume a handler fiber it did not suspend. It should wait instead until the fiber suspends for the server again, or returns. How to wait depends on the runtime, so the runtime provides it.

namespace Mcp\Server\Fiber;

interface FiberSchedulerInterface
{
    /**
     * Lets the runtime run until $settlement settles, then returns. Called on the fiber — or `{main}` — that drives
     * the handler, never while $settlement is already settled; it settles from inside the handler fiber.
     */
    public function wait(FiberSettlement $settlement): void;
}
$server = Server::builder()->setFiberScheduler(new RevoltFiberScheduler());

final class RevoltFiberScheduler implements FiberSchedulerInterface
{
    public function wait(FiberSettlement $settlement): void
    {
        $suspension = EventLoop::getSuspension();
        $settlement->then($suspension->resume(...));
        $suspension->suspend();
    }
}

FiberSettlement is a one-shot signal: isSettled(), plus then(\Closure) to call a callback once it settles. A ReactPHP scheduler is the same few lines, with a Deferred and await().

Inside, one @internal class, HandlerFibers, creates, starts, resumes and suspends handler fibers. It replaces the direct new \Fiber(), ->start(), ->resume() and \Fiber::suspend() calls in Protocol, StatelessProtocol, ClientGateway, BaseTransport, StreamableHttpTransport and StdioTransport.

  • Suspensions for the server are recorded. ClientGateway suspends through HandlerFibers::suspend(), so a suspension made after the loop resumed the fiber reaches the server, not the loop. A NotificationSuspension or RequestSuspension that a handler passes to \Fiber::suspend() itself is still read as the server's, as the existing tests do.
  • start() and resume() return only once the fiber suspended for the server, or returned. In between, the scheduler lets the runtime run.
  • Without a scheduler, a foreign suspension fails fast. It throws a LogicException naming Builder::setFiberScheduler(), instead of resuming with null. The request is answered with -32603, and the loop keeps running.
  • Exceptions come back to the server. What a handler throws while the runtime drives its fiber is rethrown on the server's side, never inside the loop.
  • Nothing else changes. No transport signature changes, the McpFiber type stays, and a fiber not created through HandlerFibers is driven exactly as before. Protocol::__construct() and StatelessProtocol::__construct() take the scheduler as an optional last argument.

With the scheduler, the reproduction answers:

answer: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"slept 203 ms, then 101 ms"}],"isError":false,"resultType":"complete",...}}

It also composes with #583. Context propagation still needs a hook around start and resume. But the scheduler is the place where an event-loop integration already owns the fiber, so it can live there.

Change

A branch is ready: 16 files, +763 / −14.

  • src/Server/Fiber/ holds FiberSchedulerInterface, FiberSettlement, and the internal HandlerFibers and HandlerFiberState.
  • The fiber call sites listed above are rerouted through it, and Builder::setFiberScheduler() is added.
  • The 0.10.0 changelog gets an entry, and docs/run/server-builder.md a "Fiber Scheduler" section.
  • New tests live in tests/Unit/Server/Fiber/:
    • HandlerFibersTest runs a dependency-free FakeEventLoop that suspends the fiber with nothing and resumes it from its own turn, as Revolt does;
    • EventLoopToolCallTest covers a tool awaiting I/O over Streamable HTTP in both eras, with and without a scheduler.
  • make phpstan and make cs are clean. The unit suite (2679, 11 of them new) and the integration suite (108) pass.

Happy to open the pull request.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions