diff --git a/CHANGELOG.md b/CHANGELOG.md index e70a3223..9b941008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file. ### Server +* Add `Builder::setFiberFactory()` to let integrations propagate execution context into handler fibers in both protocol eras. * [BC Break] Require `phpdocumentor/reflection-docblock` `^6.0`, and with it `phpdocumentor/type-resolver` `^2.0` and `phpstan/phpdoc-parser` `^2.0`. Older versions cannot parse types like `non-empty-array`, and the SDK silently ignores such a `@param`. * Require `psr/container` `^1.1`, as `Container` is not compatible with `1.0`. * [BC Break] `DocBlockParser::parseDocBlock()` no longer accepts `null`; pass `ReflectionMethod::getDocComment()` as is diff --git a/docs/run/server-builder.md b/docs/run/server-builder.md index 4eae36f0..7076d76f 100644 --- a/docs/run/server-builder.md +++ b/docs/run/server-builder.md @@ -274,6 +274,45 @@ $server = Server::builder() ->setEventDispatcher($eventDispatcher); ``` +### Handler Fiber Context + +Handlers run in fibers, which do not automatically inherit context from the caller. +Use `setFiberFactory()` to propagate context such as the active OpenTelemetry span: + +```php +use Mcp\Server; +use Mcp\Server\FiberFactoryInterface; +use OpenTelemetry\Context\Context; + +$server = Server::builder() + ->setFiberFactory(new class implements FiberFactoryInterface { + public function create(\Closure $callback): \Fiber + { + $context = Context::getCurrent(); + + return new \Fiber(static function () use ($context, $callback) { + $scope = $context->activate(); + try { + return $callback(); + } finally { + $scope->detach(); + } + }); + } + }) + ->build(); +``` + +The factory runs in the parent context and must return an **unstarted** fiber that invokes the +callback once, preserving its return value, exceptions and suspensions. Attach context inside +the fiber and release it in `finally`, including when a suspended fiber is discarded. +The SDK continues to start and resume the fiber. + +This example uses OpenTelemetry's default fiber-bound context storage, which keeps contexts +isolated across suspensions and resumptions. It requires the application's `open-telemetry/context` +package; the SDK does not depend on OpenTelemetry. The factory applies to both protocol eras, +including `buildStateless()`. Without a factory, the SDK creates ordinary PHP fibers as before. + ## Complete Example Here's a comprehensive example showing all major configuration options: @@ -352,6 +391,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) | +| `setFiberFactory()` | fiberFactory | Propagate execution context into handler fibers | | `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 | diff --git a/src/Server/Builder.php b/src/Server/Builder.php index db2b03c6..4eaa3e17 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -106,6 +106,7 @@ final class Builder private CacheInterface|CacheItemPoolInterface|null $discoveryCache = null; private ?EventDispatcherInterface $eventDispatcher = null; + private ?FiberFactoryInterface $fiberFactory = null; private ?ContainerInterface $container = null; @@ -600,6 +601,13 @@ public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): s return $this; } + public function setFiberFactory(FiberFactoryInterface $fiberFactory): self + { + $this->fiberFactory = $fiberFactory; + + return $this; + } + /** * Provides a PSR-11 DI container, primarily for resolving user-defined handler classes. * Defaults to a basic internal container. @@ -1003,6 +1011,7 @@ public function build(): Server : null, requestStateCodec: $this->requestStateCodec(), sessionLock: $this->sessionLock, + fiberFactory: $this->fiberFactory, ); $modernVersions = $this->modernVersions ?? ProtocolVersion::modernVersions(); @@ -1047,6 +1056,7 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202 extensionMethods: $this->extensionMethods, eventDispatcher: $parts['eventDispatcher'], requestStateBinder: $this->requestStateBinder, + fiberFactory: $this->fiberFactory, ); } diff --git a/src/Server/FiberFactoryInterface.php b/src/Server/FiberFactoryInterface.php new file mode 100644 index 00000000..0a403052 --- /dev/null +++ b/src/Server/FiberFactoryInterface.php @@ -0,0 +1,31 @@ + + */ +interface FiberFactoryInterface +{ + /** + * Returns an unstarted fiber invoking the callback once, preserving its return value, exceptions and suspensions. + * + * @template TReturn + * + * @param \Closure(): TReturn $callback + * + * @return \Fiber + */ + public function create(\Closure $callback): \Fiber; +} diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index 61c11cc1..6941f3cb 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -123,6 +123,7 @@ public function __construct( private readonly ?InputRequiredShim $inputRequiredShim = null, private readonly ?RequestStateCodec $requestStateCodec = null, private readonly ?SessionLockInterface $sessionLock = null, + private readonly ?FiberFactoryInterface $fiberFactory = null, ) { $this->awaitedRequestIds = new \WeakMap(); $this->fiberOutgoingMessages = new \WeakMap(); @@ -411,8 +412,7 @@ private function handleRequest(TransportInterface $transport, Request $request, // One fiber for the whole exchange: with the shim, the handler // re-enters inside it each round rather than needing a new one. // Handled in the fiber, as the transport resumes it outside the catch below. - /** @var McpFiber $fiber */ - $fiber = new \Fiber(static function () use ($handler, $request, $session, $shim, $codec, $dispatchResult, $errorForThrowable, $saveResumedSession, &$suspended): Response|Error { + $callback = static function () use ($handler, $request, $session, $shim, $codec, $dispatchResult, $errorForThrowable, $saveResumedSession, &$suspended): Response|Error { try { $result = $handler->handle($request, $session); $result = $shim?->fulfill($result, $handler, $request, $session, $codec) ?? $result; @@ -427,7 +427,9 @@ private function handleRequest(TransportInterface $transport, Request $request, } return $result; - }); + }; + /** @var McpFiber $fiber */ + $fiber = $this->fiberFactory?->create($callback) ?? new \Fiber($callback); $result = $fiber->start(); diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 52e717fd..46d70b69 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -31,6 +31,7 @@ use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\Authorization\AccessToken; use Mcp\Server\Configuration; +use Mcp\Server\FiberFactoryInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Protocol; use Mcp\Server\Session\InMemorySessionStore; @@ -114,6 +115,7 @@ public function __construct( private readonly array $extensionMethods = [], private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly ?RequestStateBinderInterface $requestStateBinder = null, + private readonly ?FiberFactoryInterface $fiberFactory = null, ) { $this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy); @@ -693,7 +695,8 @@ private function unknownMethod(string $method, string|int|null $id): Error */ private function run(RequestHandlerInterface $handler, Request $request, Session $session, RequestMeta $meta): \Generator { - $fiber = new \Fiber(static fn (): mixed => $handler->handle($request, $session)); + $callback = static fn (): mixed => $handler->handle($request, $session); + $fiber = $this->fiberFactory?->create($callback) ?? new \Fiber($callback); $input = $session->get(InputContext::class); // The codec bound to this request, which dispatch() put on the session. $codec = $session->get(RequestStateCodec::class); diff --git a/tests/Unit/Fixtures/ContextFiberFactory.php b/tests/Unit/Fixtures/ContextFiberFactory.php new file mode 100644 index 00000000..f5f91015 --- /dev/null +++ b/tests/Unit/Fixtures/ContextFiberFactory.php @@ -0,0 +1,56 @@ + + */ +final class ContextFiberFactory implements FiberFactoryInterface +{ + public string $parent = 'parent'; + public int $created = 0; + public int $released = 0; + + /** @var \WeakMap */ + private \WeakMap $contexts; + + public function __construct() + { + $this->contexts = new \WeakMap(); + } + + public function create(\Closure $callback): \Fiber + { + $context = $this->current(); + ++$this->created; + + return new \Fiber(function () use ($context, $callback) { + $this->contexts[\Fiber::getCurrent() ?? throw new LogicException('No active fiber.')] = $context; + try { + return $callback(); + } finally { + unset($this->contexts[\Fiber::getCurrent()]); + ++$this->released; + } + }); + } + + public function current(): string + { + $fiber = \Fiber::getCurrent(); + + return null === $fiber ? $this->parent : $this->contexts[$fiber]; + } +} diff --git a/tests/Unit/Server/FiberFactoryTest.php b/tests/Unit/Server/FiberFactoryTest.php new file mode 100644 index 00000000..9536f09d --- /dev/null +++ b/tests/Unit/Server/FiberFactoryTest.php @@ -0,0 +1,259 @@ + + */ +final class FiberFactoryTest extends TestCase +{ + #[DataProvider('httpModes')] + public function testContextSurvivesInterleavedRequestsAndErrors(string $mode, bool $fail): void + { + $factory = new ContextFiberFactory(); + $seen = []; + $builder = $this->builder($factory)->addTool(static function (RequestContext $context) use ($factory, &$seen, $fail): string { + $seen[] = $factory->current(); + $context->getClientGateway()->progress(1, 2); + $seen[] = $factory->current(); + if ($fail) { + throw new RuntimeException('handler failed'); + } + + return $factory->current(); + }, name: 'probe'); + $server = 'standalone' === $mode ? $builder->buildStateless() : $builder->build(); + $session = 'legacy' === $mode ? $this->post($server, $this->initialize())->getHeaderLine('Mcp-Session-Id') : null; + $created = $factory->created; + $released = $factory->released; + + $factory->parent = 'first'; + $first = $this->post($server, $this->call('legacy' !== $mode), $session); + $this->assertSame('first', $factory->current()); + $factory->parent = 'second'; + $second = $this->post($server, $this->call('legacy' !== $mode), $session); + $this->assertSame(['first', 'second'], $seen); + $this->assertSame($released, $factory->released); + + $factory->parent = 'outside'; + $secondBody = $this->consume($second); + $firstBody = $this->consume($first); + $this->assertSame(['first', 'second', 'second', 'first'], $seen); + $this->assertSame('outside', $factory->current()); + $this->assertSame($created + 2, $factory->created); + $this->assertSame($released + 2, $factory->released); + if ($fail) { + $this->assertStringContainsString('"code":-32603', $firstBody); + $this->assertStringContainsString('"code":-32603', $secondBody); + } else { + $this->assertStringContainsString('"text":"first"', $firstBody); + $this->assertStringContainsString('"text":"second"', $secondBody); + } + } + + /** + * @return iterable + */ + public static function httpModes(): iterable + { + foreach (['legacy', 'modern', 'standalone'] as $mode) { + yield $mode => [$mode, false]; + yield $mode.' error' => [$mode, true]; + } + } + + #[DataProvider('httpModes')] + public function testContextIsReleasedWithoutSuspension(string $mode, bool $fail): void + { + $factory = new ContextFiberFactory(); + $builder = $this->builder($factory)->addTool(static function () use ($factory, $fail): string { + if ($fail) { + throw new RuntimeException($factory->current()); + } + + return $factory->current(); + }, name: 'probe'); + $server = 'standalone' === $mode ? $builder->buildStateless() : $builder->build(); + $session = 'legacy' === $mode ? $this->post($server, $this->initialize())->getHeaderLine('Mcp-Session-Id') : null; + $body = $this->consume($this->post($server, $this->call('legacy' !== $mode), $session)); + + $this->assertStringContainsString($fail ? '"code":-32603' : '"text":"parent"', $body); + $this->assertSame($factory->created, $factory->released); + $this->assertSame('parent', $factory->current()); + } + + public function testContextIsReleasedWhenAStreamIsAbandoned(): void + { + $factory = new ContextFiberFactory(); + $server = $this->builder($factory)->addTool(static function (RequestContext $context) use ($factory): string { + $context->getClientGateway()->progress(1, 2); + + return $factory->current(); + }, name: 'probe')->buildStateless(); + $response = $this->post($server, $this->call(true)); + $this->assertSame(1, $factory->created); + $this->assertSame(0, $factory->released); + unset($response); + + $this->assertSame(1, $factory->released); + $this->assertSame('parent', $factory->current()); + } + + public function testContextIsReleasedWhenElicitationDiscardsTheFiber(): void + { + $factory = new ContextFiberFactory(); + $seen = null; + $server = $this->builder($factory)->setRequestState(str_repeat('k', 32)) + ->addTool(static function (RequestContext $context) use ($factory, &$seen): string { + $seen = $factory->current(); + $context->getClientGateway()->elicit('Name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); + + return 'unreachable'; + }, name: 'probe')->buildStateless(); + $message = $this->call(true); + $message['params']['_meta'][RequestMeta::CLIENT_CAPABILITIES] = ['elicitation' => ['form' => new \stdClass()]]; + $body = $this->consume($this->post($server, $message)); + + $this->assertStringContainsString('"resultType":"input_required"', $body); + $this->assertSame('parent', $seen); + $this->assertSame(1, $factory->released); + $this->assertSame('parent', $factory->current()); + } + + #[DataProvider('eras')] + public function testStdioUsesTheFactory(bool $modern): void + { + $factory = new ContextFiberFactory(); + $builder = $this->builder($factory)->addTool(static function (RequestContext $context) use ($factory): string { + $before = $factory->current(); + $context->getClientGateway()->progress(1, 2); + + return $before.':'.$factory->current(); + }, name: 'probe'); + $input = fopen('php://temp', 'r+'); + $this->assertNotFalse($input); + $path = tempnam(sys_get_temp_dir(), 'mcp-fiber'); + $this->assertNotFalse($path); + $output = fopen($path, 'w'); + $this->assertNotFalse($output); + if (!$modern) { + fwrite($input, json_encode($this->initialize(), \JSON_THROW_ON_ERROR)."\n"); + } + fwrite($input, json_encode($this->call($modern), \JSON_THROW_ON_ERROR)."\n"); + rewind($input); + try { + $builder->build()->run(new StdioTransport($input, $output)); + $written = file_get_contents($path); + $this->assertNotFalse($written); + $this->assertStringContainsString('"text":"parent:parent"', $written); + $this->assertStringContainsString('"progressToken":"p"', $written); + $this->assertSame($factory->created, $factory->released); + } finally { + unlink($path); + } + } + + /** + * @return iterable + */ + public static function eras(): iterable + { + yield 'legacy' => [false]; + yield 'modern' => [true]; + } + + private function builder(ContextFiberFactory $factory): Builder + { + return Server::builder()->setServerInfo('context-test', '1')->setFiberFactory($factory); + } + + /** + * @return array + */ + private function initialize(): array + { + return ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => [ + 'protocolVersion' => '2025-11-25', 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'test', 'version' => '1'], + ]]; + } + + /** + * @return array + */ + private function call(bool $modern): array + { + $meta = ['progressToken' => 'p']; + if ($modern) { + $meta[RequestMeta::PROTOCOL_VERSION] = '2026-07-28'; + $meta[RequestMeta::CLIENT_CAPABILITIES] = new \stdClass(); + } + + return ['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => 'probe', '_meta' => $meta]]; + } + + /** + * @param array $message + */ + private function post(Server|StatelessProtocol $server, array $message, ?string $session = null): ResponseInterface + { + $factory = new Psr17Factory(); + $request = $factory->createServerRequest('POST', 'http://localhost/mcp') + ->withHeader('Content-Type', 'application/json') + ->withHeader('Accept', 'application/json, text/event-stream') + ->withBody($factory->createStream(json_encode($message, \JSON_THROW_ON_ERROR))); + if (null !== $session) { + $request = $request->withHeader('Mcp-Session-Id', $session)->withHeader('MCP-Protocol-Version', '2025-11-25'); + } elseif (isset($message['params']['_meta'][RequestMeta::PROTOCOL_VERSION])) { + $request = $request->withHeader('MCP-Protocol-Version', '2026-07-28') + ->withHeader('Mcp-Method', 'tools/call')->withHeader('Mcp-Name', 'probe'); + } + + return $server instanceof Server + ? $server->run(new StreamableHttpTransport($request, $factory, $factory)) + : (new StatelessHttpTransport($server, $factory, $factory))->handle($request); + } + + private function consume(ResponseInterface $response): string + { + $output = ''; + ob_start(static function (string $chunk) use (&$output): string { + $output .= $chunk; + + return ''; + }); + try { + $body = $response->getBody()->getContents(); + } finally { + ob_end_flush(); + } + + return $output.$body; + } +}