Skip to content

Commit 0c184ad

Browse files
bbdu3bbdu3chr-hertel
authored
[Server] Allow context propagation into handler fibers (#670)
* [Server] Allow context propagation into handler fibers Signed-off-by: bbdu3 <ergouyang854@gamil.com> * [Server] Free a discarded handler fiber right away * [Server] Wrap the handler instead of creating the fiber for context propagation --------- Signed-off-by: bbdu3 <ergouyang854@gamil.com> Co-authored-by: bbdu3 <ergouyang854@gamil.com> Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
1 parent 0d340e1 commit 0c184ad

11 files changed

Lines changed: 479 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
3333
* Add a `binder` argument to `Builder::setRequestState()` binding the modern era's `requestState` to the request it was minted for, as the 2026-07-28 specification recommends: the binder's value is covered by the state's MAC, and a state echoed on a request that binds to another value is refused like a forged one. `OriginatingRequestBinder` binds the authenticated principal, the method, the tool, prompt or resource, and a digest of the arguments; a token without a subject fails closed, and a `subject` resolver identifies users by another claim. `RequestStateBinderInterface` takes custom bindings. Without a binder, nothing changes.
3434
* Rotate the `requestState` signing key without failing the calls in flight: `Builder::setRequestState()` and `RequestStateCodec` take a list of keys, the first signing and every key verifying. The wire format is unchanged, and a single key works as before.
3535
* Log the name of a called tool at info level, so a server logging at INFO shows which tool is called. Its arguments stay at debug level, logged only once the tool is found and its input is valid.
36+
* Add `Builder::setFiberContext()` and `Mcp\Server\Fiber\FiberContextInterface` to carry execution context, e.g. the active OpenTelemetry context, into handler fibers in both protocol eras.
3637
* Event loops
3738
* 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 scheduler must wake the server on a later turn of the loop; the guide shows one for Revolt and one for ReactPHP, and the limits under an event loop.
3839
* 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.

‎docs/run/server-builder.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,46 @@ A fiber scheduler lets handlers await I/O while the event loop serves other requ
347347
- A [session lock](sessions.md#concurrent-requests) stays held while a handler awaits I/O. Another request of the
348348
same session in the same process waits on the event loop until the lock is free. This wait has no timeout. If
349349
another process holds the lock, the lock backend blocks the event loop while it waits, up to its timeout.
350+
### Fiber Context
351+
352+
Some libraries keep their context per fiber. For example, OpenTelemetry raises a warning when code reads its context
353+
in a fiber it doesn't know. Set a fiber context to carry the context of the caller into the fiber of the handler:
354+
355+
```php
356+
use Mcp\Server;
357+
use Mcp\Server\Fiber\FiberContextInterface;
358+
use OpenTelemetry\Context\Context;
359+
360+
final class OpenTelemetryFiberContext implements FiberContextInterface
361+
{
362+
public function wrap(\Closure $handler): \Closure
363+
{
364+
// runs in the context of the caller
365+
$context = Context::getCurrent();
366+
367+
// runs in the fiber of the handler
368+
return static function () use ($context, $handler): mixed {
369+
$scope = $context->activate();
370+
371+
try {
372+
return $handler();
373+
} finally {
374+
$scope->detach();
375+
}
376+
};
377+
}
378+
}
379+
380+
$server = Server::builder()
381+
->setFiberContext(new OpenTelemetryFiberContext());
382+
```
383+
384+
The returned closure must call `$handler` once and return its result. The `finally` block also runs when the server
385+
discards a suspended fiber.
386+
387+
Under an event loop, every request runs in its own fiber, so OpenTelemetry raises the same warning in `wrap()`. Activate
388+
a context in that fiber first, for example with `Context::getRoot()->activate()`. Fibers that a handler starts itself
389+
(e.g. with `Amp\async()`) don't get the context.
350390

351391
## Complete Example
352392

@@ -427,6 +467,7 @@ $server = Server::builder()
427467
| `setContainer()` | container | Set PSR-11 container |
428468
| `setEventDispatcher()` | eventDispatcher | Set PSR-14 event dispatcher, see [Events](../advanced/events.md) |
429469
| `setFiberScheduler()` | scheduler | Wait on an event loop (Revolt, ReactPHP) while a handler awaits I/O, see [Fiber Scheduler](#fiber-scheduler) |
470+
| `setFiberContext()` | context | Carry execution context into handler fibers, see [Fiber Context](#fiber-context) |
430471
| `setSchemaGenerator()` | schemaGenerator | Replace the generator that builds input schemas from PHP types, see [Schema generation](../servers/schemas.md) |
431472
| `setSchemaValidator()` | schemaValidator | Replace the validator for tool arguments and structured output, see [Schema generation](../servers/schemas.md) |
432473
| `setReferenceHandler()` | referenceHandler | Replace the class that calls the handlers of tools, resources and prompts |

‎src/Server/Builder.php‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
use Mcp\Schema\Tool;
4646
use Mcp\Schema\ToolAnnotations;
4747
use Mcp\Server;
48+
use Mcp\Server\Fiber\FiberContextInterface;
4849
use Mcp\Server\Fiber\FiberSchedulerInterface;
4950
use Mcp\Server\Handler\ElementHandlerInterface;
5051
use Mcp\Server\Handler\Notification\NotificationHandlerInterface;
@@ -142,6 +143,8 @@ final class Builder
142143

143144
private ?FiberSchedulerInterface $fiberScheduler = null;
144145

146+
private ?FiberContextInterface $fiberContext = null;
147+
145148
/** @var array<string, string> RPC method to the extension identifier defining it */
146149
private array $extensionMethods = [];
147150

@@ -370,6 +373,16 @@ public function setFiberScheduler(FiberSchedulerInterface $scheduler): self
370373
return $this;
371374
}
372375

376+
/**
377+
* Carries execution context, e.g. the active OpenTelemetry context, into the fibers request handlers run in.
378+
*/
379+
public function setFiberContext(FiberContextInterface $context): self
380+
{
381+
$this->fiberContext = $context;
382+
383+
return $this;
384+
}
385+
373386
/**
374387
* Sets how long, and to whom, this server's answers may be cached (SEP-2549).
375388
*
@@ -1022,6 +1035,7 @@ public function build(): Server
10221035
requestStateCodec: $this->requestStateCodec(),
10231036
sessionLock: $this->sessionLock,
10241037
fiberScheduler: $this->fiberScheduler,
1038+
fiberContext: $this->fiberContext,
10251039
);
10261040

10271041
$modernVersions = $this->modernVersions ?? ProtocolVersion::modernVersions();
@@ -1067,6 +1081,7 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202
10671081
eventDispatcher: $parts['eventDispatcher'],
10681082
requestStateBinder: $this->requestStateBinder,
10691083
fiberScheduler: $this->fiberScheduler,
1084+
fiberContext: $this->fiberContext,
10701085
);
10711086
}
10721087

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Server\Fiber;
13+
14+
/**
15+
* Carries execution context, e.g. the active OpenTelemetry context, into the fibers request handlers run in.
16+
*
17+
* @author bbdu3 <ergouyang854@gamil.com>
18+
*/
19+
interface FiberContextInterface
20+
{
21+
/**
22+
* Called in the context of the caller. The returned closure runs in the handler fiber and must call $handler once and return its result.
23+
*
24+
* @template TReturn
25+
*
26+
* @param \Closure(): TReturn $handler
27+
*
28+
* @return \Closure(): TReturn
29+
*/
30+
public function wrap(\Closure $handler): \Closure;
31+
}

‎src/Server/Fiber/HandlerFibers.php‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@ final class HandlerFibers
4848
*
4949
* @return \Fiber<mixed, mixed, TReturn, mixed>
5050
*/
51-
public static function create(\Closure $handler, ?FiberSchedulerInterface $scheduler = null, ?\Closure $errorForFailure = null, LoggerInterface $logger = new NullLogger()): \Fiber
51+
public static function create(\Closure $handler, ?FiberSchedulerInterface $scheduler = null, ?\Closure $errorForFailure = null, LoggerInterface $logger = new NullLogger(), ?FiberContextInterface $context = null): \Fiber
5252
{
5353
$state = new HandlerFiberState($scheduler, $errorForFailure, $logger);
54+
$handler = $context?->wrap($handler) ?? $handler;
5455
$fiber = new \Fiber(static function () use ($handler, $state): mixed {
5556
try {
5657
$result = $handler();
@@ -120,19 +121,27 @@ public static function isAbandoned(): bool
120121
* @throws LogicException when the request of the fiber was already answered, as nobody would resume it
121122
*/
122123
public static function suspend(object $suspension): mixed
124+
{
125+
// In its own frame: a local holding the fiber while it suspends would keep it alive until the next GC run.
126+
self::parkCurrentFiber($suspension);
127+
128+
return \Fiber::suspend($suspension);
129+
}
130+
131+
private static function parkCurrentFiber(object $suspension): void
123132
{
124133
$fiber = \Fiber::getCurrent();
125-
if (null !== $fiber) {
126-
$state = self::states()[$fiber] ?? null;
134+
if (null === $fiber) {
135+
return;
136+
}
127137

128-
if (true === $state?->isAbandoned()) {
129-
throw new LogicException('The client gateway cannot be used after the request was answered.');
130-
}
138+
$state = self::states()[$fiber] ?? null;
131139

132-
$state?->parked($suspension);
140+
if (true === $state?->isAbandoned()) {
141+
throw new LogicException('The client gateway cannot be used after the request was answered.');
133142
}
134143

135-
return \Fiber::suspend($suspension);
144+
$state?->parked($suspension);
136145
}
137146

138147
/**

‎src/Server/Protocol.php‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
use Mcp\Schema\Request\InitializeRequest;
3333
use Mcp\Server\Authorization\AccessToken;
3434
use Mcp\Server\Fiber\EventLoopSessionLock;
35+
use Mcp\Server\Fiber\FiberContextInterface;
3536
use Mcp\Server\Fiber\FiberSchedulerInterface;
3637
use Mcp\Server\Fiber\HandlerFibers;
3738
use Mcp\Server\Handler\Notification\NotificationHandlerInterface;
@@ -135,6 +136,7 @@ public function __construct(
135136
private readonly ?RequestStateCodec $requestStateCodec = null,
136137
?SessionLockInterface $sessionLock = null,
137138
private readonly ?FiberSchedulerInterface $fiberScheduler = null,
139+
private readonly ?FiberContextInterface $fiberContext = null,
138140
) {
139141
$this->sessionLock = null !== $sessionLock && null !== $fiberScheduler ? new EventLoopSessionLock($sessionLock, $fiberScheduler) : $sessionLock;
140142
$this->awaitedRequestIds = new \WeakMap();
@@ -456,7 +458,7 @@ private function handleRequest(TransportInterface $transport, Request $request,
456458
}
457459

458460
return $result;
459-
}, $this->fiberScheduler, static fn (\Throwable $e): ?Error => $protocol->get()?->errorForThrowable($e, $request, $session), $this->logger);
461+
}, $this->fiberScheduler, static fn (\Throwable $e): ?Error => $protocol->get()?->errorForThrowable($e, $request, $session), $this->logger, $this->fiberContext);
460462

461463
$result = HandlerFibers::start($fiber);
462464

‎src/Server/Stateless/StatelessProtocol.php‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use Mcp\Schema\Result\InputRequiredResult;
3232
use Mcp\Server\Authorization\AccessToken;
3333
use Mcp\Server\Configuration;
34+
use Mcp\Server\Fiber\FiberContextInterface;
3435
use Mcp\Server\Fiber\FiberSchedulerInterface;
3536
use Mcp\Server\Fiber\HandlerFibers;
3637
use Mcp\Server\Handler\Request\RequestHandlerInterface;
@@ -117,6 +118,7 @@ public function __construct(
117118
private readonly ?EventDispatcherInterface $eventDispatcher = null,
118119
private readonly ?RequestStateBinderInterface $requestStateBinder = null,
119120
private readonly ?FiberSchedulerInterface $fiberScheduler = null,
121+
private readonly ?FiberContextInterface $fiberContext = null,
120122
) {
121123
$this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy);
122124

@@ -689,7 +691,7 @@ private function unknownMethod(string $method, string|int|null $id): Error
689691
*/
690692
private function run(RequestHandlerInterface $handler, Request $request, Session $session, RequestMeta $meta): \Generator
691693
{
692-
$fiber = HandlerFibers::create(static fn (): mixed => $handler->handle($request, $session), $this->fiberScheduler, logger: $this->logger);
694+
$fiber = HandlerFibers::create(static fn (): mixed => $handler->handle($request, $session), $this->fiberScheduler, logger: $this->logger, context: $this->fiberContext);
693695
$input = $session->get(InputContext::class);
694696
// The codec bound to this request, which dispatch() put on the session.
695697
$codec = $session->get(RequestStateCodec::class);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Tests\Unit\Fixtures;
13+
14+
use Mcp\Exception\LogicException;
15+
use Mcp\Server\Fiber\FiberContextInterface;
16+
17+
/**
18+
* @author bbdu3 <ergouyang854@gamil.com>
19+
*/
20+
final class RecordingFiberContext implements FiberContextInterface
21+
{
22+
public string $parent = 'parent';
23+
public int $created = 0;
24+
public int $released = 0;
25+
26+
/** @var \WeakMap<object, string> */
27+
private \WeakMap $contexts;
28+
29+
public function __construct()
30+
{
31+
$this->contexts = new \WeakMap();
32+
}
33+
34+
public function wrap(\Closure $handler): \Closure
35+
{
36+
$context = $this->current();
37+
++$this->created;
38+
39+
return function () use ($context, $handler) {
40+
$this->contexts[\Fiber::getCurrent() ?? throw new LogicException('No active fiber.')] = $context;
41+
try {
42+
return $handler();
43+
} finally {
44+
unset($this->contexts[\Fiber::getCurrent()]);
45+
++$this->released;
46+
}
47+
};
48+
}
49+
50+
public function current(): string
51+
{
52+
$fiber = \Fiber::getCurrent();
53+
54+
return null === $fiber ? $this->parent : ($this->contexts[$fiber] ?? $this->parent);
55+
}
56+
}

0 commit comments

Comments
 (0)