[Server] Add Bidirectional Client Communication Support (#109)

* feat(server): add bidirectional client communication support

* fix(server): use correct session ID string conversion method

* fix: clean up docblocks for request handler methods in Builder

* fix: merge artifacts
This commit is contained in:
Kyrian Obikwelu 2025-10-26 22:48:17 +01:00 • committed by GitHub
commit f1b471a219
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1973 additions and 445 deletions

View file

@ -12,6 +12,7 @@
namespace Mcp\Capability\Discovery;
use Mcp\Capability\Attribute\Schema;
use Mcp\Server\ClientGateway;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
/**
@ -409,10 +410,19 @@ class SchemaGenerator
$parametersInfo = [];
foreach ($reflection->getParameters() as $rp) {
$reflectionType = $rp->getType();
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
$typeName = $reflectionType->getName();
if (is_a($typeName, ClientGateway::class, true)) {
continue;
}
}
$paramName = $rp->getName();
$paramTag = $paramTags['$'.$paramName] ?? null;
$reflectionType = $rp->getType();
$typeString = $this->getParameterTypeString($rp, $paramTag);
$description = $this->docBlockParser->getParamDescription($paramTag);
$hasDefault = $rp->isDefaultValueAvailable();

View file

@ -13,6 +13,7 @@ namespace Mcp\Capability\Registry;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Exception\RegistryException;
use Mcp\Server\ClientGateway;
use Psr\Container\ContainerInterface;
/**
@ -89,6 +90,17 @@ final class ReferenceHandler implements ReferenceHandlerInterface
$paramName = $parameter->getName();
$paramPosition = $parameter->getPosition();
// Check if parameter is a special injectable type
$type = $parameter->getType();
if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
$typeName = $type->getName();
if (ClientGateway::class === $typeName && isset($arguments['_session'])) {
$finalArgs[$paramPosition] = new ClientGateway($arguments['_session']);
continue;
}
}
if (isset($arguments[$paramName])) {
$argument = $arguments[$paramName];
try {

View file

@ -59,7 +59,13 @@ abstract class Request implements HasMethodInterface, MessageInterface
$request->id = $data['id'];
if (isset($data['params']['_meta'])) {
$request->meta = $data['params']['_meta'];
$meta = $data['params']['_meta'];
if ($meta instanceof \stdClass) {
$meta = (array) $meta;
}
if (\is_array($meta)) {
$request->meta = $meta;
}
}
return $request;
@ -75,6 +81,33 @@ abstract class Request implements HasMethodInterface, MessageInterface
return $this->id;
}
/**
* @return array<string, mixed>|null
*/
public function getMeta(): ?array
{
return $this->meta;
}
public function withId(string|int $id): static
{
$clone = clone $this;
$clone->id = $id;
return $clone;
}
/**
* @param array<string, mixed>|null $meta
*/
public function withMeta(?array $meta): static
{
$clone = clone $this;
$clone->meta = $meta;
return $clone;
}
/**
* @return RequestData
*/

View file

@ -14,23 +14,26 @@ namespace Mcp\Schema\JsonRpc;
use Mcp\Exception\InvalidArgumentException;
/**
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
* @template TResult
*
* @phpstan-type ResponseData array{
* jsonrpc: string,
* id: string|int,
* result: array<string, mixed>,
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class Response implements MessageInterface
{
/**
* @param string|int $id this MUST be the same as the value of the id member in the Request Object
* @param ResultInterface|array<string, mixed> $result the value of this member is determined by the method invoked on the Server
* @param string|int $id this MUST be the same as the value of the id member in the Request Object
* @param TResult $result the value of this member is determined by the method invoked on the Server
*/
public function __construct(
public readonly string|int $id,
public readonly ResultInterface|array $result,
/** @var TResult */
public readonly mixed $result,
) {
}
@ -41,6 +44,8 @@ class Response implements MessageInterface
/**
* @param ResponseData $data
*
* @return self<array<string, mixed>>
*/
public static function fromArray(array $data): self
{
@ -67,7 +72,7 @@ class Response implements MessageInterface
* @return array{
* jsonrpc: string,
* id: string|int,
* result: ResultInterface,
* result: mixed,
* }
*/
public function jsonSerialize(): array

View file

@ -11,6 +11,7 @@
namespace Mcp\Schema\Result;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Content\AudioContent;
use Mcp\Schema\Content\ImageContent;
use Mcp\Schema\Content\TextContent;
@ -40,6 +41,51 @@ class CreateSamplingMessageResult implements ResultInterface
) {
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
if (!isset($data['role']) || !\is_string($data['role'])) {
throw new InvalidArgumentException('Missing or invalid "role" in CreateSamplingMessageResult data.');
}
if (!isset($data['content']) || !\is_array($data['content'])) {
throw new InvalidArgumentException('Missing or invalid "content" in CreateSamplingMessageResult data.');
}
if (!isset($data['model']) || !\is_string($data['model'])) {
throw new InvalidArgumentException('Missing or invalid "model" in CreateSamplingMessageResult data.');
}
$role = Role::from($data['role']);
$contentPayload = $data['content'];
$content = self::hydrateContent($contentPayload);
$stopReason = isset($data['stopReason']) && \is_string($data['stopReason']) ? $data['stopReason'] : null;
return new self($role, $content, $data['model'], $stopReason);
}
/**
* @param array<string, mixed> $contentData
*/
private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent
{
$type = $contentData['type'] ?? null;
if (!\is_string($type)) {
throw new InvalidArgumentException('Missing or invalid "type" in sampling content payload.');
}
return match ($type) {
'text' => TextContent::fromArray($contentData),
'image' => ImageContent::fromArray($contentData),
'audio' => AudioContent::fromArray($contentData),
default => throw new InvalidArgumentException(\sprintf('Unsupported sampling content type "%s".', $type)),
};
}
/**
* @return array{
* role: string,

View file

@ -43,12 +43,12 @@ final class Server
*/
public function run(TransportInterface $transport): mixed
{
$this->logger->info('Running server...');
$transport->initialize();
$this->protocol->connect($transport);
$this->logger->info('Running server...');
try {
return $transport->listen();
} finally {

View file

@ -63,7 +63,7 @@ final class Builder
private ?string $instructions = null;
/**
* @var array<int, RequestHandlerInterface>
* @var array<int, RequestHandlerInterface<mixed>>
*/
private array $requestHandlers = [];
@ -176,6 +176,8 @@ final class Builder
/**
* Register a single custom method handler.
*
* @param RequestHandlerInterface<mixed> $handler
*/
public function addRequestHandler(RequestHandlerInterface $handler): self
{
@ -187,7 +189,7 @@ final class Builder
/**
* Register multiple custom method handlers.
*
* @param iterable<int, RequestHandlerInterface> $handlers
* @param iterable<RequestHandlerInterface<mixed>> $handlers
*/
public function addRequestHandlers(iterable $handlers): self
{

View file

@ -0,0 +1,183 @@
<?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;
use Mcp\Schema\Content\SamplingMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\LoggingLevel;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Notification;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\ModelPreferences;
use Mcp\Schema\Notification\LoggingMessageNotification;
use Mcp\Schema\Notification\ProgressNotification;
use Mcp\Schema\Request\CreateSamplingMessageRequest;
use Mcp\Schema\Result\CreateSamplingMessageResult;
use Mcp\Server\Session\SessionInterface;
/**
* Helper class for tools to communicate with the client.
*
* This class provides a clean API for element handlers to send requests and notifications
* to the client. It uses PHP Fibers internally to make the communication appear
* synchronous while the transport handles all blocking operations.
*
* Example usage in a tool:
* ```php
* public function analyze(string $text, ClientGateway $client): string {
* // Send progress notification
* $client->notify(new ProgressNotification("Starting analysis..."));
*
* // Request LLM sampling from client
* $response = $client->request(new SamplingRequest($text));
*
* return $response->content->text;
* }
* ```
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
final class ClientGateway
{
public function __construct(
private readonly SessionInterface $session,
) {
}
/**
* Send a notification to the client (fire and forget).
*
* This suspends the Fiber to let the transport flush the notification via SSE,
* then immediately resumes execution.
*/
public function notify(Notification $notification): void
{
\Fiber::suspend([
'type' => 'notification',
'notification' => $notification,
'session_id' => $this->session->getId()->toRfc4122(),
]);
}
/**
* Convenience method to send a logging notification to the client.
*/
public function log(LoggingLevel $level, mixed $data, ?string $logger = null): void
{
$this->notify(new LoggingMessageNotification($level, $data, $logger));
}
/**
* Convenience method to send a progress notification to the client.
*/
public function progress(float $progress, ?float $total = null, ?string $message = null): void
{
$meta = $this->session->get(Protocol::SESSION_ACTIVE_REQUEST_META, []);
$progressToken = $meta['progressToken'] ?? null;
if (null === $progressToken) {
// Per the spec the client never asked for progress, so just bail.
return;
}
$this->notify(new ProgressNotification($progressToken, $progress, $total, $message));
}
/**
* Send a request to the client and wait for a response (blocking).
*
* This suspends the Fiber and waits for the client to respond. The transport
* handles polling the session for the response and resuming the Fiber when ready.
*
* @param Request $request The request to send
* @param int $timeout Maximum time to wait for response (seconds)
*
* @return Response<array<string, mixed>>|Error The client's response message
*
* @throws \RuntimeException If Fiber support is not available
*/
public function request(Request $request, int $timeout = 120): Response|Error
{
$response = \Fiber::suspend([
'type' => 'request',
'request' => $request,
'session_id' => $this->session->getId()->toRfc4122(),
'timeout' => $timeout,
]);
if (!$response instanceof Response && !$response instanceof Error) {
throw new \RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.');
}
return $response;
}
/**
* Create and send an LLM sampling requests.
*
* @param CreateSamplingMessageRequest $request The request to send
* @param int $timeout The timeout in seconds
*
* @return Response<CreateSamplingMessageResult>|Error The sampling response
*/
public function createMessage(CreateSamplingMessageRequest $request, int $timeout = 120): Response|Error
{
$response = $this->request($request, $timeout);
if ($response instanceof Error) {
return $response;
}
$result = CreateSamplingMessageResult::fromArray($response->result);
return new Response($response->getId(), $result);
}
/**
* Convenience method for LLM sampling requests.
*
* @param string $prompt The prompt for the LLM
* @param int $maxTokens Maximum tokens to generate
* @param int $timeout The timeout in seconds
* @param array<string, mixed> $options Additional sampling options (temperature, etc.)
*
* @return Response<CreateSamplingMessageResult>|Error The sampling response
*/
public function sample(string $prompt, int $maxTokens = 1000, int $timeout = 120, array $options = []): Response|Error
{
$preferences = $options['preferences'] ?? null;
if (\is_array($preferences)) {
$preferences = ModelPreferences::fromArray($preferences);
}
if (null !== $preferences && !$preferences instanceof ModelPreferences) {
throw new \InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.');
}
$samplingRequest = new CreateSamplingMessageRequest(
messages: [
new SamplingMessage(Role::User, new TextContent(text: $prompt)),
],
maxTokens: $maxTokens,
preferences: $preferences,
systemPrompt: $options['systemPrompt'] ?? null,
includeContext: $options['includeContext'] ?? null,
temperature: $options['temperature'] ?? null,
stopSequences: $options['stopSequences'] ?? null,
metadata: $options['metadata'] ?? null,
);
return $this->createMessage($samplingRequest, $timeout);
}
}

View file

@ -26,6 +26,8 @@ use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* @implements RequestHandlerInterface<CallToolResult>
*
* @author Christopher Hertel <mail@christopher-hertel.de>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
@ -43,6 +45,9 @@ final class CallToolHandler implements RequestHandlerInterface
return $request instanceof CallToolRequest;
}
/**
* @return Response<CallToolResult>|Error
*/
public function handle(Request $request, SessionInterface $session): Response|Error
{
\assert($request instanceof CallToolRequest);
@ -58,6 +63,8 @@ final class CallToolHandler implements RequestHandlerInterface
throw new ToolNotFoundException($request);
}
$arguments['_session'] = $session;
$result = $this->referenceHandler->handle($reference, $arguments);
if (!$result instanceof CallToolResult) {

View file

@ -24,6 +24,8 @@ use Psr\Container\ContainerInterface;
/**
* Handles completion/complete requests.
*
* @implements RequestHandlerInterface<CompletionCompleteResult>
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
final class CompletionCompleteHandler implements RequestHandlerInterface
@ -39,6 +41,9 @@ final class CompletionCompleteHandler implements RequestHandlerInterface
return $request instanceof CompletionCompleteRequest;
}
/**
* @return Response<CompletionCompleteResult>|Error
*/
public function handle(Request $request, SessionInterface $session): Response|Error
{
\assert($request instanceof CompletionCompleteRequest);

View file

@ -26,6 +26,8 @@ use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* @implements RequestHandlerInterface<GetPromptResult>
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class GetPromptHandler implements RequestHandlerInterface
@ -42,6 +44,9 @@ final class GetPromptHandler implements RequestHandlerInterface
return $request instanceof GetPromptRequest;
}
/**
* @return Response<GetPromptResult>|Error
*/
public function handle(Request $request, SessionInterface $session): Response|Error
{
\assert($request instanceof GetPromptRequest);
@ -55,6 +60,8 @@ final class GetPromptHandler implements RequestHandlerInterface
throw new PromptNotFoundException($request);
}
$arguments['_session'] = $session;
$result = $this->referenceHandler->handle($reference, $arguments);
$formatted = $reference->formatResult($result);
@ -67,11 +74,11 @@ final class GetPromptHandler implements RequestHandlerInterface
} catch (PromptGetException|ExceptionInterface $e) {
$this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]);
return Error::forInternalError('Error while handling prompt', $request->getId());
return Error::forInternalError('Error while handling prompt: '.$e->getMessage(), $request->getId());
} catch (\Throwable $e) {
$this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]);
return Error::forInternalError('Error while handling prompt', $request->getId());
return Error::forInternalError('Error while handling prompt: '.$e->getMessage(), $request->getId());
}
}
}

View file

@ -21,6 +21,8 @@ use Mcp\Server\Configuration;
use Mcp\Server\Session\SessionInterface;
/**
* @implements RequestHandlerInterface<InitializeResult>
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class InitializeHandler implements RequestHandlerInterface
@ -35,6 +37,9 @@ final class InitializeHandler implements RequestHandlerInterface
return $request instanceof InitializeRequest;
}
/**
* @return Response<InitializeResult>
*/
public function handle(Request $request, SessionInterface $session): Response
{
\assert($request instanceof InitializeRequest);

View file

@ -20,6 +20,8 @@ use Mcp\Schema\Result\ListPromptsResult;
use Mcp\Server\Session\SessionInterface;
/**
* @implements RequestHandlerInterface<ListPromptsResult>
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class ListPromptsHandler implements RequestHandlerInterface
@ -36,6 +38,8 @@ final class ListPromptsHandler implements RequestHandlerInterface
}
/**
* @return Response<ListPromptsResult>
*
* @throws InvalidCursorException
*/
public function handle(Request $request, SessionInterface $session): Response

View file

@ -20,6 +20,8 @@ use Mcp\Schema\Result\ListResourceTemplatesResult;
use Mcp\Server\Session\SessionInterface;
/**
* @implements RequestHandlerInterface<ListResourceTemplatesResult>
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class ListResourceTemplatesHandler implements RequestHandlerInterface
@ -36,6 +38,8 @@ final class ListResourceTemplatesHandler implements RequestHandlerInterface
}
/**
* @return Response<ListResourceTemplatesResult>
*
* @throws InvalidCursorException
*/
public function handle(Request $request, SessionInterface $session): Response

View file

@ -20,6 +20,8 @@ use Mcp\Schema\Result\ListResourcesResult;
use Mcp\Server\Session\SessionInterface;
/**
* @implements RequestHandlerInterface<ListResourcesResult>
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class ListResourcesHandler implements RequestHandlerInterface
@ -36,6 +38,8 @@ final class ListResourcesHandler implements RequestHandlerInterface
}
/**
* @return Response<ListResourcesResult>
*
* @throws InvalidCursorException
*/
public function handle(Request $request, SessionInterface $session): Response

View file

@ -20,6 +20,8 @@ use Mcp\Schema\Result\ListToolsResult;
use Mcp\Server\Session\SessionInterface;
/**
* @implements RequestHandlerInterface<ListToolsResult>
*
* @author Christopher Hertel <mail@christopher-hertel.de>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
@ -37,6 +39,8 @@ final class ListToolsHandler implements RequestHandlerInterface
}
/**
* @return Response<ListToolsResult>
*
* @throws InvalidCursorException When the cursor is invalid
*/
public function handle(Request $request, SessionInterface $session): Response

View file

@ -18,6 +18,8 @@ use Mcp\Schema\Result\EmptyResult;
use Mcp\Server\Session\SessionInterface;
/**
* @implements RequestHandlerInterface<EmptyResult>
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class PingHandler implements RequestHandlerInterface
@ -27,6 +29,9 @@ final class PingHandler implements RequestHandlerInterface
return $request instanceof PingRequest;
}
/**
* @return Response<EmptyResult>
*/
public function handle(Request $request, SessionInterface $session): Response
{
\assert($request instanceof PingRequest);

View file

@ -25,6 +25,8 @@ use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* @implements RequestHandlerInterface<ReadResourceResult>
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class ReadResourceHandler implements RequestHandlerInterface
@ -41,6 +43,9 @@ final class ReadResourceHandler implements RequestHandlerInterface
return $request instanceof ReadResourceRequest;
}
/**
* @return Response<ReadResourceResult>|Error
*/
public function handle(Request $request, SessionInterface $session): Response|Error
{
\assert($request instanceof ReadResourceRequest);
@ -55,7 +60,12 @@ final class ReadResourceHandler implements RequestHandlerInterface
throw new ResourceNotFoundException($request);
}
$result = $this->referenceHandler->handle($reference, ['uri' => $uri]);
$arguments = [
'uri' => $uri,
'_session' => $session,
];
$result = $this->referenceHandler->handle($reference, $arguments);
if ($reference instanceof ResourceTemplateReference) {
$formatted = $reference->formatResult($result, $uri, $reference->resourceTemplate->mimeType);

View file

@ -17,11 +17,16 @@ use Mcp\Schema\JsonRpc\Response;
use Mcp\Server\Session\SessionInterface;
/**
* @template TResult
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
interface RequestHandlerInterface
{
public function supports(Request $request): bool;
/**
* @return Response<TResult>|Error
*/
public function handle(Request $request, SessionInterface $session): Response|Error;
}

View file

@ -17,6 +17,7 @@ use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Notification;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\JsonRpc\ResultInterface;
use Mcp\Schema\Request\InitializeRequest;
use Mcp\Server\Handler\Notification\NotificationHandlerInterface;
use Mcp\Server\Handler\Request\RequestHandlerInterface;
@ -31,17 +32,35 @@ use Symfony\Component\Uid\Uuid;
/**
* @final
*
* @phpstan-import-type McpFiber from \Mcp\Server\Transport\TransportInterface
* @phpstan-import-type FiberSuspend from \Mcp\Server\Transport\TransportInterface
*
* @author Christopher Hertel <mail@christopher-hertel.de>
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class Protocol
{
/** Session key for request ID counter */
private const SESSION_REQUEST_ID_COUNTER = '_mcp.request_id_counter';
/** Session key for pending outgoing requests */
private const SESSION_PENDING_REQUESTS = '_mcp.pending_requests';
/** Session key for incoming client responses */
private const SESSION_RESPONSES = '_mcp.responses';
/** Session key for outgoing message queue */
private const SESSION_OUTGOING_QUEUE = '_mcp.outgoing_queue';
/** Session key for active request meta */
public const SESSION_ACTIVE_REQUEST_META = '_mcp.active_request_meta';
/** @var TransportInterface<mixed>|null */
private ?TransportInterface $transport = null;
/**
* @param array<int, RequestHandlerInterface> $requestHandlers
* @param array<int, NotificationHandlerInterface> $notificationHandlers
* @param array<int, RequestHandlerInterface<ResultInterface|array<string, mixed>>> $requestHandlers
* @param array<int, NotificationHandlerInterface> $notificationHandlers
*/
public function __construct(
private readonly array $requestHandlers,
@ -53,6 +72,14 @@ class Protocol
) {
}
/**
* @return TransportInterface<mixed>
*/
public function getTransport(): TransportInterface
{
return $this->transport;
}
/**
* Connect this protocol to a transport.
*
@ -72,6 +99,14 @@ class Protocol
$this->transport->onSessionEnd([$this, 'destroySession']);
$this->transport->setOutgoingMessagesProvider([$this, 'consumeOutgoingMessages']);
$this->transport->setPendingRequestsProvider([$this, 'getPendingRequests']);
$this->transport->setResponseFinder([$this, 'checkResponse']);
$this->transport->setFiberYieldHandler([$this, 'handleFiberYield']);
$this->logger->info('Protocol connected to transport', ['transport' => $transport::class]);
}
@ -91,7 +126,7 @@ class Protocol
} catch (\JsonException $e) {
$this->logger->warning('Failed to decode json message.', ['exception' => $e]);
$error = Error::forParseError($e->getMessage());
$this->sendResponse($error, ['session_id' => $sessionId]);
$this->sendResponse($error, null);
return;
}
@ -121,13 +156,15 @@ class Protocol
$this->logger->warning('Failed to create message.', ['exception' => $exception]);
$error = Error::forInvalidRequest($exception->getMessage());
$this->sendResponse($error, ['session_id' => $session->getId()]);
$this->sendResponse($error, $session);
}
private function handleRequest(Request $request, SessionInterface $session): void
{
$this->logger->info('Handling request.', ['request' => $request]);
$session->set(self::SESSION_ACTIVE_REQUEST_META, $request->getMeta());
$handlerFound = false;
foreach ($this->requestHandlers as $handler) {
@ -138,16 +175,41 @@ class Protocol
$handlerFound = true;
try {
$response = $handler->handle($request, $session);
$this->sendResponse($response, ['session_id' => $session->getId()]);
/** @var McpFiber $fiber */
$fiber = new \Fiber(fn () => $handler->handle($request, $session));
$result = $fiber->start();
if ($fiber->isSuspended()) {
if (\is_array($result) && isset($result['type'])) {
if ('notification' === $result['type']) {
$notification = $result['notification'];
$this->sendNotification($notification, $session);
} elseif ('request' === $result['type']) {
$request = $result['request'];
$timeout = $result['timeout'] ?? 120;
$this->sendRequest($request, $timeout, $session);
}
}
$this->transport->attachFiberToSession($fiber, $session->getId());
return;
} else {
$finalResult = $fiber->getReturn();
$this->sendResponse($finalResult, $session);
}
} catch (\InvalidArgumentException $e) {
$this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]);
$error = Error::forInvalidParams($e->getMessage(), $request->getId());
$this->sendResponse($error, ['session_id' => $session->getId()]);
$this->sendResponse($error, $session);
} catch (\Throwable $e) {
$this->logger->error(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]);
$error = Error::forInternalError($e->getMessage(), $request->getId());
$this->sendResponse($error, ['session_id' => $session->getId()]);
$this->sendResponse($error, $session);
}
break;
@ -155,14 +217,25 @@ class Protocol
if (!$handlerFound) {
$error = Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $request::getMethod()), $request->getId());
$this->sendResponse($error, ['session_id' => $session->getId()]);
$this->sendResponse($error, $session);
}
}
/**
* @param Response<array<string, mixed>>|Error $response
*/
private function handleResponse(Response|Error $response, SessionInterface $session): void
{
$this->logger->info('Handling response.', ['response' => $response]);
// TODO: Implement response handling
$this->logger->info('Handling response from client.', ['response' => $response]);
$messageId = $response->getId();
$session->set(self::SESSION_RESPONSES.".{$messageId}", $response->jsonSerialize());
$session->forget(self::SESSION_ACTIVE_REQUEST_META);
$this->logger->info('Client response stored in session', [
'message_id' => $messageId,
]);
}
private function handleNotification(Notification $notification, SessionInterface $session): void
@ -183,56 +256,250 @@ class Protocol
}
/**
* @param array<string, mixed> $context
* Sends a request to the client and returns the request ID.
*/
public function sendRequest(Request $request, array $context = []): void
public function sendRequest(Request $request, int $timeout, SessionInterface $session): int
{
$this->logger->info('Sending request.', ['request' => $request, 'context' => $context]);
// TODO: Implement request sending
$counter = $session->get(self::SESSION_REQUEST_ID_COUNTER, 1000);
$requestId = $counter++;
$session->set(self::SESSION_REQUEST_ID_COUNTER, $counter);
$requestWithId = $request->withId($requestId);
$this->logger->info('Queueing server request to client', [
'request_id' => $requestId,
'method' => $request::getMethod(),
]);
$pending = $session->get(self::SESSION_PENDING_REQUESTS, []);
$pending[$requestId] = [
'request_id' => $requestId,
'timeout' => $timeout,
'timestamp' => time(),
];
$session->set(self::SESSION_PENDING_REQUESTS, $pending);
$this->queueOutgoing($requestWithId, ['type' => 'request'], $session);
return $requestId;
}
/**
* @param array<string, mixed> $context
* Queues a notification for later delivery.
*/
public function sendResponse(Response|Error $response, array $context = []): void
public function sendNotification(Notification $notification, SessionInterface $session): void
{
$this->logger->info('Sending response.', ['response' => $response, 'context' => $context]);
$this->logger->info('Queueing server notification to client', [
'method' => $notification::getMethod(),
]);
$encoded = null;
$this->queueOutgoing($notification, ['type' => 'notification'], $session);
}
try {
if ($response instanceof Response && [] === $response->result) {
$encoded = json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT);
/**
* Sends a response either immediately or queued for later delivery.
*
* @param Response<ResultInterface|array<string, mixed>>|Error $response
* @param array<string, mixed> $context
*/
private function sendResponse(Response|Error $response, ?SessionInterface $session, array $context = []): void
{
if (null === $session) {
$this->logger->info('Sending immediate response', [
'response_id' => $response->getId(),
]);
try {
$encoded = json_encode($response, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$this->logger->error('Failed to encode response to JSON.', [
'message_id' => $response->getId(),
'exception' => $e,
]);
$fallbackError = new Error(
id: $response->getId(),
code: Error::INTERNAL_ERROR,
message: 'Response could not be encoded to JSON'
);
$encoded = json_encode($fallbackError, \JSON_THROW_ON_ERROR);
}
$encoded = json_encode($response, \JSON_THROW_ON_ERROR);
$context['type'] = 'response';
$this->transport->send($encoded, $context);
} else {
$this->logger->info('Queueing server response', [
'response_id' => $response->getId(),
]);
$this->queueOutgoing($response, ['type' => 'response'], $session);
}
}
/**
* Helper to queue outgoing messages in session.
*
* @param Request|Notification|Response<ResultInterface|array<string, mixed>>|Error $message
* @param array<string, mixed> $context
*/
private function queueOutgoing(Request|Notification|Response|Error $message, array $context, SessionInterface $session): void
{
try {
$encoded = json_encode($message, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$this->logger->error('Failed to encode response to JSON.', [
'message_id' => $response->getId(),
$this->logger->error('Failed to encode message to JSON.', [
'exception' => $e,
]);
$fallbackError = new Error(
id: $response->getId(),
code: Error::INTERNAL_ERROR,
message: 'Response could not be encoded to JSON'
);
$encoded = json_encode($fallbackError, \JSON_THROW_ON_ERROR);
return;
}
$context['type'] = 'response';
$this->transport->send($encoded, $context);
$queue = $session->get(self::SESSION_OUTGOING_QUEUE, []);
$queue[] = [
'message' => $encoded,
'context' => $context,
];
$session->set(self::SESSION_OUTGOING_QUEUE, $queue);
}
/**
* @param array<string, mixed> $context
* Consume (get and clear) all outgoing messages for a session.
*
* @return array<int, array{message: string, context: array<string, mixed>}>
*/
public function sendNotification(Notification $notification, array $context = []): void
public function consumeOutgoingMessages(Uuid $sessionId): array
{
$this->logger->info('Sending notification.', ['notification' => $notification, 'context' => $context]);
$context['type'] = 'notification';
// TODO: Implement notification sending
$session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
$queue = $session->get(self::SESSION_OUTGOING_QUEUE, []);
$session->set(self::SESSION_OUTGOING_QUEUE, []);
$session->save();
return $queue;
}
/**
* Check for a response to a specific request ID.
*
* When a response is found, it is removed from the session, and the
* corresponding pending request is also cleared.
*/
/**
* @return Response<array<string, mixed>>|Error|null
*/
public function checkResponse(int $requestId, Uuid $sessionId): Response|Error|null
{
$session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
$responseData = $session->get(self::SESSION_RESPONSES.".{$requestId}");
if (null === $responseData) {
return null;
}
$this->logger->debug('Found and consuming client response.', [
'request_id' => $requestId,
'session_id' => $sessionId->toRfc4122(),
]);
$session->set(self::SESSION_RESPONSES.".{$requestId}", null);
$pending = $session->get(self::SESSION_PENDING_REQUESTS, []);
unset($pending[$requestId]);
$session->set(self::SESSION_PENDING_REQUESTS, $pending);
$session->save();
try {
if (isset($responseData['error'])) {
return Error::fromArray($responseData);
}
return Response::fromArray($responseData);
} catch (\Throwable $e) {
$this->logger->error('Failed to reconstruct client response from session.', [
'request_id' => $requestId,
'exception' => $e,
'response_data' => $responseData,
]);
return null;
}
}
/**
* Get pending requests for a session.
*
* @return array<int, mixed> The pending requests
*/
public function getPendingRequests(Uuid $sessionId): array
{
$session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
return $session->get(self::SESSION_PENDING_REQUESTS, []);
}
/**
* Handle values yielded by Fibers during transport-managed resumes.
*
* @param FiberSuspend|null $yieldedValue
*/
public function handleFiberYield(mixed $yieldedValue, ?Uuid $sessionId): void
{
if (!$sessionId) {
$this->logger->warning('Fiber yielded value without associated session context.');
return;
}
if (!\is_array($yieldedValue) || !isset($yieldedValue['type'])) {
$this->logger->warning('Fiber yielded unexpected payload.', [
'payload' => $yieldedValue,
'session_id' => $sessionId->toRfc4122(),
]);
return;
}
$session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore);
$payloadSessionId = $yieldedValue['session_id'] ?? null;
if (\is_string($payloadSessionId) && $payloadSessionId !== $sessionId->toRfc4122()) {
$this->logger->warning('Fiber yielded payload with mismatched session ID.', [
'payload_session_id' => $payloadSessionId,
'expected_session_id' => $sessionId->toRfc4122(),
]);
}
try {
if ('notification' === $yieldedValue['type']) {
$notification = $yieldedValue['notification'] ?? null;
if (!$notification instanceof Notification) {
$this->logger->warning('Fiber yielded notification without Notification instance.', [
'payload' => $yieldedValue,
]);
return;
}
$this->sendNotification($notification, $session);
} elseif ('request' === $yieldedValue['type']) {
$request = $yieldedValue['request'] ?? null;
if (!$request instanceof Request) {
$this->logger->warning('Fiber yielded request without Request instance.', [
'payload' => $yieldedValue,
]);
return;
}
$timeout = isset($yieldedValue['timeout']) ? (int) $yieldedValue['timeout'] : 120;
$this->sendRequest($request, $timeout, $session);
} else {
$this->logger->warning('Fiber yielded unknown operation type.', [
'type' => $yieldedValue['type'],
]);
}
} finally {
$session->save();
}
}
/**
@ -261,7 +528,7 @@ class Protocol
// Spec: An initialize request must not be part of a batch.
if (\count($messages) > 1) {
$error = Error::forInvalidRequest('The "initialize" request MUST NOT be part of a batch.');
$this->sendResponse($error, ['session_id' => $sessionId]);
$this->sendResponse($error, null);
return null;
}
@ -269,24 +536,31 @@ class Protocol
// Spec: An initialize request must not have a session ID.
if ($sessionId) {
$error = Error::forInvalidRequest('A session ID MUST NOT be sent with an "initialize" request.');
$this->sendResponse($error);
$this->sendResponse($error, null);
return null;
}
return $this->sessionFactory->create($this->sessionStore);
$session = $this->sessionFactory->create($this->sessionStore);
$this->logger->debug('Created new session for initialize', [
'session_id' => $session->getId()->toRfc4122(),
]);
$this->transport->setSessionId($session->getId());
return $session;
}
if (!$sessionId) {
$error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.');
$this->sendResponse($error, ['status_code' => 400]);
$this->sendResponse($error, null, ['status_code' => 400]);
return null;
}
if (!$this->sessionStore->exists($sessionId)) {
$error = Error::forInvalidRequest('Session not found or has expired.');
$this->sendResponse($error, ['status_code' => 404]);
$this->sendResponse($error, null, ['status_code' => 404]);
return null;
}

Some files were not shown because too many files have changed in this diff Show more