[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:
parent
0963d243bc
commit
f1b471a219
21 changed files with 1973 additions and 445 deletions
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
183
src/Server/ClientGateway.php
Normal file
183
src/Server/ClientGateway.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
136
src/Server/Transport/BaseTransport.php
Normal file
136
src/Server/Transport/BaseTransport.php
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<?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\Transport;
|
||||
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* Provides a skeletal implementation of the TransportInterface to minimize
|
||||
* the effort required to implement this interface.
|
||||
*
|
||||
* @phpstan-import-type FiberResume from TransportInterface
|
||||
* @phpstan-import-type FiberReturn from TransportInterface
|
||||
* @phpstan-import-type FiberSuspend from TransportInterface
|
||||
* @phpstan-import-type McpFiber from TransportInterface
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
abstract class BaseTransport
|
||||
{
|
||||
use ManagesTransportCallbacks;
|
||||
|
||||
protected ?Uuid $sessionId = null;
|
||||
|
||||
/**
|
||||
* @var McpFiber|null
|
||||
*/
|
||||
protected ?\Fiber $sessionFiber = null;
|
||||
|
||||
public function __construct(
|
||||
protected readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function setSessionId(?Uuid $sessionId): void
|
||||
{
|
||||
$this->sessionId = $sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param McpFiber $fiber
|
||||
*/
|
||||
public function attachFiberToSession(\Fiber $fiber, Uuid $sessionId): void
|
||||
{
|
||||
$this->sessionFiber = $fiber;
|
||||
$this->sessionId = $sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{message: string, context: array<string, mixed>}>
|
||||
*/
|
||||
protected function getOutgoingMessages(?Uuid $sessionId): array
|
||||
{
|
||||
if ($sessionId && \is_callable($this->outgoingMessagesProvider)) {
|
||||
return ($this->outgoingMessagesProvider)($sessionId);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected function getPendingRequests(?Uuid $sessionId): array
|
||||
{
|
||||
if ($sessionId && \is_callable($this->pendingRequestsProvider)) {
|
||||
return ($this->pendingRequestsProvider)($sessionId);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @phpstan-return FiberResume
|
||||
*/
|
||||
protected function checkForResponse(int $requestId, ?Uuid $sessionId): Response|Error|null
|
||||
{
|
||||
if ($sessionId && \is_callable($this->responseFinder)) {
|
||||
return ($this->responseFinder)($requestId, $sessionId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FiberSuspend|null $yielded
|
||||
*/
|
||||
protected function handleFiberYield(mixed $yielded, ?Uuid $sessionId): void
|
||||
{
|
||||
if (null === $yielded || !\is_callable($this->fiberYieldHandler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
($this->fiberYieldHandler)($yielded, $sessionId);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Fiber yield handler failed.', [
|
||||
'exception' => $e,
|
||||
'sessionId' => $sessionId?->toRfc4122(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function handleMessage(string $payload, ?Uuid $sessionId): void
|
||||
{
|
||||
if (\is_callable($this->messageListener)) {
|
||||
($this->messageListener)($payload, $sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
protected function handleSessionEnd(?Uuid $sessionId): void
|
||||
{
|
||||
if ($sessionId && \is_callable($this->sessionEndListener)) {
|
||||
($this->sessionEndListener)($sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
156
src/Server/Transport/CallbackStream.php
Normal file
156
src/Server/Transport/CallbackStream.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?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\Transport;
|
||||
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* A PSR-7 StreamInterface that executes a callback when read.
|
||||
*
|
||||
* This enables true streaming with echo/flush() for SSE (Server-Sent Events).
|
||||
* The callback is executed once when the stream is first read and can write
|
||||
* directly to the output buffer (e.g. via echo + flush()).
|
||||
*
|
||||
* Example usage for SSE:
|
||||
* ```php
|
||||
* $stream = new CallbackStream(function() {
|
||||
* echo "event: message\n";
|
||||
* echo "data: {\"hello\":\"world\"}\n\n";
|
||||
* flush();
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
final class CallbackStream implements StreamInterface
|
||||
{
|
||||
private bool $called = false;
|
||||
|
||||
private ?\Throwable $exception = null;
|
||||
|
||||
/**
|
||||
* @param callable(): void $callback The callback to execute when stream is read
|
||||
*/
|
||||
public function __construct(private $callback, private LoggerInterface $logger = new NullLogger())
|
||||
{
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
try {
|
||||
$this->invoke();
|
||||
} catch (\Throwable $e) {
|
||||
$this->exception = $e;
|
||||
$this->logger->error(
|
||||
\sprintf('CallbackStream execution failed: %s', $e->getMessage()),
|
||||
['exception' => $e]
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function read($length): string
|
||||
{
|
||||
$this->invoke();
|
||||
|
||||
if (null !== $this->exception) {
|
||||
throw $this->exception;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getContents(): string
|
||||
{
|
||||
$this->invoke();
|
||||
|
||||
if (null !== $this->exception) {
|
||||
throw $this->exception;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function eof(): bool
|
||||
{
|
||||
return $this->called;
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
// No-op - callback-based stream doesn't need closing
|
||||
}
|
||||
|
||||
public function detach()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getSize(): ?int
|
||||
{
|
||||
return null; // Unknown size for callback streams
|
||||
}
|
||||
|
||||
public function tell(): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function isSeekable(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function seek($offset, $whence = \SEEK_SET): void
|
||||
{
|
||||
throw new \RuntimeException('Stream is not seekable');
|
||||
}
|
||||
|
||||
public function rewind(): void
|
||||
{
|
||||
throw new \RuntimeException('Stream is not seekable');
|
||||
}
|
||||
|
||||
public function isWritable(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function write($string): int
|
||||
{
|
||||
throw new \RuntimeException('Stream is not writable');
|
||||
}
|
||||
|
||||
public function isReadable(): bool
|
||||
{
|
||||
return !$this->called;
|
||||
}
|
||||
|
||||
private function invoke(): void
|
||||
{
|
||||
if ($this->called) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->called = true;
|
||||
$this->exception = null;
|
||||
($this->callback)();
|
||||
}
|
||||
|
||||
public function getMetadata($key = null)
|
||||
{
|
||||
return null === $key ? [] : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -18,15 +18,9 @@ use Symfony\Component\Uid\Uuid;
|
|||
*
|
||||
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
|
||||
*/
|
||||
class InMemoryTransport implements TransportInterface
|
||||
class InMemoryTransport extends BaseTransport implements TransportInterface
|
||||
{
|
||||
/** @var callable(string, ?Uuid): void */
|
||||
private $messageListener;
|
||||
|
||||
/** @var callable(Uuid): void */
|
||||
private $sessionDestroyListener;
|
||||
|
||||
private ?Uuid $sessionId = null;
|
||||
use ManagesTransportCallbacks;
|
||||
|
||||
/**
|
||||
* @param list<string> $messages
|
||||
|
|
@ -58,29 +52,24 @@ class InMemoryTransport implements TransportInterface
|
|||
public function listen(): mixed
|
||||
{
|
||||
foreach ($this->messages as $message) {
|
||||
if (\is_callable($this->messageListener)) {
|
||||
\call_user_func($this->messageListener, $message, $this->sessionId);
|
||||
}
|
||||
$this->handleMessage($message, $this->sessionId);
|
||||
}
|
||||
|
||||
if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) {
|
||||
\call_user_func($this->sessionDestroyListener, $this->sessionId);
|
||||
$this->sessionId = null;
|
||||
}
|
||||
$this->handleSessionEnd($this->sessionId);
|
||||
|
||||
$this->sessionId = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function onSessionEnd(callable $listener): void
|
||||
public function setSessionId(?Uuid $sessionId): void
|
||||
{
|
||||
$this->sessionDestroyListener = $listener;
|
||||
$this->sessionId = $sessionId;
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) {
|
||||
\call_user_func($this->sessionDestroyListener, $this->sessionId);
|
||||
$this->sessionId = null;
|
||||
}
|
||||
$this->handleSessionEnd($this->sessionId);
|
||||
$this->sessionId = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
82
src/Server/Transport/ManagesTransportCallbacks.php
Normal file
82
src/Server/Transport/ManagesTransportCallbacks.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?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\Transport;
|
||||
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* A trait for managing the various callbacks provided by the Protocol layer.
|
||||
*
|
||||
* @phpstan-import-type FiberReturn from \Mcp\Server\Transport\TransportInterface
|
||||
* @phpstan-import-type FiberResume from \Mcp\Server\Transport\TransportInterface
|
||||
* @phpstan-import-type FiberSuspend from \Mcp\Server\Transport\TransportInterface
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
* */
|
||||
trait ManagesTransportCallbacks
|
||||
{
|
||||
/** @var callable(string, ?Uuid): void */
|
||||
protected $messageListener;
|
||||
|
||||
/** @var callable(Uuid): void */
|
||||
protected $sessionEndListener;
|
||||
|
||||
/** @var callable(Uuid): array<int, array{message: string, context: array<string, mixed>}> */
|
||||
protected $outgoingMessagesProvider;
|
||||
|
||||
/** @var callable(Uuid): array<int, array<string, mixed>> */
|
||||
protected $pendingRequestsProvider;
|
||||
|
||||
/** @var callable(int, Uuid): Response<array<string, mixed>>|Error|null */
|
||||
protected $responseFinder;
|
||||
|
||||
/** @var callable(FiberSuspend|null, ?Uuid): void */
|
||||
protected $fiberYieldHandler;
|
||||
|
||||
public function onMessage(callable $listener): void
|
||||
{
|
||||
$this->messageListener = $listener;
|
||||
}
|
||||
|
||||
public function onSessionEnd(callable $listener): void
|
||||
{
|
||||
$this->sessionEndListener = $listener;
|
||||
}
|
||||
|
||||
public function setOutgoingMessagesProvider(callable $provider): void
|
||||
{
|
||||
$this->outgoingMessagesProvider = $provider;
|
||||
}
|
||||
|
||||
public function setPendingRequestsProvider(callable $provider): void
|
||||
{
|
||||
$this->pendingRequestsProvider = $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(int, Uuid):(Response<array<string, mixed>>|Error|null) $finder
|
||||
*/
|
||||
public function setResponseFinder(callable $finder): void
|
||||
{
|
||||
$this->responseFinder = $finder;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(FiberSuspend|null, ?Uuid): void $handler
|
||||
*/
|
||||
public function setFiberYieldHandler(callable $handler): void
|
||||
{
|
||||
$this->fiberYieldHandler = $handler;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,25 +11,17 @@
|
|||
|
||||
namespace Mcp\Server\Transport;
|
||||
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* @implements TransportInterface<int>
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
class StdioTransport implements TransportInterface
|
||||
* */
|
||||
class StdioTransport extends BaseTransport implements TransportInterface
|
||||
{
|
||||
/** @var callable(string, ?Uuid): void */
|
||||
private $messageListener;
|
||||
|
||||
/** @var callable(Uuid): void */
|
||||
private $sessionEndListener;
|
||||
|
||||
private ?Uuid $sessionId = null;
|
||||
|
||||
/**
|
||||
* @param resource $input
|
||||
* @param resource $output
|
||||
|
|
@ -37,80 +29,137 @@ class StdioTransport implements TransportInterface
|
|||
public function __construct(
|
||||
private $input = \STDIN,
|
||||
private $output = \STDOUT,
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
LoggerInterface $logger = new NullLogger(),
|
||||
) {
|
||||
}
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function onMessage(callable $listener): void
|
||||
{
|
||||
$this->messageListener = $listener;
|
||||
parent::__construct($logger);
|
||||
}
|
||||
|
||||
public function send(string $data, array $context): void
|
||||
{
|
||||
$this->logger->debug('Sending data to client via StdioTransport.', ['data' => $data]);
|
||||
|
||||
if (isset($context['session_id'])) {
|
||||
$this->sessionId = $context['session_id'];
|
||||
}
|
||||
|
||||
fwrite($this->output, $data.\PHP_EOL);
|
||||
$this->writeLine($data);
|
||||
}
|
||||
|
||||
public function listen(): int
|
||||
{
|
||||
$this->logger->info('StdioTransport is listening for messages on STDIN...');
|
||||
stream_set_blocking($this->input, false);
|
||||
|
||||
$status = 0;
|
||||
while (!feof($this->input)) {
|
||||
$line = fgets($this->input);
|
||||
if (false === $line) {
|
||||
if (!feof($this->input)) {
|
||||
$status = 1;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$trimmedLine = trim($line);
|
||||
if (!empty($trimmedLine)) {
|
||||
$this->logger->debug('Received message on StdioTransport.', ['line' => $trimmedLine]);
|
||||
if (\is_callable($this->messageListener)) {
|
||||
\call_user_func($this->messageListener, $trimmedLine, $this->sessionId);
|
||||
}
|
||||
}
|
||||
$this->processInput();
|
||||
$this->processFiber();
|
||||
$this->flushOutgoingMessages();
|
||||
}
|
||||
|
||||
$this->logger->info('StdioTransport finished listening.');
|
||||
$this->handleSessionEnd($this->sessionId);
|
||||
|
||||
if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) {
|
||||
\call_user_func($this->sessionEndListener, $this->sessionId);
|
||||
$this->sessionId = null;
|
||||
}
|
||||
|
||||
return $status;
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function onSessionEnd(callable $listener): void
|
||||
protected function processInput(): void
|
||||
{
|
||||
$this->sessionEndListener = $listener;
|
||||
$line = fgets($this->input);
|
||||
if (false === $line) {
|
||||
usleep(50000); // 50ms
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$trimmedLine = trim($line);
|
||||
if (!empty($trimmedLine)) {
|
||||
$this->handleMessage($trimmedLine, $this->sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private function processFiber(): void
|
||||
{
|
||||
if (null === $this->sessionFiber) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->sessionFiber->isTerminated()) {
|
||||
$this->handleFiberTermination();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->sessionFiber->isSuspended()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pendingRequests = $this->getPendingRequests($this->sessionId);
|
||||
|
||||
if (empty($pendingRequests)) {
|
||||
$yielded = $this->sessionFiber->resume();
|
||||
$this->handleFiberYield($yielded, $this->sessionId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($pendingRequests as $pending) {
|
||||
$requestId = $pending['request_id'];
|
||||
$timestamp = $pending['timestamp'];
|
||||
$timeout = $pending['timeout'] ?? 120;
|
||||
|
||||
$response = $this->checkForResponse($requestId, $this->sessionId);
|
||||
|
||||
if (null !== $response) {
|
||||
$yielded = $this->sessionFiber->resume($response);
|
||||
$this->handleFiberYield($yielded, $this->sessionId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (time() - $timestamp >= $timeout) {
|
||||
$error = Error::forInternalError('Request timed out', $requestId);
|
||||
$yielded = $this->sessionFiber->resume($error);
|
||||
$this->handleFiberYield($yielded, $this->sessionId);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function handleFiberTermination(): void
|
||||
{
|
||||
$finalResult = $this->sessionFiber->getReturn();
|
||||
|
||||
if (null !== $finalResult) {
|
||||
try {
|
||||
$encoded = json_encode($finalResult, \JSON_THROW_ON_ERROR);
|
||||
$this->writeLine($encoded);
|
||||
} catch (\JsonException $e) {
|
||||
$this->logger->error('STDIO: Failed to encode final Fiber result.', ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->sessionFiber = null;
|
||||
}
|
||||
|
||||
private function flushOutgoingMessages(): void
|
||||
{
|
||||
$messages = $this->getOutgoingMessages($this->sessionId);
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$this->writeLine($message['message']);
|
||||
}
|
||||
}
|
||||
|
||||
private function writeLine(string $payload): void
|
||||
{
|
||||
fwrite($this->output, $payload.\PHP_EOL);
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) {
|
||||
\call_user_func($this->sessionEndListener, $this->sessionId);
|
||||
$this->sessionId = null;
|
||||
}
|
||||
|
||||
$this->handleSessionEnd($this->sessionId);
|
||||
if (\is_resource($this->input)) {
|
||||
fclose($this->input);
|
||||
}
|
||||
|
||||
if (\is_resource($this->output)) {
|
||||
fclose($this->output);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,24 +25,14 @@ use Symfony\Component\Uid\Uuid;
|
|||
* @implements TransportInterface<ResponseInterface>
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
class StreamableHttpTransport implements TransportInterface
|
||||
* */
|
||||
class StreamableHttpTransport extends BaseTransport implements TransportInterface
|
||||
{
|
||||
private ResponseFactoryInterface $responseFactory;
|
||||
private StreamFactoryInterface $streamFactory;
|
||||
|
||||
/** @var callable(string, ?Uuid): void */
|
||||
private $messageListener;
|
||||
|
||||
/** @var callable(Uuid): void */
|
||||
private $sessionEndListener;
|
||||
|
||||
private ?Uuid $sessionId = null;
|
||||
|
||||
/** @var string[] */
|
||||
private array $outgoingMessages = [];
|
||||
private ?Uuid $outgoingSessionId = null;
|
||||
private ?int $outgoingStatusCode = null;
|
||||
private ?string $immediateResponse = null;
|
||||
private ?int $immediateStatusCode = null;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private array $corsHeaders = [
|
||||
|
|
@ -55,8 +45,9 @@ class StreamableHttpTransport implements TransportInterface
|
|||
private readonly ServerRequestInterface $request,
|
||||
?ResponseFactoryInterface $responseFactory = null,
|
||||
?StreamFactoryInterface $streamFactory = null,
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
LoggerInterface $logger = new NullLogger(),
|
||||
) {
|
||||
parent::__construct($logger);
|
||||
$sessionIdString = $this->request->getHeaderLine('Mcp-Session-Id');
|
||||
$this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null;
|
||||
|
||||
|
|
@ -70,44 +61,20 @@ class StreamableHttpTransport implements TransportInterface
|
|||
|
||||
public function send(string $data, array $context): void
|
||||
{
|
||||
$this->outgoingMessages[] = $data;
|
||||
|
||||
if (isset($context['session_id'])) {
|
||||
$this->outgoingSessionId = $context['session_id'];
|
||||
}
|
||||
|
||||
if (isset($context['status_code']) && \is_int($context['status_code'])) {
|
||||
$this->outgoingStatusCode = $context['status_code'];
|
||||
}
|
||||
|
||||
$this->logger->debug('Sending data to client via StreamableHttpTransport.', [
|
||||
'data' => $data,
|
||||
'session_id' => $this->outgoingSessionId?->toRfc4122(),
|
||||
'status_code' => $this->outgoingStatusCode,
|
||||
]);
|
||||
$this->immediateResponse = $data;
|
||||
$this->immediateStatusCode = $context['status_code'] ?? 200;
|
||||
}
|
||||
|
||||
public function listen(): ResponseInterface
|
||||
{
|
||||
return match ($this->request->getMethod()) {
|
||||
'OPTIONS' => $this->handleOptionsRequest(),
|
||||
'GET' => $this->handleGetRequest(),
|
||||
'POST' => $this->handlePostRequest(),
|
||||
'DELETE' => $this->handleDeleteRequest(),
|
||||
default => $this->handleUnsupportedRequest(),
|
||||
default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405),
|
||||
};
|
||||
}
|
||||
|
||||
public function onMessage(callable $listener): void
|
||||
{
|
||||
$this->messageListener = $listener;
|
||||
}
|
||||
|
||||
public function onSessionEnd(callable $listener): void
|
||||
{
|
||||
$this->sessionEndListener = $listener;
|
||||
}
|
||||
|
||||
protected function handleOptionsRequest(): ResponseInterface
|
||||
{
|
||||
return $this->withCorsHeaders($this->responseFactory->createResponse(204));
|
||||
|
|
@ -115,89 +82,163 @@ class StreamableHttpTransport implements TransportInterface
|
|||
|
||||
protected function handlePostRequest(): ResponseInterface
|
||||
{
|
||||
$acceptHeader = $this->request->getHeaderLine('Accept');
|
||||
if (!str_contains($acceptHeader, 'application/json') || !str_contains($acceptHeader, 'text/event-stream')) {
|
||||
$error = Error::forInvalidRequest('Not Acceptable: Client must accept both application/json and text/event-stream.');
|
||||
$this->logger->warning('Client does not accept required content types.', ['accept' => $acceptHeader]);
|
||||
|
||||
return $this->createErrorResponse($error, 406);
|
||||
}
|
||||
|
||||
if (!str_contains($this->request->getHeaderLine('Content-Type'), 'application/json')) {
|
||||
$error = Error::forInvalidRequest('Unsupported Media Type: Content-Type must be application/json.');
|
||||
$this->logger->warning('Client sent unsupported content type.', ['content_type' => $this->request->getHeaderLine('Content-Type')]);
|
||||
|
||||
return $this->createErrorResponse($error, 415);
|
||||
}
|
||||
|
||||
$body = $this->request->getBody()->getContents();
|
||||
if (empty($body)) {
|
||||
$error = Error::forInvalidRequest('Bad Request: Empty request body.');
|
||||
$this->logger->warning('Client sent empty request body.');
|
||||
$this->handleMessage($body, $this->sessionId);
|
||||
|
||||
return $this->createErrorResponse($error, 400);
|
||||
if (null !== $this->immediateResponse) {
|
||||
$response = $this->responseFactory->createResponse($this->immediateStatusCode ?? 200)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->streamFactory->createStream($this->immediateResponse));
|
||||
|
||||
return $this->withCorsHeaders($response);
|
||||
}
|
||||
|
||||
$this->logger->debug('Received message on StreamableHttpTransport.', [
|
||||
'body' => $body,
|
||||
'session_id' => $this->sessionId?->toRfc4122(),
|
||||
]);
|
||||
if (null !== $this->sessionFiber) {
|
||||
$this->logger->info('Fiber suspended, handling via SSE.');
|
||||
|
||||
if (\is_callable($this->messageListener)) {
|
||||
\call_user_func($this->messageListener, $body, $this->sessionId);
|
||||
return $this->createStreamedResponse();
|
||||
}
|
||||
|
||||
if (empty($this->outgoingMessages)) {
|
||||
return $this->withCorsHeaders($this->responseFactory->createResponse(202));
|
||||
}
|
||||
|
||||
$responseBody = 1 === \count($this->outgoingMessages)
|
||||
? $this->outgoingMessages[0]
|
||||
: '['.implode(',', $this->outgoingMessages).']';
|
||||
|
||||
$status = $this->outgoingStatusCode ?? 200;
|
||||
|
||||
$response = $this->responseFactory->createResponse($status)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->streamFactory->createStream($responseBody));
|
||||
|
||||
if ($this->outgoingSessionId) {
|
||||
$response = $response->withHeader('Mcp-Session-Id', $this->outgoingSessionId->toRfc4122());
|
||||
}
|
||||
|
||||
return $this->withCorsHeaders($response);
|
||||
}
|
||||
|
||||
protected function handleGetRequest(): ResponseInterface
|
||||
{
|
||||
$response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 405);
|
||||
|
||||
return $this->withCorsHeaders($response);
|
||||
return $this->createJsonResponse();
|
||||
}
|
||||
|
||||
protected function handleDeleteRequest(): ResponseInterface
|
||||
{
|
||||
if (!$this->sessionId) {
|
||||
$error = Error::forInvalidRequest('Bad Request: Mcp-Session-Id header is required for DELETE requests.');
|
||||
$this->logger->warning('DELETE request received without session ID.');
|
||||
|
||||
return $this->createErrorResponse($error, 400);
|
||||
return $this->createErrorResponse(Error::forInvalidRequest('Mcp-Session-Id header is required.'), 400);
|
||||
}
|
||||
|
||||
if (\is_callable($this->sessionEndListener)) {
|
||||
\call_user_func($this->sessionEndListener, $this->sessionId);
|
||||
}
|
||||
$this->handleSessionEnd($this->sessionId);
|
||||
|
||||
return $this->withCorsHeaders($this->responseFactory->createResponse(204));
|
||||
}
|
||||
|
||||
protected function handleUnsupportedRequest(): ResponseInterface
|
||||
protected function createJsonResponse(): ResponseInterface
|
||||
{
|
||||
$this->logger->warning('Unsupported HTTP method received.', [
|
||||
'method' => $this->request->getMethod(),
|
||||
]);
|
||||
$outgoingMessages = $this->getOutgoingMessages($this->sessionId);
|
||||
|
||||
$response = $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405);
|
||||
if (empty($outgoingMessages)) {
|
||||
return $this->withCorsHeaders($this->responseFactory->createResponse(202));
|
||||
}
|
||||
|
||||
$messages = array_column($outgoingMessages, 'message');
|
||||
$responseBody = 1 === \count($messages) ? $messages[0] : '['.implode(',', $messages).']';
|
||||
|
||||
$response = $this->responseFactory->createResponse(200)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->streamFactory->createStream($responseBody));
|
||||
|
||||
if ($this->sessionId) {
|
||||
$response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122());
|
||||
}
|
||||
|
||||
return $this->withCorsHeaders($response);
|
||||
}
|
||||
|
||||
protected function createStreamedResponse(): ResponseInterface
|
||||
{
|
||||
$callback = function (): void {
|
||||
try {
|
||||
$this->logger->info('SSE: Starting request processing loop');
|
||||
|
||||
while ($this->sessionFiber->isSuspended()) {
|
||||
$this->flushOutgoingMessages($this->sessionId);
|
||||
|
||||
$pendingRequests = $this->getPendingRequests($this->sessionId);
|
||||
|
||||
if (empty($pendingRequests)) {
|
||||
$yielded = $this->sessionFiber->resume();
|
||||
$this->handleFiberYield($yielded, $this->sessionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
$resumed = false;
|
||||
foreach ($pendingRequests as $pending) {
|
||||
$requestId = $pending['request_id'];
|
||||
$timestamp = $pending['timestamp'];
|
||||
$timeout = $pending['timeout'] ?? 120;
|
||||
|
||||
$response = $this->checkForResponse($requestId, $this->sessionId);
|
||||
|
||||
if (null !== $response) {
|
||||
$yielded = $this->sessionFiber->resume($response);
|
||||
$this->handleFiberYield($yielded, $this->sessionId);
|
||||
$resumed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (time() - $timestamp >= $timeout) {
|
||||
$error = Error::forInternalError('Request timed out', $requestId);
|
||||
$yielded = $this->sessionFiber->resume($error);
|
||||
$this->handleFiberYield($yielded, $this->sessionId);
|
||||
$resumed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$resumed) {
|
||||
usleep(100000);
|
||||
} // Prevent tight loop
|
||||
}
|
||||
|
||||
$this->handleFiberTermination();
|
||||
} finally {
|
||||
$this->sessionFiber = null;
|
||||
}
|
||||
};
|
||||
|
||||
$stream = new CallbackStream($callback, $this->logger);
|
||||
$response = $this->responseFactory->createResponse(200)
|
||||
->withHeader('Content-Type', 'text/event-stream')
|
||||
->withHeader('Cache-Control', 'no-cache')
|
||||
->withHeader('Connection', 'keep-alive')
|
||||
->withHeader('X-Accel-Buffering', 'no')
|
||||
->withBody($stream);
|
||||
|
||||
if ($this->sessionId) {
|
||||
$response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122());
|
||||
}
|
||||
|
||||
return $this->withCorsHeaders($response);
|
||||
}
|
||||
|
||||
private function handleFiberTermination(): void
|
||||
{
|
||||
$finalResult = $this->sessionFiber->getReturn();
|
||||
|
||||
if (null !== $finalResult) {
|
||||
try {
|
||||
$encoded = json_encode($finalResult, \JSON_THROW_ON_ERROR);
|
||||
echo "event: message\n";
|
||||
echo "data: {$encoded}\n\n";
|
||||
@ob_flush();
|
||||
flush();
|
||||
} catch (\JsonException $e) {
|
||||
$this->logger->error('SSE: Failed to encode final Fiber result.', ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->sessionFiber = null;
|
||||
}
|
||||
|
||||
private function flushOutgoingMessages(?Uuid $sessionId): void
|
||||
{
|
||||
$messages = $this->getOutgoingMessages($sessionId);
|
||||
|
||||
foreach ($messages as $message) {
|
||||
echo "event: message\n";
|
||||
echo "data: {$message['message']}\n\n";
|
||||
@ob_flush();
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
protected function createErrorResponse(Error $jsonRpcError, int $statusCode): ResponseInterface
|
||||
{
|
||||
$payload = json_encode($jsonRpcError, \JSON_THROW_ON_ERROR);
|
||||
$response = $this->responseFactory->createResponse($statusCode)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->streamFactory->createStream($payload));
|
||||
|
||||
return $this->withCorsHeaders($response);
|
||||
}
|
||||
|
|
@ -210,17 +251,4 @@ class StreamableHttpTransport implements TransportInterface
|
|||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function createErrorResponse(Error $jsonRpcError, int $statusCode): ResponseInterface
|
||||
{
|
||||
$errorPayload = json_encode($jsonRpcError, \JSON_THROW_ON_ERROR);
|
||||
|
||||
return $this->responseFactory->createResponse($statusCode)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->streamFactory->createStream($errorPayload));
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue