Skip to content
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
* Fix the server resuming a request handler's fiber that the runtime had suspended: the handler's await returned early or threw (`Must call resume() or throw() before calling suspend() again`), and the runtime later resumed a fiber that had moved on — under Revolt, terminating the event loop. Without a scheduler, such a handler now fails the request with a `LogicException` naming `Builder::setFiberScheduler()`. `Protocol::__construct()` and `StatelessProtocol::__construct()` take the scheduler as optional last argument.
* What a handler throws while the runtime drives its fiber reaches the server, as it does when the server drives it, never the runtime.
* [BC Break] A handler calling `\Fiber::suspend()` itself with anything but a `NotificationSuspension` or `RequestSuspension` fails the request without a scheduler, also when no event loop is involved. It used to be resumed with `null`.
* Fix a request failing with `503`, and a resumed request losing its session writes, while another request of the session holds the session lock across an await: both wait for the lock on the event loop.
* Fix a handler still running after its request was answered, e.g. without a fiber scheduler: it no longer fires `ResponseEvent` or `ErrorEvent`, what it throws is logged, and calling the `ClientGateway` throws.

### Client

Expand Down
8 changes: 8 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
},
"require-dev": {
"ext-openssl": "*",
"amphp/amp": "^3.0",
"amphp/http-server": "^3.3",
"composer/semver": "^3.0",
"firebase/php-jwt": "^6.10 || ^7.0",
"laminas/laminas-httphandlerrunner": "^2.12",
Expand All @@ -55,6 +57,10 @@
"phpunit/phpunit": "^10.5",
"psr/cache": "^1.0 || ^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"react/async": "^4.3",
"react/http": "^1.11",
"react/promise": "^3.0",
"revolt/event-loop": "^1.0",
"symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0",
Expand Down Expand Up @@ -88,6 +94,8 @@
"Mcp\\Example\\Server\\McpApps\\": "examples/server/mcp-apps/",
"Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/",
"Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/",
"Mcp\\Example\\Server\\ReactPhp\\": "examples/server/reactphp/",
"Mcp\\Example\\Server\\Revolt\\": "examples/server/revolt/",
"Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/",
"Mcp\\Tests\\": "tests/"
},
Expand Down
10 changes: 7 additions & 3 deletions docs/run/server-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,11 +338,15 @@ A fiber scheduler lets handlers await I/O while the event loop serves other requ
- On protocol versions up to `2025-11-25`, the same transport blocks the event loop while a handler waits for an
answer from the client (e.g. `ClientGateway::elicit()`). One process can't receive the answer during that time,
so the request fails when the wait times out.
- `StdioTransport` reads the next message after the current handler returned or started to wait for the client.
Requests over stdio run one after the other. Your own tasks on the event loop still run in between.
- Call the `ClientGateway` from the fiber of the handler. A call from another fiber (e.g. in `EventLoop::queue()`)
never returns, and the request gets no answer. The same happens if a handler passes a `NotificationSuspension`
or `RequestSuspension` to `\Fiber::suspend()` itself after the event loop resumed it.
never returns. If the handler waits for that fiber, the request gets no answer. If it doesn't wait, the request
gets its answer and the message is lost without an error. A handler that passes a `NotificationSuspension` or
`RequestSuspension` to `\Fiber::suspend()` itself after the event loop resumed it gets no answer either.
- A [session lock](sessions.md#concurrent-requests) stays held while a handler awaits I/O. Another request of the
same session in the same process then fails with status `503` and the message `Session lock is unavailable.`
same session in the same process waits on the event loop until the lock is free. This wait has no timeout. If
another process holds the lock, the lock backend blocks the event loop while it waits, up to its timeout.

## Complete Example

Expand Down
22 changes: 22 additions & 0 deletions examples/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ curl -sS http://127.0.0.1:8000/ \
`tests/Integration/StatelessLifecycleTest.php` drives this example end to end — discovery, a tool call,
the multi round-trip flow, and the response stream carrying progress and log notifications.

## Event loop

`revolt/server.php` runs the server on the Revolt event loop with `amphp/http-server`. It is a
long-running process, so you start it directly instead of with `php -S`:

```bash
php examples/server/revolt/server.php 127.0.0.1:8000
```

Its tools wait on the loop with `Amp\delay()`, and other requests are served in the meantime. The
`RevoltFiberScheduler` set with `Builder::setFiberScheduler()` makes this work, see
[Fiber Scheduler](../../docs/run/server-builder.md#fiber-scheduler).

`reactphp/server.php` is the same server on the ReactPHP event loop with `react/http`:

```bash
php examples/server/reactphp/server.php 127.0.0.1:8000
```

Both servers answer with status `501` when a response needs streaming, for example when a tool sends a
notification.

## Debugging

You can enable debug output by setting the `DEBUG` environment variable to `1`, and additionally log to a file by
Expand Down
37 changes: 37 additions & 0 deletions examples/server/reactphp/ReactFiberScheduler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Example\Server\ReactPhp;

use Mcp\Server\Fiber\FiberSchedulerInterface;
use Mcp\Server\Fiber\FiberSettlement;
use React\EventLoop\Loop;
use React\Promise\Deferred;

use function React\Async\await;

/**
* Lets the event loop run while a request handler awaits I/O.
*/
final class ReactFiberScheduler implements FiberSchedulerInterface
{
public function wait(FiberSettlement $settlement): void
{
$deferred = new Deferred();

// await() resumes as soon as the promise resolves, and the handler fiber still runs at this point.
$settlement->then(static function () use ($deferred): void {
Loop::futureTick(static fn () => $deferred->resolve(null));
});

await($deferred->promise());
}
}
74 changes: 74 additions & 0 deletions examples/server/reactphp/SlowTools.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Example\Server\ReactPhp;

use Psr\Log\LoggerInterface;

use function React\Async\async;
use function React\Async\await;
use function React\Async\delay;
use function React\Async\parallel;

/**
* Tools awaiting I/O on the event loop, simulated with timers.
*/
final class SlowTools
{
/** @var array<string, float> latency of each simulated service, in seconds */
private const SERVICES = [
'database' => 0.3,
'cache' => 0.1,
'search' => 0.2,
];

public function __construct(
private readonly LoggerInterface $logger,
) {
}

/**
* Waits on the event loop without blocking other requests.
*
* @param float $seconds how long to wait, at most 5 seconds
*/
public function wait(float $seconds): string
{
$seconds = max(0.0, min($seconds, 5.0));
$this->logger->info('Waiting on the event loop.', ['seconds' => $seconds]);

delay($seconds);

return \sprintf('Waited %s seconds on the event loop.', $seconds);
}

/**
* Checks all services concurrently, in the time of the slowest one.
*
* @return array<string, string> the status of each service
*/
public function checkServices(): array
{
$checks = [];
foreach (self::SERVICES as $service => $latency) {
$checks[$service] = async(static function () use ($latency): string {
delay($latency);

return 'up';
});
}

$statuses = await(parallel($checks));
ksort($statuses);

return $statuses;
}
}
68 changes: 68 additions & 0 deletions examples/server/reactphp/server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

require_once dirname(__DIR__).'/bootstrap.php';
chdir(__DIR__);

use Http\Discovery\Psr17Factory;
use Mcp\Example\Server\ReactPhp\ReactFiberScheduler;
use Mcp\Example\Server\ReactPhp\SlowTools;
use Mcp\Server;
use Mcp\Server\Transport\CallbackStream;
use Mcp\Server\Transport\StreamableHttpTransport;
use Psr\Http\Message\ServerRequestInterface;
use React\Http\HttpServer;
use React\Http\Message\Response;
use React\Socket\SocketServer;

use function React\Async\async;

/*
* An MCP server on the ReactPHP event loop, served by react/http.
*
* One long-running process serves all requests: while a tool awaits I/O, the
* loop serves other requests. The fiber scheduler lets the loop drive the
* tool's fiber until the tool returns.
*
* php examples/server/reactphp/server.php [127.0.0.1:8000]
*
* The transport writes streamed responses to the PHP output, which this server
* doesn't send to the client. So it answers with 501 when a tool sends a
* notification (e.g. progress) or waits on the client (elicitation, sampling)
* on a session, and for subscriptions/listen.
*/

$address = $argv[1] ?? '127.0.0.1:8000';

$server = Server::builder()
->setServerInfo('ReactPHP Server', '1.0.0')
->setLogger(logger())
->setContainer(container())
->setFiberScheduler(new ReactFiberScheduler())
->addTool([SlowTools::class, 'wait'], 'wait')
->addTool([SlowTools::class, 'checkServices'], 'check_services')
->build();

$factory = new Psr17Factory();

$http = new HttpServer(async(static function (ServerRequestInterface $request) use ($server, $factory): Response {
$response = $server->run(new StreamableHttpTransport($request, $factory, $factory, logger()));
$body = $response->getBody();

if ($body instanceof CallbackStream) {
return new Response(Response::STATUS_NOT_IMPLEMENTED, ['Content-Type' => 'text/plain'], 'This server does not support streamed responses.');
}

return new Response($response->getStatusCode(), $response->getHeaders(), (string) $body);
}));
$http->listen(new SocketServer($address));

logger()->info(sprintf('ReactPHP MCP server listening on http://%s', $address));
29 changes: 29 additions & 0 deletions examples/server/revolt/RevoltFiberScheduler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Example\Server\Revolt;

use Mcp\Server\Fiber\FiberSchedulerInterface;
use Mcp\Server\Fiber\FiberSettlement;
use Revolt\EventLoop;

/**
* Lets the event loop run while a request handler awaits I/O.
*/
final class RevoltFiberScheduler implements FiberSchedulerInterface
{
public function wait(FiberSettlement $settlement): void
{
$suspension = EventLoop::getSuspension();
$settlement->then($suspension->resume(...));
$suspension->suspend();
}
}
70 changes: 70 additions & 0 deletions examples/server/revolt/SlowTools.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Example\Server\Revolt;

use Amp\Future;
use Psr\Log\LoggerInterface;

use function Amp\async;
use function Amp\delay;

/**
* Tools awaiting I/O on the event loop, simulated with timers.
*/
final class SlowTools
{
/** @var array<string, float> latency of each simulated service, in seconds */
private const SERVICES = [
'database' => 0.3,
'cache' => 0.1,
'search' => 0.2,
];

public function __construct(
private readonly LoggerInterface $logger,
) {
}

/**
* Waits on the event loop without blocking other requests.
*
* @param float $seconds how long to wait, at most 5 seconds
*/
public function wait(float $seconds): string
{
$seconds = max(0.0, min($seconds, 5.0));
$this->logger->info('Waiting on the event loop.', ['seconds' => $seconds]);

delay($seconds);

return \sprintf('Waited %s seconds on the event loop.', $seconds);
}

/**
* Checks all services concurrently, in the time of the slowest one.
*
* @return array<string, string> the status of each service
*/
public function checkServices(): array
{
$checks = [];
foreach (self::SERVICES as $service => $latency) {
$checks[$service] = async(static function () use ($latency): string {
delay($latency);

return 'up';
});
}

return array_map(static fn (Future $check): string => $check->await(), $checks);
}
}
Loading
Loading