Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ All notable changes to `mcp/sdk` will be documented in this file.
* [BC Break] `DocBlockParser::parseDocBlock()` no longer accepts `null`; pass `ReflectionMethod::getDocComment()` as is
* Fix tools with an array shape parameter whose keys are nullable or unions (e.g. `array{name: string, nickname: string|null}`) rejecting valid arguments with `-32602`: a `null` inside the shape no longer turns the parameter into `array|null`, and such keys get their own types instead of `object`, including lists and nested shapes (`string[]|null`, `array{x: int}|null`).
* Fix `array<string|null>` and other generics with a union element getting `items` of type `object`. A `null` inside a generic no longer makes the parameter nullable, and neither does a DocBlock class whose name contains `null`.
* Event loops
* Add `Builder::setFiberScheduler()` and `Mcp\Server\Fiber\FiberSchedulerInterface`: under an event loop — Revolt (AMPHP), ReactPHP's async — a request handler awaiting I/O suspends its fiber for the loop, and the server now waits through the scheduler until the handler suspends for the server again or returns. The guide shows a Revolt scheduler in ten lines.
* 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.

### Client

Expand Down
4 changes: 4 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,7 @@
"phpunit/phpunit": "^10.5",
"psr/cache": "^1.0 || ^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^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 +91,7 @@
"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\\Revolt\\": "examples/server/revolt/",
"Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/",
"Mcp\\Tests\\": "tests/"
},
Expand Down
33 changes: 33 additions & 0 deletions docs/run/server-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,38 @@ $server = Server::builder()
->setEventDispatcher($eventDispatcher);
```

### Fiber Scheduler

The server runs every request handler in a fiber, so that the handler can wait on the client — for an
elicitation, a sampling request, or to send a notification — through the
[`ClientGateway`](../handlers/client-communication.md). Under an event loop (Revolt, which AMPHP builds on, or
ReactPHP's async), the handler's own I/O suspends that same fiber, for the loop. Tell the server how to wait on
your runtime, and it leaves the fiber to the loop until the handler suspends for the server again or returns:

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

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

$server = Server::builder()
->setFiberScheduler(new RevoltFiberScheduler());
```

Without a scheduler, a handler whose fiber is suspended by anything but the server fails the request with a
`LogicException`, rather than being resumed with nothing. Under an event loop, wait on the client through the
gateway: a suspension payload a handler hands to `\Fiber::suspend()` itself, after the loop resumed it, reaches
the loop, not the server.

## Complete Example

Here's a comprehensive example showing all major configuration options:
Expand Down Expand Up @@ -344,6 +376,7 @@ $server = Server::builder()
| `setLogger()` | logger | Set PSR-3 logger |
| `setContainer()` | container | Set PSR-11 container |
| `setEventDispatcher()` | eventDispatcher | Set PSR-14 event dispatcher, see [Events](../advanced/events.md) |
| `setFiberScheduler()` | scheduler | Wait on an event loop (Revolt, ReactPHP) while a handler awaits I/O, see [Fiber Scheduler](#fiber-scheduler) |
| `setSchemaGenerator()` | schemaGenerator | Replace the generator that builds input schemas from PHP types, see [Schema generation](../servers/schemas.md) |
| `setSchemaValidator()` | schemaValidator | Replace the validator for tool arguments and structured output, see [Schema generation](../servers/schemas.md) |
| `setReferenceHandler()` | referenceHandler | Replace the class that calls the handlers of tools, resources and prompts |
Expand Down
13 changes: 13 additions & 0 deletions examples/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ 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).

## Debugging

You can enable debug output by setting the `DEBUG` environment variable to `1`, and additionally log to a file by
Expand Down
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);
}
}
69 changes: 69 additions & 0 deletions examples/server/revolt/server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?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 Amp\Http\Server\DefaultErrorHandler;
use Amp\Http\Server\Request;
use Amp\Http\Server\RequestHandler\ClosureRequestHandler;
use Amp\Http\Server\Response;
use Amp\Http\Server\SocketHttpServer;
use Http\Discovery\Psr17Factory;
use Mcp\Example\Server\Revolt\RevoltFiberScheduler;
use Mcp\Example\Server\Revolt\SlowTools;
use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport;
use Revolt\EventLoop;

/*
* An MCP server on the Revolt event loop, served by amphp/http-server.
*
* 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/revolt/server.php [127.0.0.1:8000]
*
* Responses are buffered, so a tool that waits on the client (elicitation,
* sampling) is not supported here.
*/

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

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

$factory = new Psr17Factory();

$http = SocketHttpServer::createForDirectAccess(logger());
$http->expose($address);
$http->start(new ClosureRequestHandler(static function (Request $request) use ($server, $factory): Response {
$psrRequest = $factory->createServerRequest($request->getMethod(), (string) $request->getUri())
->withBody($factory->createStream($request->getBody()->buffer()));
foreach ($request->getHeaders() as $name => $values) {
$psrRequest = $psrRequest->withHeader($name, $values);
}

$response = $server->run(new StreamableHttpTransport($psrRequest, $factory, $factory, logger()));

return new Response($response->getStatusCode(), $response->getHeaders(), (string) $response->getBody());
}), new DefaultErrorHandler());

logger()->info(sprintf('Revolt MCP server listening on http://%s', $address));

EventLoop::run();
20 changes: 20 additions & 0 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
use Mcp\Schema\Tool;
use Mcp\Schema\ToolAnnotations;
use Mcp\Server;
use Mcp\Server\Fiber\FiberSchedulerInterface;
use Mcp\Server\Handler\ElementHandlerInterface;
use Mcp\Server\Handler\Notification\NotificationHandlerInterface;
use Mcp\Server\Handler\PromptHandlerInterface;
Expand Down Expand Up @@ -135,6 +136,8 @@ final class Builder

private float $subscriptionLifetime = 30.0;

private ?FiberSchedulerInterface $fiberScheduler = null;

/** @var array<string, string> RPC method to the extension identifier defining it */
private array $extensionMethods = [];

Expand Down Expand Up @@ -344,6 +347,21 @@ public function setSubscriptionLifetime(float $seconds): self
return $this;
}

/**
* Sets the runtime that may suspend a request handler's fiber besides the server: an event loop.
*
* The server runs every request handler in a fiber, to let it wait on the client. Under an event
* loop — Revolt (AMPHP), ReactPHP's async — a handler awaiting I/O suspends that fiber for the loop:
* the server then waits through this scheduler until the handler suspends for the server again or
* returns, instead of resuming the fiber itself. Without one, such a handler fails the request.
*/
public function setFiberScheduler(FiberSchedulerInterface $scheduler): self
{
$this->fiberScheduler = $scheduler;

return $this;
}

/**
* Sets how long, and to whom, this server's answers may be cached (SEP-2549).
*
Expand Down Expand Up @@ -979,6 +997,7 @@ public function build(): Server
: null,
requestStateCodec: $this->requestStateCodec(),
sessionLock: $this->sessionLock,
fiberScheduler: $this->fiberScheduler,
);

$modernVersions = $this->modernVersions ?? ProtocolVersion::modernVersions();
Expand Down Expand Up @@ -1018,6 +1037,7 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202
notificationBus: $this->notificationBus,
extensionMethods: $this->extensionMethods,
eventDispatcher: $parts['eventDispatcher'],
fiberScheduler: $this->fiberScheduler,
);
}

Expand Down
5 changes: 3 additions & 2 deletions src/Server/ClientGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
use Mcp\Schema\Result\ListRootsResult;
use Mcp\Schema\Tool;
use Mcp\Schema\ToolChoice;
use Mcp\Server\Fiber\HandlerFibers;
use Mcp\Server\Session\SessionInterface;
use Mcp\Server\Suspension\NotificationSuspension;
use Mcp\Server\Suspension\RequestSuspension;
Expand Down Expand Up @@ -99,7 +100,7 @@ public function __construct(
*/
public function notify(Notification $notification): void
{
$result = \Fiber::suspend(new NotificationSuspension($notification, $this->session->getId()->toRfc4122()));
$result = HandlerFibers::suspend(new NotificationSuspension($notification, $this->session->getId()->toRfc4122()));

if ($result instanceof Error) {
throw new ClientException($result);
Expand Down Expand Up @@ -470,7 +471,7 @@ public function request(Request $request, int $timeout = 120): Response|Error
*/
private function suspend(Request $request, int $timeout, ?string $key = null): Response|Error
{
$response = \Fiber::suspend(new RequestSuspension($request, $this->session->getId()->toRfc4122(), $timeout, $key));
$response = HandlerFibers::suspend(new RequestSuspension($request, $this->session->getId()->toRfc4122(), $timeout, $key));

if (!$response instanceof Response && !$response instanceof Error) {
throw new RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.');
Expand Down
35 changes: 35 additions & 0 deletions src/Server/Fiber/FiberSchedulerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?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\Server\Fiber;

/**
* The runtime that may suspend a request handler's fiber besides the server: an event loop.
*
* The server runs every request handler in a fiber, so that the handler can wait on the client
* ({@see \Mcp\Server\ClientGateway}). Under an event loop — Revolt (AMPHP), ReactPHP's async, any fiber
* scheduler — the handler's own I/O suspends that same fiber, for the loop: the server must not resume
* it then, the loop will. The server hands control to the scheduler instead, until the handler suspends
* for the server again or returns.
*
* Without a scheduler, a handler fiber suspended by anything but the server fails the request.
*/
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, once the handler suspended for the server or
* returned; implement it with the runtime's own way of waiting for a callback.
*/
public function wait(FiberSettlement $settlement): void;
}
Loading
Loading