<?php

declare(strict_types=1);

namespace PhpMcp\Server\Defaults;

use PhpMcp\Server\Contracts\SessionIdGeneratorInterface;

class DefaultUuidSessionIdGenerator implements SessionIdGeneratorInterface
{
    public function generateId(): string
    {
        return bin2hex(random_bytes(16));
    }
    public function onSessionInitialized(string $sessionId): void
    {
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Defaults;

use PhpMcp\Server\Contracts\EventStoreInterface;

/**
 * Simple in-memory implementation of the EventStore interface for resumability
 * This is primarily intended for examples and testing, not for production use
 * where a persistent storage solution would be more appropriate.
 */
class InMemoryEventStore implements EventStoreInterface
{
    public const DEFAULT_MAX_EVENTS_PER_STREAM = 1000;

    /**
     * @var array<string, array{streamId: string, message: string}>
     * Example: [eventId1 => ['streamId' => 'abc', 'message' => '...']]
     */
    private array $events = [];

    private function generateEventId(string $streamId): string
    {
        return $streamId . '_' . (int)(microtime(true) * 1000) . '_' . bin2hex(random_bytes(4));
    }

    private function getStreamIdFromEventId(string $eventId): ?string
    {
        $parts = explode('_', $eventId);
        return $parts[0] ?? null;
    }

    public function storeEvent(string $streamId, string $message): string
    {
        $eventId = $this->generateEventId($streamId);

        $this->events[$eventId] = [
            'streamId' => $streamId,
            'message' => $message,
        ];

        return $eventId;
    }

    public function replayEventsAfter(string $lastEventId, callable $sendCallback): void
    {
        if (!isset($this->events[$lastEventId])) {
            return;
        }

        $streamId = $this->getStreamIdFromEventId($lastEventId);
        if ($streamId === null) {
            return;
        }

        $foundLastEvent = false;

        // Sort by eventId for deterministic ordering
        ksort($this->events);

        foreach ($this->events as $eventId => ['streamId' => $eventStreamId, 'message' => $message]) {
            if ($eventStreamId !== $streamId) {
                continue;
            }

            if ($eventId === $lastEventId) {
                $foundLastEvent = true;
                continue;
            }

            if ($foundLastEvent) {
                $sendCallback($eventId, $message);
            }
        }
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Transports;

use Evenement\EventEmitterTrait;
use PhpMcp\Server\Contracts\EventStoreInterface;
use PhpMcp\Server\Contracts\LoggerAwareInterface;
use PhpMcp\Server\Contracts\LoopAwareInterface;
use PhpMcp\Server\Contracts\ServerTransportInterface;
use PhpMcp\Server\Exception\McpServerException;
use PhpMcp\Server\Exception\TransportException;
use PhpMcp\Schema\JsonRpc\Message;
use PhpMcp\Schema\JsonRpc\BatchRequest;
use PhpMcp\Schema\JsonRpc\BatchResponse;
use PhpMcp\Schema\JsonRpc\Error;
use PhpMcp\Schema\JsonRpc\Parser;
use PhpMcp\Schema\JsonRpc\Request;
use PhpMcp\Schema\JsonRpc\Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
use React\Http\HttpServer;
use React\Http\Message\Response as HttpResponse;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use React\Socket\SocketServer;
use React\Stream\ThroughStream;
use Throwable;

use function React\Promise\resolve;
use function React\Promise\reject;

class StreamableHttpServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface
{
    use EventEmitterTrait;

    protected LoggerInterface $logger;
    protected LoopInterface $loop;

    private ?SocketServer $socket = null;
    private ?HttpServer $http = null;
    private bool $listening = false;
    private bool $closing = false;

    private ?EventStoreInterface $eventStore;

    /**
     * Stores Deferred objects for POST requests awaiting a direct JSON response.
     * Keyed by a unique pendingRequestId.
     * @var array<string, Deferred>
     */
    private array $pendingRequests = [];

    /**
     * Stores active SSE streams.
     * Key: streamId
     * Value: ['stream' => ThroughStream, 'sessionId' => string, 'context' => array]
     * @var array<string, array{stream: ThroughStream, sessionId: string, context: array}>
     */
    private array $activeSseStreams = [];

    private ?ThroughStream $getStream = null;

    /**
     * @param bool $enableJsonResponse If true, the server will return JSON responses instead of starting an SSE stream.
     * This can be useful for simple request/response scenarios without streaming.
     */
    public function __construct(
        private readonly string $host = '127.0.0.1',
        private readonly int $port = 8080,
        private string $mcpPath = '/mcp',
        private ?array $sslContext = null,
        private readonly bool $enableJsonResponse = true,
        private readonly bool $stateless = false,
        ?EventStoreInterface $eventStore = null
    ) {
        $this->logger = new NullLogger();
        $this->loop = Loop::get();
        $this->mcpPath = '/' . trim($mcpPath, '/');
        $this->eventStore = $eventStore;
    }

    protected function generateId(): string
    {
        return bin2hex(random_bytes(16)); // 32 hex characters
    }

    public function setLogger(LoggerInterface $logger): void
    {
        $this->logger = $logger;
    }

    public function setLoop(LoopInterface $loop): void
    {
        $this->loop = $loop;
    }

    public function listen(): void
    {
        if ($this->listening) {
            throw new TransportException('StreamableHttp transport is already listening.');
        }

        if ($this->closing) {
            throw new TransportException('Cannot listen, transport is closing/closed.');
        }

        $listenAddress = "{$this->host}:{$this->port}";
        $protocol = $this->sslContext ? 'https' : 'http';

        try {
            $this->socket = new SocketServer(
                $listenAddress,
                $this->sslContext ?? [],
                $this->loop
            );

            $this->http = new HttpServer($this->loop, $this->createRequestHandler());
            $this->http->listen($this->socket);

            $this->socket->on('error', function (Throwable $error) {
                $this->logger->error('Socket server error (StreamableHttp).', ['error' => $error->getMessage()]);
                $this->emit('error', [new TransportException("Socket server error: {$error->getMessage()}", 0, $error)]);
                $this->close();
            });

            $this->logger->info("Server is up and listening on {$protocol}://{$listenAddress} 🚀");
            $this->logger->info("MCP Endpoint: {$protocol}://{$listenAddress}{$this->mcpPath}");

            $this->listening = true;
            $this->closing = false;
            $this->emit('ready');
        } catch (Throwable $e) {
            $this->logger->error("Failed to start StreamableHttp listener on {$listenAddress}", ['exception' => $e]);
            throw new TransportException("Failed to start StreamableHttp listener on {$listenAddress}: {$e->getMessage()}", 0, $e);
        }
    }

    private function createRequestHandler(): callable
    {
        return function (ServerRequestInterface $request) {
            $path = $request->getUri()->getPath();
            $method = $request->getMethod();

            $this->logger->debug("Request received", ['method' => $method, 'path' => $path, 'target' => $this->mcpPath]);

            if ($path !== $this->mcpPath) {
                $error = Error::forInvalidRequest("Not found: {$path}");
                return new HttpResponse(404, ['Content-Type' => 'application/json'], json_encode($error));
            }

            $corsHeaders = [
                'Access-Control-Allow-Origin' => '*',
                'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS',
                'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization',
            ];

            if ($method === 'OPTIONS') {
                return new HttpResponse(204, $corsHeaders);
            }

            $addCors = function (HttpResponse $r) use ($corsHeaders) {
                foreach ($corsHeaders as $key => $value) {
                    $r = $r->withAddedHeader($key, $value);
                }
                return $r;
            };

            try {
                return match ($method) {
                    'GET' => $this->handleGetRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))),
                    'POST' => $this->handlePostRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))),
                    'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))),
                    default => $addCors($this->handleUnsupportedRequest($request)),
                };
            } catch (Throwable $e) {
                return $addCors($this->handleRequestError($e, $request));
            }
        };
    }

    private function handleGetRequest(ServerRequestInterface $request): PromiseInterface
    {
        if ($this->stateless) {
            $error = Error::forInvalidRequest("GET requests (SSE streaming) are not supported in stateless mode.");
            return resolve(new HttpResponse(405, ['Content-Type' => 'application/json'], json_encode($error)));
        }

        $acceptHeader = $request->getHeaderLine('Accept');
        if (!str_contains($acceptHeader, 'text/event-stream')) {
            $error = Error::forInvalidRequest("Not Acceptable: Client must accept text/event-stream for GET requests.");
            return resolve(new HttpResponse(406, ['Content-Type' => 'application/json'], json_encode($error)));
        }

        $sessionId = $request->getHeaderLine('Mcp-Session-Id');
        if (empty($sessionId)) {
            $this->logger->warning("GET request without Mcp-Session-Id.");
            $error = Error::forInvalidRequest("Mcp-Session-Id header required for GET requests.");
            return resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
        }

        $this->getStream = new ThroughStream();

        $this->getStream->on('close', function () use ($sessionId) {
            $this->logger->debug("GET SSE stream closed.", ['sessionId' => $sessionId]);
            $this->getStream = null;
        });

        $this->getStream->on('error', function (Throwable $e) use ($sessionId) {
            $this->logger->error("GET SSE stream error.", ['sessionId' => $sessionId, 'error' => $e->getMessage()]);
            $this->getStream = null;
        });

        $headers = [
            'Content-Type' => 'text/event-stream',
            'Cache-Control' => 'no-cache',
            'Connection' => 'keep-alive',
            'X-Accel-Buffering' => 'no',
        ];

        $response = new HttpResponse(200, $headers, $this->getStream);

        if ($this->eventStore) {
            $lastEventId = $request->getHeaderLine('Last-Event-ID');
            $this->replayEvents($lastEventId, $this->getStream, $sessionId);
        }

        return resolve($response);
    }

    private function handlePostRequest(ServerRequestInterface $request): PromiseInterface
    {
        $deferred = new Deferred();

        $acceptHeader = $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 or text/event-stream");
            $deferred->resolve(new HttpResponse(406, ['Content-Type' => 'application/json'], json_encode($error)));
            return $deferred->promise();
        }

        if (!str_contains($request->getHeaderLine('Content-Type'), 'application/json')) {
            $error = Error::forInvalidRequest("Unsupported Media Type: Content-Type must be application/json");
            $deferred->resolve(new HttpResponse(415, ['Content-Type' => 'application/json'], json_encode($error)));
            return $deferred->promise();
        }

        $body = $request->getBody()->getContents();

        if (empty($body)) {
            $this->logger->warning("Received empty POST body");
            $error = Error::forInvalidRequest("Empty request body.");
            $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
            return $deferred->promise();
        }

        try {
            $message = Parser::parse($body);
        } catch (Throwable $e) {
            $this->logger->error("Failed to parse MCP message from POST body", ['error' => $e->getMessage()]);
            $error = Error::forParseError("Invalid JSON: " . $e->getMessage());
            $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
            return $deferred->promise();
        }

        $isInitializeRequest = ($message instanceof Request && $message->method === 'initialize');
        $sessionId = null;

        if ($this->stateless) {
            $sessionId = $this->generateId();
            $this->emit('client_connected', [$sessionId]);
        } else {
            if ($isInitializeRequest) {
                if ($request->hasHeader('Mcp-Session-Id')) {
                    $this->logger->warning("Client sent Mcp-Session-Id with InitializeRequest. Ignoring.", ['clientSentId' => $request->getHeaderLine('Mcp-Session-Id')]);
                    $error = Error::forInvalidRequest("Invalid request: Session already initialized. Mcp-Session-Id header not allowed with InitializeRequest.", $message->getId());
                    $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
                    return $deferred->promise();
                }

                $sessionId = $this->generateId();
                $this->emit('client_connected', [$sessionId]);
            } else {
                $sessionId = $request->getHeaderLine('Mcp-Session-Id');

                if (empty($sessionId)) {
                    $this->logger->warning("POST request without Mcp-Session-Id.");
                    $error = Error::forInvalidRequest("Mcp-Session-Id header required for POST requests.", $message->getId());
                    $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
                    return $deferred->promise();
                }
            }
        }

        $context = [
            'is_initialize_request' => $isInitializeRequest,
        ];

        $nRequests = match (true) {
            $message instanceof Request => 1,
            $message instanceof BatchRequest => $message->nRequests(),
            default => 0,
        };

        if ($nRequests === 0) {
            $deferred->resolve(new HttpResponse(202));
            $context['type'] = 'post_202_sent';
        } else {
            if ($this->enableJsonResponse) {
                $pendingRequestId = $this->generateId();
                $this->pendingRequests[$pendingRequestId] = $deferred;

                $timeoutTimer = $this->loop->addTimer(30, function () use ($pendingRequestId, $sessionId) {
                    if (isset($this->pendingRequests[$pendingRequestId])) {
                        $deferred = $this->pendingRequests[$pendingRequestId];
                        unset($this->pendingRequests[$pendingRequestId]);
                        $this->logger->warning("Timeout waiting for direct JSON response processing.", ['pending_request_id' => $pendingRequestId, 'session_id' => $sessionId]);
                        $errorResponse = McpServerException::internalError("Request processing timed out.")->toJsonRpcError($pendingRequestId);
                        $deferred->resolve(new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($errorResponse->toArray())));
                    }
                });

                $this->pendingRequests[$pendingRequestId]->promise()->finally(function () use ($timeoutTimer) {
                    $this->loop->cancelTimer($timeoutTimer);
                });

                $context['type'] = 'post_json';
                $context['pending_request_id'] = $pendingRequestId;
            } else {
                $streamId = $this->generateId();
                $sseStream = new ThroughStream();
                $this->activeSseStreams[$streamId] = [
                    'stream' => $sseStream,
                    'sessionId' => $sessionId,
                    'context' => ['nRequests' => $nRequests, 'nResponses' => 0]
                ];

                $sseStream->on('close', function () use ($streamId) {
                    $this->logger->info("POST SSE stream closed by client/server.", ['streamId' => $streamId, 'sessionId' => $this->activeSseStreams[$streamId]['sessionId']]);
                    unset($this->activeSseStreams[$streamId]);
                });
                $sseStream->on('error', function (Throwable $e) use ($streamId) {
                    $this->logger->error("POST SSE stream error.", ['streamId' => $streamId, 'sessionId' => $this->activeSseStreams[$streamId]['sessionId'], 'error' => $e->getMessage()]);
                    unset($this->activeSseStreams[$streamId]);
                });

                $headers = [
                    'Content-Type' => 'text/event-stream',
                    'Cache-Control' => 'no-cache',
                    'Connection' => 'keep-alive',
                    'X-Accel-Buffering' => 'no',
                ];

                if (!empty($sessionId) && !$this->stateless) {
                    $headers['Mcp-Session-Id'] = $sessionId;
                }

                $deferred->resolve(new HttpResponse(200, $headers, $sseStream));
                $context['type'] = 'post_sse';
                $context['streamId'] = $streamId;
                $context['nRequests'] = $nRequests;
            }
        }

        $context['stateless'] = $this->stateless;
        $context['request'] = $request;

        $this->loop->futureTick(function () use ($message, $sessionId, $context) {
            $this->emit('message', [$message, $sessionId, $context]);
        });

        return $deferred->promise();
    }

    private function handleDeleteRequest(ServerRequestInterface $request): PromiseInterface
    {
        if ($this->stateless) {
            return resolve(new HttpResponse(204));
        }

        $sessionId = $request->getHeaderLine('Mcp-Session-Id');
        if (empty($sessionId)) {
            $this->logger->warning("DELETE request without Mcp-Session-Id.");
            $error = Error::forInvalidRequest("Mcp-Session-Id header required for DELETE.");
            return resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
        }

        $streamsToClose = [];
        foreach ($this->activeSseStreams as $streamId => $streamInfo) {
            if ($streamInfo['sessionId'] === $sessionId) {
                $streamsToClose[] = $streamId;
            }
        }

        foreach ($streamsToClose as $streamId) {
            $this->activeSseStreams[$streamId]['stream']->end();
            unset($this->activeSseStreams[$streamId]);
        }

        if ($this->getStream !== null) {
            $this->getStream->end();
            $this->getStream = null;
        }

        $this->emit('client_disconnected', [$sessionId, 'Session terminated by DELETE request']);

        return resolve(new HttpResponse(204));
    }

    private function handleUnsupportedRequest(ServerRequestInterface $request): HttpResponse
    {
        $error = Error::forInvalidRequest("Method not allowed: {$request->getMethod()}");
        $headers = [
            'Content-Type' => 'application/json',
            'Allow' => 'GET, POST, DELETE, OPTIONS',
        ];
        return new HttpResponse(405, $headers, json_encode($error));
    }

    private function handleRequestError(Throwable $e, ServerRequestInterface $request): HttpResponse
    {
        $this->logger->error("Error processing HTTP request", [
            'method' => $request->getMethod(),
            'path' => $request->getUri()->getPath(),
            'exception' => $e->getMessage()
        ]);

        if ($e instanceof TransportException) {
            $error = Error::forInternalError("Transport Error: " . $e->getMessage());
            return new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($error));
        }

        $error = Error::forInternalError("Internal Server Error during HTTP request processing.");
        return new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($error));
    }

    public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface
    {
        if ($this->closing) {
            return reject(new TransportException('Transport is closing.'));
        }

        $isInitializeResponse = ($context['is_initialize_request'] ?? false) && ($message instanceof Response);

        switch ($context['type'] ?? null) {
            case 'post_202_sent':
                return resolve(null);

            case 'post_sse':
                $streamId = $context['streamId'];
                if (!isset($this->activeSseStreams[$streamId])) {
                    $this->logger->error("SSE stream for POST not found.", ['streamId' => $streamId, 'sessionId' => $sessionId]);
                    return reject(new TransportException("SSE stream {$streamId} not found for POST response."));
                }

                $stream = $this->activeSseStreams[$streamId]['stream'];
                if (!$stream->isWritable()) {
                    $this->logger->warning("SSE stream for POST is not writable.", ['streamId' => $streamId, 'sessionId' => $sessionId]);
                    return reject(new TransportException("SSE stream {$streamId} for POST is not writable."));
                }

                $sentCountThisCall = 0;

                if ($message instanceof Response || $message instanceof Error) {
                    $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
                    $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null;
                    $this->sendSseEventToStream($stream, $json, $eventId);
                    $sentCountThisCall = 1;
                } elseif ($message instanceof BatchResponse) {
                    foreach ($message->getAll() as $singleResponse) {
                        $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
                        $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null;
                        $this->sendSseEventToStream($stream, $json, $eventId);
                        $sentCountThisCall++;
                    }
                }

                if (isset($this->activeSseStreams[$streamId]['context'])) {
                    $this->activeSseStreams[$streamId]['context']['nResponses'] += $sentCountThisCall;
                    if ($this->activeSseStreams[$streamId]['context']['nResponses'] >= $this->activeSseStreams[$streamId]['context']['nRequests']) {
                        $this->logger->info("All expected responses sent for POST SSE stream. Closing.", ['streamId' => $streamId, 'sessionId' => $sessionId]);
                        $stream->end(); // Will trigger 'close' event.

                        if ($context['stateless'] ?? false) {
                            $this->loop->futureTick(function () use ($sessionId) {
                                $this->emit('client_disconnected', [$sessionId, 'Stateless request completed']);
                            });
                        }
                    }
                }

                return resolve(null);

            case 'post_json':
                $pendingRequestId = $context['pending_request_id'];
                if (!isset($this->pendingRequests[$pendingRequestId])) {
                    $this->logger->error("Pending direct JSON request not found.", ['pending_request_id' => $pendingRequestId, 'session_id' => $sessionId]);
                    return reject(new TransportException("Pending request {$pendingRequestId} not found."));
                }

                $deferred = $this->pendingRequests[$pendingRequestId];
                unset($this->pendingRequests[$pendingRequestId]);

                $responseBody = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
                $headers = ['Content-Type' => 'application/json'];
                if ($isInitializeResponse && !$this->stateless) {
                    $headers['Mcp-Session-Id'] = $sessionId;
                }

                $statusCode = $context['status_code'] ?? 200;
                $deferred->resolve(new HttpResponse($statusCode, $headers, $responseBody . "\n"));

                if ($context['stateless'] ?? false) {
                    $this->loop->futureTick(function () use ($sessionId) {
                        $this->emit('client_disconnected', [$sessionId, 'Stateless request completed']);
                    });
                }

                return resolve(null);

            default:
                if ($this->getStream === null) {
                    $this->logger->error("GET SSE stream not found.", ['sessionId' => $sessionId]);
                    return reject(new TransportException("GET SSE stream not found."));
                }

                if (!$this->getStream->isWritable()) {
                    $this->logger->warning("GET SSE stream is not writable.", ['sessionId' => $sessionId]);
                    return reject(new TransportException("GET SSE stream not writable."));
                }
                if ($message instanceof Response || $message instanceof Error) {
                    $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
                    $eventId = $this->eventStore ? $this->eventStore->storeEvent('GET_STREAM', $json) : null;
                    $this->sendSseEventToStream($this->getStream, $json, $eventId);
                } elseif ($message instanceof BatchResponse) {
                    foreach ($message->getAll() as $singleResponse) {
                        $json = json_encode($singleResponse, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
                        $eventId = $this->eventStore ? $this->eventStore->storeEvent('GET_STREAM', $json) : null;
                        $this->sendSseEventToStream($this->getStream, $json, $eventId);
                    }
                }
                return resolve(null);
        }
    }

    private function replayEvents(string $lastEventId, ThroughStream $sseStream, string $sessionId): void
    {
        if (empty($lastEventId)) {
            return;
        }

        try {
            $this->eventStore->replayEventsAfter(
                $lastEventId,
                function (string $replayedEventId, string $json) use ($sseStream) {
                    $this->logger->debug("Replaying event", ['replayedEventId' => $replayedEventId]);
                    $this->sendSseEventToStream($sseStream, $json, $replayedEventId);
                }
            );
        } catch (Throwable $e) {
            $this->logger->error("Error during event replay.", ['sessionId' => $sessionId, 'exception' => $e]);
        }
    }

    private function sendSseEventToStream(ThroughStream $stream, string $data, ?string $eventId = null): bool
    {
        if (! $stream->isWritable()) {
            return false;
        }

        $frame = "event: message\n";
        if ($eventId !== null) {
            $frame .= "id: {$eventId}\n";
        }

        $lines = explode("\n", $data);
        foreach ($lines as $line) {
            $frame .= "data: {$line}\n";
        }
        $frame .= "\n";

        return $stream->write($frame);
    }

    public function close(): void
    {
        if ($this->closing) {
            return;
        }

        $this->closing = true;
        $this->listening = false;
        $this->logger->info('Closing transport...');

        if ($this->socket) {
            $this->socket->close();
            $this->socket = null;
        }

        foreach ($this->activeSseStreams as $streamId => $streamInfo) {
            if ($streamInfo['stream']->isWritable()) {
                $streamInfo['stream']->end();
            }
        }

        if ($this->getStream !== null) {
            $this->getStream->end();
            $this->getStream = null;
        }

        foreach ($this->pendingRequests as $pendingRequestId => $deferred) {
            $deferred->reject(new TransportException('Transport is closing.'));
        }

        $this->activeSseStreams = [];
        $this->pendingRequests = [];

        $this->emit('close', ['Transport closed.']);
        $this->removeAllListeners();
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Session;

use PhpMcp\Server\Contracts\SessionHandlerInterface;
use PhpMcp\Server\Contracts\SessionInterface;

class Session implements SessionInterface
{
    /**
     * @var array<string, mixed> Stores all session data.
     * Keys are snake_case by convention for MCP-specific data.
     *
     * Official keys are:
     * - initialized: bool
     * - client_info: array|null
     * - protocol_version: string|null
     * - subscriptions: array<string, bool>
     * - message_queue: array<string>
     * - log_level: string|null
     */
    protected array $data = [];

    public function __construct(
        protected SessionHandlerInterface $handler,
        protected string $id = '',
        ?array $data = null
    ) {
        if (empty($this->id)) {
            $this->id = $this->generateId();
        }

        if ($data !== null) {
            $this->hydrate($data);
        } elseif ($sessionData = $this->handler->read($this->id)) {
            $this->data = json_decode($sessionData, true) ?? [];
        }
    }

    /**
     * Retrieve an existing session instance from handler or return null if session doesn't exist
     */
    public static function retrieve(string $id, SessionHandlerInterface $handler): ?SessionInterface
    {
        $sessionData = $handler->read($id);

        if (!$sessionData) {
            return null;
        }

        $data = json_decode($sessionData, true);
        if ($data === null) {
            return null;
        }

        return new static($handler, $id, $data);
    }

    public function getId(): string
    {
        return $this->id;
    }

    public function getHandler(): SessionHandlerInterface
    {
        return $this->handler;
    }

    public function generateId(): string
    {
        return bin2hex(random_bytes(16));
    }

    public function save(): void
    {
        $this->handler->write($this->id, json_encode($this->data));
    }

    public function get(string $key, mixed $default = null): mixed
    {
        $key = explode('.', $key);
        $data = $this->data;

        foreach ($key as $segment) {
            if (is_array($data) && array_key_exists($segment, $data)) {
                $data = $data[$segment];
            } else {
                return $default;
            }
        }

        return $data;
    }

    public function set(string $key, mixed $value, bool $overwrite = true): void
    {
        $segments = explode('.', $key);
        $data = &$this->data;

        while (count($segments) > 1) {
            $segment = array_shift($segments);
            if (!isset($data[$segment]) || !is_array($data[$segment])) {
                $data[$segment] = [];
            }
            $data = &$data[$segment];
        }

        $lastKey = array_shift($segments);
        if ($overwrite || !isset($data[$lastKey])) {
            $data[$lastKey] = $value;
        }
    }

    public function has(string $key): bool
    {
        $key = explode('.', $key);
        $data = $this->data;

        foreach ($key as $segment) {
            if (is_array($data) && array_key_exists($segment, $data)) {
                $data = $data[$segment];
            } elseif (is_object($data) && isset($data->{$segment})) {
                $data = $data->{$segment};
            } else {
                return false;
            }
        }

        return true;
    }

    public function forget(string $key): void
    {
        $segments = explode('.', $key);
        $data = &$this->data;

        while (count($segments) > 1) {
            $segment = array_shift($segments);
            if (!isset($data[$segment]) || !is_array($data[$segment])) {
                $data[$segment] = [];
            }
            $data = &$data[$segment];
        }

        $lastKey = array_shift($segments);
        if (isset($data[$lastKey])) {
            unset($data[$lastKey]);
        }
    }

    public function clear(): void
    {
        $this->data = [];
    }

    public function pull(string $key, mixed $default = null): mixed
    {
        $value = $this->get($key, $default);
        $this->forget($key);
        return $value;
    }

    public function all(): array
    {
        return $this->data;
    }

    public function hydrate(array $attributes): void
    {
        $this->data = array_merge(
            [
                'initialized' => false,
                'client_info' => null,
                'protocol_version' => null,
                'message_queue' => [],
                'log_level' => null,
            ],
            $attributes
        );
        unset($this->data['id']);
    }

    public function queueMessage(string $rawFramedMessage): void
    {
        $this->data['message_queue'][] = $rawFramedMessage;
    }

    public function dequeueMessages(): array
    {
        $messages = $this->data['message_queue'] ?? [];
        $this->data['message_queue'] = [];
        return $messages;
    }

    public function hasQueuedMessages(): bool
    {
        return !empty($this->data['message_queue']);
    }

    public function jsonSerialize(): array
    {
        return $this->all();
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Transports;

use Evenement\EventEmitterTrait;
use PhpMcp\Server\Contracts\LoggerAwareInterface;
use PhpMcp\Server\Contracts\LoopAwareInterface;
use PhpMcp\Server\Contracts\ServerTransportInterface;
use PhpMcp\Server\Exception\TransportException;
use PhpMcp\Schema\JsonRpc\Message;
use PhpMcp\Schema\JsonRpc\Error;
use PhpMcp\Schema\JsonRpc\Parser;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
use React\Http\HttpServer;
use React\Http\Message\Response;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use React\Socket\SocketServer;
use React\Stream\ThroughStream;
use React\Stream\WritableStreamInterface;
use Throwable;

use function React\Promise\resolve;
use function React\Promise\reject;

/**
 * Implementation of the HTTP+SSE server transport using ReactPHP components.
 *
 * Listens for HTTP connections, manages SSE streams, and emits events.
 */
class HttpServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface
{
    use EventEmitterTrait;

    protected LoggerInterface $logger;

    protected LoopInterface $loop;

    protected ?SocketServer $socket = null;

    protected ?HttpServer $http = null;

    /** @var array<string, ThroughStream> sessionId => SSE Stream */
    private array $activeSseStreams = [];

    protected bool $listening = false;

    protected bool $closing = false;

    protected string $ssePath;

    protected string $messagePath;

    /**
     * @param  string  $host  Host to bind to (e.g., '127.0.0.1', '0.0.0.0').
     * @param  int  $port  Port to listen on (e.g., 8080).
     * @param  string  $mcpPathPrefix  URL prefix for MCP endpoints (e.g., 'mcp').
     * @param  array|null  $sslContext  Optional SSL context options for React SocketServer (for HTTPS).
     */
    public function __construct(
        private readonly string $host = '127.0.0.1',
        private readonly int $port = 8080,
        private readonly string $mcpPathPrefix = 'mcp',
        private readonly ?array $sslContext = null,
    ) {
        $this->logger = new NullLogger();
        $this->loop = Loop::get();
        $this->ssePath = '/' . trim($mcpPathPrefix, '/') . '/sse';
        $this->messagePath = '/' . trim($mcpPathPrefix, '/') . '/message';
    }

    public function setLogger(LoggerInterface $logger): void
    {
        $this->logger = $logger;
    }

    public function setLoop(LoopInterface $loop): void
    {
        $this->loop = $loop;
    }

    protected function generateId(): string
    {
        return bin2hex(random_bytes(16)); // 32 hex characters
    }

    /**
     * Starts the HTTP server listener.
     *
     * @throws TransportException If port binding fails.
     */
    public function listen(): void
    {
        if ($this->listening) {
            throw new TransportException('Http transport is already listening.');
        }
        if ($this->closing) {
            throw new TransportException('Cannot listen, transport is closing/closed.');
        }

        $listenAddress = "{$this->host}:{$this->port}";
        $protocol = $this->sslContext ? 'https' : 'http';

        try {
            $this->socket = new SocketServer(
                $listenAddress,
                $this->sslContext ?? [],
                $this->loop
            );

            $this->http = new HttpServer($this->loop, $this->createRequestHandler());
            $this->http->listen($this->socket);

            $this->socket->on('error', function (Throwable $error) {
                $this->logger->error('Socket server error.', ['error' => $error->getMessage()]);
                $this->emit('error', [new TransportException("Socket server error: {$error->getMessage()}", 0, $error)]);
                $this->close();
            });

            $this->logger->info("Server is up and listening on {$protocol}://{$listenAddress} 🚀");
            $this->logger->info("SSE Endpoint: {$protocol}://{$listenAddress}{$this->ssePath}");
            $this->logger->info("Message Endpoint: {$protocol}://{$listenAddress}{$this->messagePath}");

            $this->listening = true;
            $this->closing = false;
            $this->emit('ready');
        } catch (Throwable $e) {
            $this->logger->error("Failed to start listener on {$listenAddress}", ['exception' => $e]);
            throw new TransportException("Failed to start HTTP listener on {$listenAddress}: {$e->getMessage()}", 0, $e);
        }
    }

    /** Creates the main request handling callback for ReactPHP HttpServer */
    protected function createRequestHandler(): callable
    {
        return function (ServerRequestInterface $request) {
            $path = $request->getUri()->getPath();
            $method = $request->getMethod();
            $this->logger->debug('Received request', ['method' => $method, 'path' => $path]);

            if ($method === 'GET' && $path === $this->ssePath) {
                return $this->handleSseRequest($request);
            }

            if ($method === 'POST' && $path === $this->messagePath) {
                return $this->handleMessagePostRequest($request);
            }

            $this->logger->debug('404 Not Found', ['method' => $method, 'path' => $path]);

            return new Response(404, ['Content-Type' => 'text/plain'], 'Not Found');
        };
    }

    /** Handles a new SSE connection request */
    protected function handleSseRequest(ServerRequestInterface $request): Response
    {
        $sessionId = $this->generateId();
        $this->logger->info('New SSE connection', ['sessionId' => $sessionId]);

        $sseStream = new ThroughStream();

        $sseStream->on('close', function () use ($sessionId) {
            $this->logger->info('SSE stream closed', ['sessionId' => $sessionId]);
            unset($this->activeSseStreams[$sessionId]);
            $this->emit('client_disconnected', [$sessionId, 'SSE stream closed']);
        });

        $sseStream->on('error', function (Throwable $error) use ($sessionId) {
            $this->logger->warning('SSE stream error', ['sessionId' => $sessionId, 'error' => $error->getMessage()]);
            unset($this->activeSseStreams[$sessionId]);
            $this->emit('error', [new TransportException("SSE Stream Error: {$error->getMessage()}", 0, $error), $sessionId]);
            $this->emit('client_disconnected', [$sessionId, 'SSE stream error']);
        });

        $this->activeSseStreams[$sessionId] = $sseStream;

        $this->loop->futureTick(function () use ($sessionId, $request, $sseStream) {
            if (! isset($this->activeSseStreams[$sessionId]) || ! $sseStream->isWritable()) {
                $this->logger->warning('Cannot send initial endpoint event, stream closed/invalid early.', ['sessionId' => $sessionId]);

                return;
            }

            try {
                $baseUri = $request->getUri()->withPath($this->messagePath)->withQuery('')->withFragment('');
                $postEndpointWithId = (string) $baseUri->withQuery("clientId={$sessionId}");
                $this->sendSseEvent($sseStream, 'endpoint', $postEndpointWithId, "init-{$sessionId}");

                $this->emit('client_connected', [$sessionId]);
            } catch (Throwable $e) {
                $this->logger->error('Error sending initial endpoint event', ['sessionId' => $sessionId, 'exception' => $e]);
                $sseStream->close();
            }
        });

        return new Response(
            200,
            [
                'Content-Type' => 'text/event-stream',
                'Cache-Control' => 'no-cache',
                'Connection' => 'keep-alive',
                'X-Accel-Buffering' => 'no',
                'Access-Control-Allow-Origin' => '*',
            ],
            $sseStream
        );
    }

    /** Handles incoming POST requests with messages */
    protected function handleMessagePostRequest(ServerRequestInterface $request): Response
    {
        $queryParams = $request->getQueryParams();
        $sessionId = $queryParams['clientId'] ?? null;
        $jsonEncodeFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;

        if (! $sessionId || ! is_string($sessionId)) {
            $this->logger->warning('Received POST without valid clientId query parameter.');
            $error = Error::forInvalidRequest('Missing or invalid clientId query parameter');

            return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags));
        }

        if (! isset($this->activeSseStreams[$sessionId])) {
            $this->logger->warning('Received POST for unknown or disconnected sessionId.', ['sessionId' => $sessionId]);

            $error = Error::forInvalidRequest('Session ID not found or disconnected');

            return new Response(404, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags));
        }

        if (! str_contains(strtolower($request->getHeaderLine('Content-Type')), 'application/json')) {
            $error = Error::forInvalidRequest('Content-Type must be application/json');

            return new Response(415, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags));
        }

        $body = $request->getBody()->getContents();

        if (empty($body)) {
            $this->logger->warning('Received empty POST body', ['sessionId' => $sessionId]);

            $error = Error::forInvalidRequest('Empty request body');

            return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags));
        }

        try {
            $message = Parser::parse($body);
        } catch (Throwable $e) {
            $this->logger->error('Error parsing message', ['sessionId' => $sessionId, 'exception' => $e]);

            $error = Error::forParseError('Invalid JSON-RPC message: ' . $e->getMessage());

            return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags));
        }

        $this->emit('message', [$message, $sessionId]);

        return new Response(202, ['Content-Type' => 'text/plain'], 'Accepted');
    }

    /**
     * Sends a raw JSON-RPC message frame to a specific client via SSE.
     */
    public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface
    {
        if (! isset($this->activeSseStreams[$sessionId])) {
            return reject(new TransportException("Cannot send message: Client '{$sessionId}' not connected via SSE."));
        }

        $stream = $this->activeSseStreams[$sessionId];
        if (! $stream->isWritable()) {
            return reject(new TransportException("Cannot send message: SSE stream for client '{$sessionId}' is not writable."));
        }

        $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

        if ($json === '') {
            return resolve(null);
        }

        $deferred = new Deferred();
        $written = $this->sendSseEvent($stream, 'message', $json);

        if ($written) {
            $deferred->resolve(null);
        } else {
            $this->logger->debug('SSE stream buffer full, waiting for drain.', ['sessionId' => $sessionId]);
            $stream->once('drain', function () use ($deferred, $sessionId) {
                $this->logger->debug('SSE stream drained.', ['sessionId' => $sessionId]);
                $deferred->resolve(null);
            });
        }

        return $deferred->promise();
    }

    /** Helper to format and write an SSE event */
    private function sendSseEvent(WritableStreamInterface $stream, string $event, string $data, ?string $id = null): bool
    {
        if (! $stream->isWritable()) {
            return false;
        }

        $frame = "event: {$event}\n";
        if ($id !== null) {
            $frame .= "id: {$id}\n";
        }

        $lines = explode("\n", $data);
        foreach ($lines as $line) {
            $frame .= "data: {$line}\n";
        }
        $frame .= "\n"; // End of event

        $this->logger->debug('Sending SSE event', ['event' => $event, 'frame' => $frame]);

        return $stream->write($frame);
    }

    /**
     * Stops the HTTP server and closes all connections.
     */
    public function close(): void
    {
        if ($this->closing) {
            return;
        }
        $this->closing = true;
        $this->listening = false;
        $this->logger->info('Closing transport...');

        if ($this->socket) {
            $this->socket->close();
            $this->socket = null;
        }

        $activeStreams = $this->activeSseStreams;
        $this->activeSseStreams = [];
        foreach ($activeStreams as $sessionId => $stream) {
            $this->logger->debug('Closing active SSE stream', ['sessionId' => $sessionId]);
            unset($this->activeSseStreams[$sessionId]);
            $stream->close();
        }

        $this->emit('close', ['HttpTransport closed.']);
        $this->removeAllListeners();
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server;

use Closure;
use PhpMcp\Schema\Annotations;
use PhpMcp\Schema\Implementation;
use PhpMcp\Schema\Prompt;
use PhpMcp\Schema\PromptArgument;
use PhpMcp\Schema\Resource;
use PhpMcp\Schema\ResourceTemplate;
use PhpMcp\Schema\ServerCapabilities;
use PhpMcp\Schema\Tool;
use PhpMcp\Schema\ToolAnnotations;
use PhpMcp\Server\Attributes\CompletionProvider;
use PhpMcp\Server\Contracts\SessionHandlerInterface;
use PhpMcp\Server\Defaults\BasicContainer;
use PhpMcp\Server\Defaults\EnumCompletionProvider;
use PhpMcp\Server\Defaults\ListCompletionProvider;
use PhpMcp\Server\Exception\ConfigurationException;

use PhpMcp\Server\Session\ArraySessionHandler;
use PhpMcp\Server\Session\CacheSessionHandler;
use PhpMcp\Server\Session\SessionManager;
use PhpMcp\Server\Utils\HandlerResolver;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Psr\SimpleCache\CacheInterface;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
use Throwable;

final class ServerBuilder
{
    private ?Implementation $serverInfo = null;

    private ?ServerCapabilities $capabilities = null;

    private ?LoggerInterface $logger = null;

    private ?CacheInterface $cache = null;

    private ?ContainerInterface $container = null;

    private ?LoopInterface $loop = null;

    private ?SessionHandlerInterface $sessionHandler = null;

    private ?string $sessionDriver = null;

    private ?int $sessionTtl = 3600;

    private ?int $paginationLimit = 50;

    private ?string $instructions = null;

    /** @var array<
     *     array{handler: array|string|Closure,
     *     name: string|null,
     *     description: string|null,
     *     annotations: ToolAnnotations|null}
     * > */
    private array $manualTools = [];

    /** @var array<
     *     array{handler: array|string|Closure,
     *     uri: string,
     *     name: string|null,
     *     description: string|null,
     *     mimeType: string|null,
     *     size: int|null,
     *     annotations: Annotations|null}
     * > */
    private array $manualResources = [];

    /** @var array<
     *     array{handler: array|string|Closure,
     *     uriTemplate: string,
     *     name: string|null,
     *     description: string|null,
     *     mimeType: string|null,
     *     annotations: Annotations|null}
     * > */
    private array $manualResourceTemplates = [];

    /** @var array<
     *     array{handler: array|string|Closure,
     *     name: string|null,
     *     description: string|null}
     * > */
    private array $manualPrompts = [];

    public function __construct() {}

    /**
     * Sets the server's identity. Required.
     */
    public function withServerInfo(string $name, string $version): self
    {
        $this->serverInfo = Implementation::make(name: trim($name), version: trim($version));

        return $this;
    }

    /**
     * Configures the server's declared capabilities.
     */
    public function withCapabilities(ServerCapabilities $capabilities): self
    {
        $this->capabilities = $capabilities;

        return $this;
    }

    /**
     * Configures the server's pagination limit.
     */
    public function withPaginationLimit(int $paginationLimit): self
    {
        $this->paginationLimit = $paginationLimit;

        return $this;
    }

    /**
     * Configures the instructions describing how to use the server and its features.
     *
     * This can be used by clients to improve the LLM's understanding of available tools, resources,
     * etc. It can be thought of like a "hint" to the model. For example, this information MAY
     * be added to the system prompt.
     */
    public function withInstructions(?string $instructions): self
    {
        $this->instructions = $instructions;

        return $this;
    }

    /**
     * Provides a PSR-3 logger instance. Defaults to NullLogger.
     */
    public function withLogger(LoggerInterface $logger): self
    {
        $this->logger = $logger;

        return $this;
    }

    /**
     * Provides a PSR-16 cache instance used for all internal caching.
     */
    public function withCache(CacheInterface $cache): self
    {
        $this->cache = $cache;

        return $this;
    }

    /**
     * Configures session handling with a specific driver.
     *
     * @param 'array' | 'cache' $driver The session driver: 'array' for in-memory sessions, 'cache' for cache-backed sessions
     * @param int $ttl Session time-to-live in seconds. Defaults to 3600.
     */
    public function withSession(string $driver, int $ttl = 3600): self
    {
        if (!in_array($driver, ['array', 'cache'], true)) {
            throw new \InvalidArgumentException(
                "Unsupported session driver '{$driver}'. Only 'array' and 'cache' drivers are supported. " .
                    "For custom session handling, use withSessionHandler() instead."
            );
        }

        $this->sessionDriver = $driver;
        $this->sessionTtl = $ttl;

        return $this;
    }

    /**
     * Provides a custom session handler.
     */
    public function withSessionHandler(SessionHandlerInterface $sessionHandler, int $sessionTtl = 3600): self
    {
        $this->sessionHandler = $sessionHandler;
        $this->sessionTtl = $sessionTtl;

        return $this;
    }

    /**
     * Provides a PSR-11 DI container, primarily for resolving user-defined handler classes.
     * Defaults to a basic internal container.
     */
    public function withContainer(ContainerInterface $container): self
    {
        $this->container = $container;

        return $this;
    }

    /**
     * Provides a ReactPHP Event Loop instance. Defaults to Loop::get().
     */
    public function withLoop(LoopInterface $loop): self
    {
        $this->loop = $loop;

        return $this;
    }

    /**
     * Manually registers a tool handler.
     */
    public function withTool(callable|array|string $handler, ?string $name = null, ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null): self
    {
        $this->manualTools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema');

        return $this;
    }

    /**
     * Manually registers a resource handler.
     */
    public function withResource(callable|array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null, ?Annotations $annotations = null): self
    {
        $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations');

        return $this;
    }

    /**
     * Manually registers a resource template handler.
     */
    public function withResourceTemplate(callable|array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?Annotations $annotations = null): self
    {
        $this->manualResourceTemplates[] = compact('handler', 'uriTemplate', 'name', 'description', 'mimeType', 'annotations');

        return $this;
    }

    /**
     * Manually registers a prompt handler.
     */
    public function withPrompt(callable|array|string $handler, ?string $name = null, ?string $description = null): self
    {
        $this->manualPrompts[] = compact('handler', 'name', 'description');

        return $this;
    }

    /**
     * Builds the fully configured Server instance.
     *
     * @throws ConfigurationException If required configuration is missing.
     */
    public function build(): Server
    {
        if ($this->serverInfo === null) {
            throw new ConfigurationException('Server name and version must be provided using withServerInfo().');
        }

        $loop = $this->loop ?? Loop::get();
        $cache = $this->cache;
        $logger = $this->logger ?? new NullLogger();
        $container = $this->container ?? new BasicContainer();
        $capabilities = $this->capabilities ?? ServerCapabilities::make();

        $configuration = new Configuration(
            serverInfo: $this->serverInfo,
            capabilities: $capabilities,
            logger: $logger,
            loop: $loop,
            cache: $cache,
            container: $container,
            paginationLimit: $this->paginationLimit ?? 50,
            instructions: $this->instructions,
        );

        $sessionHandler = $this->createSessionHandler();
        $sessionManager = new SessionManager($sessionHandler, $logger, $loop, $this->sessionTtl);
        $registry = new Registry($logger, $cache, $sessionManager);
        $protocol = new Protocol($configuration, $registry, $sessionManager);

        $registry->disableNotifications();

        $this->registerManualElements($registry, $logger);

        $registry->enableNotifications();

        $server = new Server($configuration, $registry, $protocol, $sessionManager);

        return $server;
    }

    /**
     * Helper to perform the actual registration based on stored data.
     * Moved into the builder.
     */
    private function registerManualElements(Registry $registry, LoggerInterface $logger): void
    {
        if (empty($this->manualTools) && empty($this->manualResources) && empty($this->manualResourceTemplates) && empty($this->manualPrompts)) {
            return;
        }

        $docBlockParser = new Utils\DocBlockParser($logger);
        $schemaGenerator = new Utils\SchemaGenerator($docBlockParser);

        // Register Tools
        foreach ($this->manualTools as $data) {
            try {
                $reflection = HandlerResolver::resolve($data['handler']);

                if ($reflection instanceof \ReflectionFunction) {
                    $name = $data['name'] ?? 'closure_tool_' . spl_object_id($data['handler']);
                    $description = $data['description'] ?? null;
                } else {
                    $classShortName = $reflection->getDeclaringClass()->getShortName();
                    $methodName = $reflection->getName();
                    $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null);

                    $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName);
                    $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null;
                }

                $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection);

                $tool = Tool::make($name, $inputSchema, $description, $data['annotations']);
                $registry->registerTool($tool, $data['handler'], true);

                $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']);
                $logger->debug("Registered manual tool {$name} from handler {$handlerDesc}");
            } catch (Throwable $e) {
                $logger->error('Failed to register manual tool', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]);
                throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e);
            }
        }

        // Register Resources
        foreach ($this->manualResources as $data) {
            try {
                $reflection = HandlerResolver::resolve($data['handler']);

                if ($reflection instanceof \ReflectionFunction) {
                    $name = $data['name'] ?? 'closure_resource_' . spl_object_id($data['handler']);
                    $description = $data['description'] ?? null;
                } else {
                    $classShortName = $reflection->getDeclaringClass()->getShortName();
                    $methodName = $reflection->getName();
                    $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null);

                    $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName);
                    $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null;
                }

                $uri = $data['uri'];
                $mimeType = $data['mimeType'];
                $size = $data['size'];
                $annotations = $data['annotations'];

                $resource = Resource::make($uri, $name, $description, $mimeType, $annotations, $size);
                $registry->registerResource($resource, $data['handler'], true);

                $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']);
                $logger->debug("Registered manual resource {$name} from handler {$handlerDesc}");
            } catch (Throwable $e) {
                $logger->error('Failed to register manual resource', ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e]);
                throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e);
            }
        }

        // Register Templates
        foreach ($this->manualResourceTemplates as $data) {
            try {
                $reflection = HandlerResolver::resolve($data['handler']);

                if ($reflection instanceof \ReflectionFunction) {
                    $name = $data['name'] ?? 'closure_template_' . spl_object_id($data['handler']);
                    $description = $data['description'] ?? null;
                } else {
                    $classShortName = $reflection->getDeclaringClass()->getShortName();
                    $methodName = $reflection->getName();
                    $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null);

                    $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName);
                    $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null;
                }

                $uriTemplate = $data['uriTemplate'];
                $mimeType = $data['mimeType'];
                $annotations = $data['annotations'];

                $template = ResourceTemplate::make($uriTemplate, $name, $description, $mimeType, $annotations);
                $completionProviders = $this->getCompletionProviders($reflection);
                $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true);

                $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']);
                $logger->debug("Registered manual template {$name} from handler {$handlerDesc}");
            } catch (Throwable $e) {
                $logger->error('Failed to register manual template', ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e]);
                throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e);
            }
        }

        // Register Prompts
        foreach ($this->manualPrompts as $data) {
            try {
                $reflection = HandlerResolver::resolve($data['handler']);

                if ($reflection instanceof \ReflectionFunction) {
                    $name = $data['name'] ?? 'closure_prompt_' . spl_object_id($data['handler']);
                    $description = $data['description'] ?? null;
                } else {
                    $classShortName = $reflection->getDeclaringClass()->getShortName();
                    $methodName = $reflection->getName();
                    $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null);

                    $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName);
                    $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null;
                }

                $arguments = [];
                $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags($docBlockParser->parseDocBlock($reflection->getDocComment() ?? null)) : [];
                foreach ($reflection->getParameters() as $param) {
                    $reflectionType = $param->getType();

                    // Basic DI check (heuristic)
                    if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) {
                        continue;
                    }

                    $paramTag = $paramTags['$' . $param->getName()] ?? null;
                    $arguments[] = PromptArgument::make(
                        name: $param->getName(),
                        description: $paramTag ? trim((string) $paramTag->getDescription()) : null,
                        required: ! $param->isOptional() && ! $param->isDefaultValueAvailable()
                    );
                }

                $prompt = Prompt::make($name, $description, $arguments);
                $completionProviders = $this->getCompletionProviders($reflection);
                $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true);

                $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']);
                $logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}");
            } catch (Throwable $e) {
                $logger->error('Failed to register manual prompt', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]);
                throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e);
            }
        }

        $logger->debug('Manual element registration complete.');
    }

    /**
     * Creates the appropriate session handler based on configuration.
     *
     * @throws ConfigurationException If cache driver is selected but no cache is provided
     */
    private function createSessionHandler(): SessionHandlerInterface
    {
        // If a custom session handler was provided, use it
        if ($this->sessionHandler !== null) {
            return $this->sessionHandler;
        }

        // If no session driver was specified, default to array
        if ($this->sessionDriver === null) {
            return new ArraySessionHandler($this->sessionTtl ?? 3600);
        }

        // Create handler based on driver
        return match ($this->sessionDriver) {
            'array' => new ArraySessionHandler($this->sessionTtl ?? 3600),
            'cache' => $this->createCacheSessionHandler(),
            default => throw new ConfigurationException("Unsupported session driver: {$this->sessionDriver}")
        };
    }

    /**
     * Creates a cache-based session handler.
     *
     * @throws ConfigurationException If no cache is configured
     */
    private function createCacheSessionHandler(): CacheSessionHandler
    {
        if ($this->cache === null) {
            throw new ConfigurationException(
                "Cache session driver requires a cache instance. Please configure a cache using withCache() before using withSession('cache')."
            );
        }

        return new CacheSessionHandler($this->cache, $this->sessionTtl ?? 3600);
    }

    private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array
    {
        $completionProviders = [];
        foreach ($reflection->getParameters() as $param) {
            $reflectionType = $param->getType();
            if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
                continue;
            }

            $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF);
            if (!empty($completionAttributes)) {
                $attributeInstance = $completionAttributes[0]->newInstance();

                if ($attributeInstance->provider) {
                    $completionProviders[$param->getName()] = $attributeInstance->provider;
                } elseif ($attributeInstance->providerClass) {
                    $completionProviders[$param->getName()] = $attributeInstance->providerClass;
                } elseif ($attributeInstance->values) {
                    $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values);
                } elseif ($attributeInstance->enum) {
                    $completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum);
                }
            }
        }

        return $completionProviders;
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Session;

use Evenement\EventEmitterInterface;
use Evenement\EventEmitterTrait;
use PhpMcp\Server\Contracts\SessionHandlerInterface;
use PhpMcp\Server\Contracts\SessionInterface;
use Psr\Log\LoggerInterface;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
use React\EventLoop\TimerInterface;

class SessionManager implements EventEmitterInterface
{
    use EventEmitterTrait;

    protected ?TimerInterface $gcTimer = null;

    public function __construct(
        protected SessionHandlerInterface $handler,
        protected LoggerInterface $logger,
        protected ?LoopInterface $loop = null,
        protected int $ttl = 3600,
        protected int|float $gcInterval = 300
    ) {
        $this->loop ??= Loop::get();
    }

    /**
     * Start the garbage collection timer
     */
    public function startGcTimer(): void
    {
        if ($this->gcTimer !== null) {
            return;
        }

        $this->gcTimer = $this->loop->addPeriodicTimer($this->gcInterval, [$this, 'gc']);
    }

    public function gc(): array
    {
        $deletedSessions = $this->handler->gc($this->ttl);

        foreach ($deletedSessions as $sessionId) {
            $this->emit('session_deleted', [$sessionId]);
        }

        if (count($deletedSessions) > 0) {
            $this->logger->debug('Session garbage collection complete', [
                'purged_sessions' => count($deletedSessions),
            ]);
        }

        return $deletedSessions;
    }

    /**
     * Stop the garbage collection timer
     */
    public function stopGcTimer(): void
    {
        if ($this->gcTimer !== null) {
            $this->loop->cancelTimer($this->gcTimer);
            $this->gcTimer = null;
        }
    }

    /**
     * Create a new session
     */
    public function createSession(string $sessionId): SessionInterface
    {
        $session = new Session($this->handler, $sessionId);

        $session->hydrate([
            'initialized' => false,
            'client_info' => null,
            'protocol_version' => null,
            'subscriptions' => [],      // [uri => true]
            'message_queue' => [],      // string[] (raw JSON-RPC frames)
            'log_level' => null,
        ]);

        $session->save();

        $this->logger->info('Session created', ['sessionId' => $sessionId]);
        $this->emit('session_created', [$sessionId, $session]);

        return $session;
    }

    /**
     * Get an existing session
     */
    public function getSession(string $sessionId): ?SessionInterface
    {
        return Session::retrieve($sessionId, $this->handler);
    }

    public function hasSession(string $sessionId): bool
    {
        return $this->getSession($sessionId) !== null;
    }

    /**
     * Delete a session completely
     */
    public function deleteSession(string $sessionId): bool
    {
        $success = $this->handler->destroy($sessionId);

        if ($success) {
            $this->emit('session_deleted', [$sessionId]);
            $this->logger->info('Session deleted', ['sessionId' => $sessionId]);
        } else {
            $this->logger->warning('Failed to delete session', ['sessionId' => $sessionId]);
        }

        return $success;
    }

    public function queueMessage(string $sessionId, string $message): void
    {
        $session = $this->getSession($sessionId);
        if ($session === null) {
            return;
        }

        $session->queueMessage($message);
        $session->save();
    }

    public function dequeueMessages(string $sessionId): array
    {
        $session = $this->getSession($sessionId);
        if ($session === null) {
            return [];
        }

        $messages = $session->dequeueMessages();
        $session->save();

        return $messages;
    }

    public function hasQueuedMessages(string $sessionId): bool
    {
        $session = $this->getSession($sessionId, true);
        if ($session === null) {
            return false;
        }

        return $session->hasQueuedMessages();
    }
}

<?php

declare(strict_types=1); // Added missing strict_types

namespace PhpMcp\Server\Defaults;

use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use ReflectionClass;
use ReflectionException;
use ReflectionNamedType;
use ReflectionParameter;
use Throwable; // Changed from \Throwable to Throwable

/**
 * A basic PSR-11 container implementation with simple constructor auto-wiring.
 *
 * Supports instantiating classes with parameterless constructors or constructors
 * where all parameters are type-hinted classes/interfaces known to the container,
 * or have default values. Does NOT support scalar/built-in type injection without defaults.
 */
class BasicContainer implements ContainerInterface
{
    /** @var array<string, object> Cache for already created instances (shared singletons) */
    private array $instances = [];

    /** @var array<string, bool> Track classes currently being resolved to detect circular dependencies */
    private array $resolving = [];

    /**
     * Finds an entry of the container by its identifier and returns it.
     *
     * @param  string  $id  Identifier of the entry to look for (usually a FQCN).
     * @return mixed Entry.
     *
     * @throws NotFoundExceptionInterface No entry was found for **this** identifier.
     * @throws ContainerExceptionInterface Error while retrieving the entry (e.g., dependency resolution failure, circular dependency).
     */
    public function get(string $id): mixed
    {
        // 1. Check instance cache
        if (isset($this->instances[$id])) {
            return $this->instances[$id];
        }

        // 2. Check if class exists
        if (! class_exists($id) && ! interface_exists($id)) { // Also check interface for bindings
            throw new NotFoundException("Class, interface, or entry '{$id}' not found.");
        }

        // 7. Circular Dependency Check
        if (isset($this->resolving[$id])) {
            throw new ContainerException("Circular dependency detected while resolving '{$id}'. Resolution path: ".implode(' -> ', array_keys($this->resolving))." -> {$id}");
        }

        $this->resolving[$id] = true; // Mark as currently resolving

        try {
            // 3. Reflect on the class
            $reflector = new ReflectionClass($id);

            // Check if class is instantiable (abstract classes, interfaces cannot be directly instantiated)
            if (! $reflector->isInstantiable()) {
                // We might have an interface bound to a concrete class via set()
                // This check is slightly redundant due to class_exists but good practice
                throw new ContainerException("Class '{$id}' is not instantiable (e.g., abstract class or interface without explicit binding).");
            }

            // 4. Get the constructor
            $constructor = $reflector->getConstructor();

            // 5. If no constructor or constructor has no parameters, instantiate directly
            if ($constructor === null || $constructor->getNumberOfParameters() === 0) {
                $instance = $reflector->newInstance();
            } else {
                // 6. Constructor has parameters, attempt to resolve them
                $parameters = $constructor->getParameters();
                $resolvedArgs = [];

                foreach ($parameters as $parameter) {
                    $resolvedArgs[] = $this->resolveParameter($parameter, $id);
                }

                // Instantiate with resolved arguments
                $instance = $reflector->newInstanceArgs($resolvedArgs);
            }

            // Cache the instance
            $this->instances[$id] = $instance;

            return $instance;

        } catch (ReflectionException $e) {
            throw new ContainerException("Reflection failed for '{$id}'.", 0, $e);
        } catch (ContainerExceptionInterface $e) { // Re-throw container exceptions directly
            throw $e;
        } catch (Throwable $e) { // Catch other instantiation errors
            throw new ContainerException("Failed to instantiate or resolve dependencies for '{$id}': ".$e->getMessage(), (int) $e->getCode(), $e);
        } finally {
            // 7. Remove from resolving stack once done (success or failure)
            unset($this->resolving[$id]);
        }
    }

    /**
     * Attempts to resolve a single constructor parameter.
     *
     * @throws ContainerExceptionInterface If a required dependency cannot be resolved.
     */
    private function resolveParameter(ReflectionParameter $parameter, string $consumerClassId): mixed
    {
        // Check for type hint
        $type = $parameter->getType();

        if ($type instanceof ReflectionNamedType && ! $type->isBuiltin()) {
            // Type hint is a class or interface name
            $typeName = $type->getName();
            try {
                // Recursively get the dependency
                return $this->get($typeName);
            } catch (NotFoundExceptionInterface $e) {
                // Dependency class not found, fail ONLY if required
                if (! $parameter->isOptional() && ! $parameter->allowsNull()) {
                    throw new ContainerException("Unresolvable dependency '{$typeName}' required by '{$consumerClassId}' constructor parameter \${$parameter->getName()}.", 0, $e);
                }
                // If optional or nullable, proceed (will check allowsNull/Default below)
            } catch (ContainerExceptionInterface $e) {
                // Dependency itself failed to resolve (e.g., its own deps, circular)
                throw new ContainerException("Failed to resolve dependency '{$typeName}' for '{$consumerClassId}' parameter \${$parameter->getName()}: ".$e->getMessage(), 0, $e);
            }
        }

        // Check if parameter has a default value
        if ($parameter->isDefaultValueAvailable()) {
            return $parameter->getDefaultValue();
        }

        // Check if parameter allows null (and wasn't resolved above)
        if ($parameter->allowsNull()) {
            return null;
        }

        // Check if it was a built-in type without a default (unresolvable by this basic container)
        if ($type instanceof ReflectionNamedType && $type->isBuiltin()) {
            throw new ContainerException("Cannot auto-wire built-in type '{$type->getName()}' for required parameter \${$parameter->getName()} in '{$consumerClassId}' constructor. Provide a default value or use a more advanced container.");
        }

        // Check if it was a union/intersection type without a default (also unresolvable)
        if ($type !== null && ! $type instanceof ReflectionNamedType) {
            throw new ContainerException("Cannot auto-wire complex type (union/intersection) for required parameter \${$parameter->getName()} in '{$consumerClassId}' constructor. Provide a default value or use a more advanced container.");
        }

        // If we reach here, it's an untyped, required parameter without a default.
        // Or potentially an unresolvable optional class dependency where null is not allowed (edge case).
        throw new ContainerException("Cannot resolve required parameter \${$parameter->getName()} for '{$consumerClassId}' constructor (untyped or unresolvable complex type).");
    }

    /**
     * Returns true if the container can return an entry for the given identifier.
     * Checks explicitly set instances and if the class/interface exists.
     * Does not guarantee `get()` will succeed if auto-wiring fails.
     */
    public function has(string $id): bool
    {
        return isset($this->instances[$id]) || class_exists($id) || interface_exists($id);
    }

    /**
     * Adds a pre-built instance or a factory/binding to the container.
     * This basic version only supports pre-built instances (singletons).
     */
    public function set(string $id, object $instance): void
    {
        // Could add support for closures/factories later if needed
        $this->instances[$id] = $instance;
    }
}

// Keep custom exception classes as they are PSR-11 compliant placeholders
class ContainerException extends \Exception implements ContainerExceptionInterface
{
}
class NotFoundException extends \Exception implements NotFoundExceptionInterface
{
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server;

use PhpMcp\Schema\Constants;
use PhpMcp\Server\Contracts\ServerTransportInterface;
use PhpMcp\Server\Contracts\SessionInterface;
use PhpMcp\Server\Exception\McpServerException;
use PhpMcp\Schema\JsonRpc\BatchRequest;
use PhpMcp\Schema\JsonRpc\BatchResponse;
use PhpMcp\Schema\JsonRpc\Error;
use PhpMcp\Schema\JsonRpc\Notification;
use PhpMcp\Schema\JsonRpc\Request;
use PhpMcp\Schema\JsonRpc\Response;
use PhpMcp\Schema\Notification\PromptListChangedNotification;
use PhpMcp\Schema\Notification\ResourceListChangedNotification;
use PhpMcp\Schema\Notification\ResourceUpdatedNotification;
use PhpMcp\Schema\Notification\RootsListChangedNotification;
use PhpMcp\Schema\Notification\ToolListChangedNotification;
use PhpMcp\Server\Session\SessionManager;
use PhpMcp\Server\Session\SubscriptionManager;
use Psr\Log\LoggerInterface;
use React\Promise\PromiseInterface;
use Throwable;

use function React\Promise\reject;
use function React\Promise\resolve;

/**
 * Bridges the core MCP Processor logic with a ServerTransportInterface
 * by listening to transport events and processing incoming messages.
 *
 * This handler manages the JSON-RPC parsing, processing delegation, and response sending
 * based on events received from the transport layer.
 */
class Protocol
{
    public const LATEST_PROTOCOL_VERSION = '2025-03-26';
    public const SUPPORTED_PROTOCOL_VERSIONS = [self::LATEST_PROTOCOL_VERSION, '2024-11-05'];

    protected ?ServerTransportInterface $transport = null;

    protected LoggerInterface $logger;

    /** Stores listener references for proper removal */
    protected array $listeners = [];

    public function __construct(
        protected Configuration $configuration,
        protected Registry $registry,
        protected SessionManager $sessionManager,
        protected ?Dispatcher $dispatcher = null,
        protected ?SubscriptionManager $subscriptionManager = null,
    ) {
        $this->logger = $this->configuration->logger;
        $this->subscriptionManager ??= new SubscriptionManager($this->logger);
        $this->dispatcher ??= new Dispatcher($this->configuration, $this->registry, $this->subscriptionManager);

        $this->sessionManager->on('session_deleted', function (string $sessionId) {
            $this->subscriptionManager->cleanupSession($sessionId);
        });

        $this->registry->on('list_changed', function (string $listType) {
            $this->handleListChanged($listType);
        });
    }

    /**
     * Binds this handler to a transport instance by attaching event listeners.
     * Does NOT start the transport's listening process itself.
     */
    public function bindTransport(ServerTransportInterface $transport): void
    {
        if ($this->transport !== null) {
            $this->unbindTransport();
        }

        $this->transport = $transport;

        $this->listeners = [
            'message' => [$this, 'processMessage'],
            'client_connected' => [$this, 'handleClientConnected'],
            'client_disconnected' => [$this, 'handleClientDisconnected'],
            'error' => [$this, 'handleTransportError'],
        ];

        $this->transport->on('message', $this->listeners['message']);
        $this->transport->on('client_connected', $this->listeners['client_connected']);
        $this->transport->on('client_disconnected', $this->listeners['client_disconnected']);
        $this->transport->on('error', $this->listeners['error']);
    }

    /**
     * Detaches listeners from the current transport.
     */
    public function unbindTransport(): void
    {
        if ($this->transport && ! empty($this->listeners)) {
            $this->transport->removeListener('message', $this->listeners['message']);
            $this->transport->removeListener('client_connected', $this->listeners['client_connected']);
            $this->transport->removeListener('client_disconnected', $this->listeners['client_disconnected']);
            $this->transport->removeListener('error', $this->listeners['error']);
        }

        $this->transport = null;
        $this->listeners = [];
    }

    /**
     * Handles a message received from the transport.
     *
     * Processes via Processor, sends Response/Error.
     */
    public function processMessage(Request|Notification|BatchRequest $message, string $sessionId, array $messageContext = []): void
    {
        $this->logger->debug('Message received.', ['sessionId' => $sessionId, 'message' => $message]);

        $session = $this->sessionManager->getSession($sessionId);

        if ($session === null) {
            $error = Error::forInvalidRequest('Invalid or expired session. Please re-initialize the session.', $message->id);
            $messageContext['status_code'] = 404;

            $this->transport->sendMessage($error, $sessionId, $messageContext)
                ->then(function () use ($sessionId, $error, $messageContext) {
                    $this->logger->debug('Response sent.', ['sessionId' => $sessionId, 'payload' => $error, 'context' => $messageContext]);
                })
                ->catch(function (Throwable $e) use ($sessionId) {
                    $this->logger->error('Failed to send response.', ['sessionId' => $sessionId, 'error' => $e->getMessage()]);
                });

            return;
        }

        if ($messageContext['stateless'] ?? false) {
            $session->set('initialized', true);
            $session->set('protocol_version', self::LATEST_PROTOCOL_VERSION);
            $session->set('client_info', ['name' => 'stateless-client', 'version' => '1.0.0']);
        }

        $context = new Context(
            $session,
            $messageContext['request'] ?? null,
        );

        $response = null;

        if ($message instanceof BatchRequest) {
            $response = $this->processBatchRequest($message, $session, $context);
        } elseif ($message instanceof Request) {
            $response = $this->processRequest($message, $session, $context);
        } elseif ($message instanceof Notification) {
            $this->processNotification($message, $session);
        }

        $session->save();

        if ($response === null) {
            return;
        }

        $this->transport->sendMessage($response, $sessionId, $messageContext)
            ->then(function () use ($sessionId, $response) {
                $this->logger->debug('Response sent.', ['sessionId' => $sessionId, 'payload' => $response]);
            })
            ->catch(function (Throwable $e) use ($sessionId) {
                $this->logger->error('Failed to send response.', ['sessionId' => $sessionId, 'error' => $e->getMessage()]);
            });
    }

    /**
     * Process a batch message
     */
    private function processBatchRequest(BatchRequest $batch, SessionInterface $session, Context $context): ?BatchResponse
    {
        $items = [];

        foreach ($batch->getNotifications() as $notification) {
            $this->processNotification($notification, $session);
        }

        foreach ($batch->getRequests() as $request) {
            $items[] = $this->processRequest($request, $session, $context);
        }

        return empty($items) ? null : new BatchResponse($items);
    }

    /**
     * Process a request message
     */
    private function processRequest(Request $request, SessionInterface $session, Context $context): Response|Error
    {
        try {
            if ($request->method !== 'initialize') {
                $this->assertSessionInitialized($session);
            }

            $this->assertRequestCapability($request->method);

            $result = $this->dispatcher->handleRequest($request, $context);

            return Response::make($request->id, $result);
        } catch (McpServerException $e) {
            $this->logger->debug('MCP Processor caught McpServerException', ['method' => $request->method, 'code' => $e->getCode(), 'message' => $e->getMessage(), 'data' => $e->getData()]);

            return $e->toJsonRpcError($request->id);
        } catch (Throwable $e) {
            $this->logger->error('MCP Processor caught unexpected error', [
                'method' => $request->method,
                'exception' => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
            ]);

            return new Error(
                jsonrpc: '2.0',
                id: $request->id,
                code: Constants::INTERNAL_ERROR,
                message: 'Internal error processing method ' . $request->method,
                data: $e->getMessage()
            );
        }
    }

    /**
     * Process a notification message
     */
    private function processNotification(Notification $notification, SessionInterface $session): void
    {
        $method = $notification->method;
        $params = $notification->params;

        try {
            $this->dispatcher->handleNotification($notification, $session);
        } catch (Throwable $e) {
            $this->logger->error('Error while processing notification', ['method' => $method, 'exception' => $e->getMessage()]);
            return;
        }
    }

    /**
     * Send a notification to a session
     */
    public function sendNotification(Notification $notification, string $sessionId): PromiseInterface
    {
        if ($this->transport === null) {
            $this->logger->error('Cannot send notification, transport not bound', [
                'sessionId' => $sessionId,
                'method' => $notification->method
            ]);
            return reject(new McpServerException('Transport not bound'));
        }

        return $this->transport->sendMessage($notification, $sessionId, [])
            ->then(function () {
                return resolve(null);
            })
            ->catch(function (Throwable $e) {
                return reject(new McpServerException('Failed to send notification: ' . $e->getMessage(), previous: $e));
            });
    }

    /**
     * Notify subscribers about resource content change
     */
    public function notifyResourceUpdated(string $uri): void
    {
        $subscribers = $this->subscriptionManager->getSubscribers($uri);

        if (empty($subscribers)) {
            return;
        }

        $notification = ResourceUpdatedNotification::make($uri);

        foreach ($subscribers as $sessionId) {
            $this->sendNotification($notification, $sessionId);
        }

        $this->logger->debug("Sent resource change notification", [
            'uri' => $uri,
            'subscriber_count' => count($subscribers)
        ]);
    }

    /**
     * Validate that a session is initialized
     */
    private function assertSessionInitialized(SessionInterface $session): void
    {
        if (!$session->get('initialized', false)) {
            throw McpServerException::invalidRequest('Client session not initialized.');
        }
    }

    /**
     * Assert that a request method is enabled
     */
    private function assertRequestCapability(string $method): void
    {
        $capabilities = $this->configuration->capabilities;

        switch ($method) {
            case "ping":
            case "initialize":
                // No specific capability required for these methods
                break;

            case 'tools/list':
            case 'tools/call':
                if (!$capabilities->tools) {
                    throw McpServerException::methodNotFound($method, 'Tools are not enabled on this server.');
                }
                break;

            case 'resources/list':
            case 'resources/templates/list':
            case 'resources/read':
                if (!$capabilities->resources) {
                    throw McpServerException::methodNotFound($method, 'Resources are not enabled on this server.');
                }
                break;

            case 'resources/subscribe':
            case 'resources/unsubscribe':
                if (!$capabilities->resources) {
                    throw McpServerException::methodNotFound($method, 'Resources are not enabled on this server.');
                }
                if (!$capabilities->resourcesSubscribe) {
                    throw McpServerException::methodNotFound($method, 'Resources subscription is not enabled on this server.');
                }
                break;

            case 'prompts/list':
            case 'prompts/get':
                if (!$capabilities->prompts) {
                    throw McpServerException::methodNotFound($method, 'Prompts are not enabled on this server.');
                }
                break;

            case 'logging/setLevel':
                if (!$capabilities->logging) {
                    throw McpServerException::methodNotFound($method, 'Logging is not enabled on this server.');
                }
                break;

            case 'completion/complete':
                if (!$capabilities->completions) {
                    throw McpServerException::methodNotFound($method, 'Completions are not enabled on this server.');
                }
                break;

            default:
                break;
        }
    }

    private function canSendNotification(string $method): bool
    {
        $capabilities = $this->configuration->capabilities;

        $valid = true;

        switch ($method) {
            case 'notifications/message':
                if (!$capabilities->logging) {
                    $this->logger->warning('Logging is not enabled on this server. Notifications/message will not be sent.');
                    $valid = false;
                }
                break;

            case "notifications/resources/updated":
            case "notifications/resources/list_changed":
                if (!$capabilities->resources || !$capabilities->resourcesListChanged) {
                    $this->logger->warning('Resources list changed notifications are not enabled on this server. Notifications/resources/list_changed will not be sent.');
                    $valid = false;
                }
                break;

            case "notifications/tools/list_changed":
                if (!$capabilities->tools || !$capabilities->toolsListChanged) {
                    $this->logger->warning('Tools list changed notifications are not enabled on this server. Notifications/tools/list_changed will not be sent.');
                    $valid = false;
                }
                break;

            case "notifications/prompts/list_changed":
                if (!$capabilities->prompts || !$capabilities->promptsListChanged) {
                    $this->logger->warning('Prompts list changed notifications are not enabled on this server. Notifications/prompts/list_changed will not be sent.');
                    $valid = false;
                }
                break;

            case "notifications/cancelled":
                // Cancellation notifications are always allowed
                break;

            case "notifications/progress":
                // Progress notifications are always allowed
                break;

            default:
                break;
        }

        return $valid;
    }

    /**
     * Handles 'client_connected' event from the transport
     */
    public function handleClientConnected(string $sessionId): void
    {
        $this->logger->info('Client connected', ['sessionId' => $sessionId]);

        $this->sessionManager->createSession($sessionId);
    }

    /**
     * Handles 'client_disconnected' event from the transport
     */
    public function handleClientDisconnected(string $sessionId, ?string $reason = null): void
    {
        $this->logger->info('Client disconnected', ['clientId' => $sessionId, 'reason' => $reason ?? 'N/A']);

        $this->sessionManager->deleteSession($sessionId);
    }

    /**
     * Handle list changed event from registry
     */
    public function handleListChanged(string $listType): void
    {
        $listChangeUri = "mcp://changes/{$listType}";

        $subscribers = $this->subscriptionManager->getSubscribers($listChangeUri);
        if (empty($subscribers)) {
            return;
        }

        $notification = match ($listType) {
            'resources' => ResourceListChangedNotification::make(),
            'tools' => ToolListChangedNotification::make(),
            'prompts' => PromptListChangedNotification::make(),
            'roots' => RootsListChangedNotification::make(),
            default => throw new \InvalidArgumentException("Invalid list type: {$listType}"),
        };

        if (!$this->canSendNotification($notification->method)) {
            return;
        }

        foreach ($subscribers as $sessionId) {
            $this->sendNotification($notification, $sessionId);
        }

        $this->logger->debug("Sent list change notification", [
            'list_type' => $listType,
            'subscriber_count' => count($subscribers)
        ]);
    }

    /**
     * Handles 'error' event from the transport
     */
    public function handleTransportError(Throwable $error, ?string $clientId = null): void
    {
        $context = ['error' => $error->getMessage(), 'exception_class' => get_class($error)];

        if ($clientId) {
            $context['clientId'] = $clientId;
            $this->logger->error('Transport error for client', $context);
        } else {
            $this->logger->error('General transport error', $context);
        }
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Session;

use PhpMcp\Server\Contracts\SessionHandlerInterface;
use PhpMcp\Server\Defaults\SystemClock;
use Psr\Clock\ClockInterface;

class ArraySessionHandler implements SessionHandlerInterface
{
    /**
     * @var array<string, array{ data: array, timestamp: int }>
     */
    protected array $store = [];

    private ClockInterface $clock;

    public function __construct(
        public readonly int $ttl = 3600,
        ?ClockInterface $clock = null
    ) {
        $this->clock = $clock ?? new SystemClock();
    }

    public function read(string $sessionId): string|false
    {
        $session = $this->store[$sessionId] ?? '';
        if ($session === '') {
            return false;
        }

        $currentTimestamp = $this->clock->now()->getTimestamp();

        if ($currentTimestamp - $session['timestamp'] > $this->ttl) {
            unset($this->store[$sessionId]);
            return false;
        }

        return $session['data'];
    }

    public function write(string $sessionId, string $data): bool
    {
        $this->store[$sessionId] = [
            'data' => $data,
            'timestamp' => $this->clock->now()->getTimestamp(),
        ];

        return true;
    }

    public function destroy(string $sessionId): bool
    {
        if (isset($this->store[$sessionId])) {
            unset($this->store[$sessionId]);
        }

        return true;
    }

    public function gc(int $maxLifetime): array
    {
        $currentTimestamp = $this->clock->now()->getTimestamp();
        $deletedSessions = [];

        foreach ($this->store as $sessionId => $session) {
            if ($currentTimestamp - $session['timestamp'] > $maxLifetime) {
                unset($this->store[$sessionId]);
                $deletedSessions[] = $sessionId;
            }
        }

        return $deletedSessions;
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Session;

use PhpMcp\Server\Contracts\SessionHandlerInterface;
use PhpMcp\Server\Defaults\SystemClock;
use Psr\SimpleCache\CacheInterface;
use Psr\Clock\ClockInterface;

class CacheSessionHandler implements SessionHandlerInterface
{
    private const SESSION_INDEX_KEY = 'mcp_session_index';
    private array $sessionIndex = [];
    private ClockInterface $clock;

    public function __construct(
        public readonly CacheInterface $cache,
        public readonly int $ttl = 3600,
        ?ClockInterface $clock = null
    ) {
        $this->sessionIndex = $this->cache->get(self::SESSION_INDEX_KEY, []);
        $this->clock = $clock ?? new SystemClock();
    }

    public function read(string $sessionId): string|false
    {
        $session = $this->cache->get($sessionId, false);
        if ($session === false) {
            if (isset($this->sessionIndex[$sessionId])) {
                unset($this->sessionIndex[$sessionId]);
                $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex);
            }
            return false;
        }

        if (!isset($this->sessionIndex[$sessionId])) {
            $this->sessionIndex[$sessionId] = $this->clock->now()->getTimestamp();
            $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex);
            return $session;
        }

        if ($this->clock->now()->getTimestamp() - $this->sessionIndex[$sessionId] > $this->ttl) {
            $this->cache->delete($sessionId);
            return false;
        }

        return $session;
    }

    public function write(string $sessionId, string $data): bool
    {
        $this->sessionIndex[$sessionId] = $this->clock->now()->getTimestamp();
        $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex);
        return $this->cache->set($sessionId, $data);
    }

    public function destroy(string $sessionId): bool
    {
        unset($this->sessionIndex[$sessionId]);
        $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex);
        return $this->cache->delete($sessionId);
    }

    public function gc(int $maxLifetime): array
    {
        $currentTime = $this->clock->now()->getTimestamp();
        $deletedSessions = [];

        foreach ($this->sessionIndex as $sessionId => $timestamp) {
            if ($currentTime - $timestamp > $maxLifetime) {
                $this->cache->delete($sessionId);
                unset($this->sessionIndex[$sessionId]);
                $deletedSessions[] = $sessionId;
            }
        }

        $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex);

        return $deletedSessions;
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Utils;

use PhpMcp\Schema\Prompt;
use PhpMcp\Schema\PromptArgument;
use PhpMcp\Schema\Resource;
use PhpMcp\Schema\ResourceTemplate;
use PhpMcp\Schema\Tool;
use PhpMcp\Server\Attributes\CompletionProvider;
use PhpMcp\Server\Attributes\McpPrompt;
use PhpMcp\Server\Attributes\McpResource;
use PhpMcp\Server\Attributes\McpResourceTemplate;
use PhpMcp\Server\Attributes\McpTool;
use PhpMcp\Server\Defaults\EnumCompletionProvider;
use PhpMcp\Server\Defaults\ListCompletionProvider;
use PhpMcp\Server\Exception\McpServerException;
use PhpMcp\Server\Registry;
use Psr\Log\LoggerInterface;
use ReflectionAttribute;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Throwable;

class Discoverer
{
    private DocBlockParser $docBlockParser;

    private SchemaGenerator $schemaGenerator;

    public function __construct(
        private Registry $registry,
        private LoggerInterface $logger,
        ?DocBlockParser $docBlockParser = null,
        ?SchemaGenerator $schemaGenerator = null,
    ) {
        $this->docBlockParser = $docBlockParser ?? new DocBlockParser($this->logger);
        $this->schemaGenerator = $schemaGenerator ?? new SchemaGenerator($this->docBlockParser);
    }

    /**
     * Discover MCP elements in the specified directories.
     *
     * @param  string  $basePath  The base path for resolving directories.
     * @param  array<string>  $directories  List of directories (relative to base path) to scan.
     * @param  array<string>  $excludeDirs  List of directories (relative to base path) to exclude from the scan.
     */
    public function discover(string $basePath, array $directories, array $excludeDirs = []): void
    {
        $startTime = microtime(true);
        $discoveredCount = [
            'tools' => 0,
            'resources' => 0,
            'prompts' => 0,
            'resourceTemplates' => 0,
        ];

        try {
            $finder = new Finder();
            $absolutePaths = [];
            foreach ($directories as $dir) {
                $path = rtrim($basePath, '/') . '/' . ltrim($dir, '/');
                if (is_dir($path)) {
                    $absolutePaths[] = $path;
                }
            }

            if (empty($absolutePaths)) {
                $this->logger->warning('No valid discovery directories found to scan.', ['configured_paths' => $directories, 'base_path' => $basePath]);

                return;
            }

            $finder->files()
                ->in($absolutePaths)
                ->exclude($excludeDirs)
                ->name('*.php');

            foreach ($finder as $file) {
                $this->processFile($file, $discoveredCount);
            }
        } catch (Throwable $e) {
            $this->logger->error('Error during file finding process for MCP discovery', [
                'exception' => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
            ]);
        }

        $duration = microtime(true) - $startTime;
        $this->logger->info('Attribute discovery finished.', [
            'duration_sec' => round($duration, 3),
            'tools' => $discoveredCount['tools'],
            'resources' => $discoveredCount['resources'],
            'prompts' => $discoveredCount['prompts'],
            'resourceTemplates' => $discoveredCount['resourceTemplates'],
        ]);
    }

    /**
     * Process a single PHP file for MCP elements on classes or methods.
     */
    private function processFile(SplFileInfo $file, array &$discoveredCount): void
    {
        $filePath = $file->getRealPath();
        if ($filePath === false) {
            $this->logger->warning('Could not get real path for file', ['path' => $file->getPathname()]);

            return;
        }

        $className = $this->getClassFromFile($filePath);
        if (! $className) {
            $this->logger->warning('No valid class found in file', ['file' => $filePath]);

            return;
        }

        try {
            $reflectionClass = new ReflectionClass($className);

            if ($reflectionClass->isAbstract() || $reflectionClass->isInterface() || $reflectionClass->isTrait() || $reflectionClass->isEnum()) {
                return;
            }

            $processedViaClassAttribute = false;
            if ($reflectionClass->hasMethod('__invoke')) {
                $invokeMethod = $reflectionClass->getMethod('__invoke');
                if ($invokeMethod->isPublic() && ! $invokeMethod->isStatic()) {
                    $attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class];
                    foreach ($attributeTypes as $attributeType) {
                        $classAttribute = $reflectionClass->getAttributes($attributeType, ReflectionAttribute::IS_INSTANCEOF)[0] ?? null;
                        if ($classAttribute) {
                            $this->processMethod($invokeMethod, $discoveredCount, $classAttribute);
                            $processedViaClassAttribute = true;
                            break;
                        }
                    }
                }
            }

            if (! $processedViaClassAttribute) {
                foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
                    if (
                        $method->getDeclaringClass()->getName() !== $reflectionClass->getName() ||
                        $method->isStatic() || $method->isAbstract() || $method->isConstructor() || $method->isDestructor() || $method->getName() === '__invoke'
                    ) {
                        continue;
                    }
                    $attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class];
                    foreach ($attributeTypes as $attributeType) {
                        $methodAttribute = $method->getAttributes($attributeType, ReflectionAttribute::IS_INSTANCEOF)[0] ?? null;
                        if ($methodAttribute) {
                            $this->processMethod($method, $discoveredCount, $methodAttribute);
                            break;
                        }
                    }
                }
            }
        } catch (ReflectionException $e) {
            $this->logger->error('Reflection error processing file for MCP discovery', ['file' => $filePath, 'class' => $className, 'exception' => $e->getMessage()]);
        } catch (Throwable $e) {
            $this->logger->error('Unexpected error processing file for MCP discovery', [
                'file' => $filePath,
                'class' => $className,
                'exception' => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
            ]);
        }
    }

    /**
     * Process a method with a given MCP attribute instance.
     * Can be called for regular methods or the __invoke method of an invokable class.
     *
     * @param  ReflectionMethod  $method  The target method (e.g., regular method or __invoke).
     * @param  array  $discoveredCount  Pass by reference to update counts.
     * @param  ReflectionAttribute<McpTool|McpResource|McpPrompt|McpResourceTemplate>  $attribute  The ReflectionAttribute instance found (on method or class).
     */
    private function processMethod(ReflectionMethod $method, array &$discoveredCount, ReflectionAttribute $attribute): void
    {
        $className = $method->getDeclaringClass()->getName();
        $classShortName = $method->getDeclaringClass()->getShortName();
        $methodName = $method->getName();
        $attributeClassName = $attribute->getName();

        try {
            $instance = $attribute->newInstance();

            switch ($attributeClassName) {
                case McpTool::class:
                    $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
                    $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName);
                    $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
                    $inputSchema = $this->schemaGenerator->generate($method);
                    $tool = Tool::make($name, $inputSchema, $description, $instance->annotations);
                    $this->registry->registerTool($tool, [$className, $methodName]);
                    $discoveredCount['tools']++;
                    break;

                case McpResource::class:
                    $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
                    $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName);
                    $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
                    $mimeType = $instance->mimeType;
                    $size = $instance->size;
                    $annotations = $instance->annotations;
                    $resource = Resource::make($instance->uri, $name, $description, $mimeType, $annotations, $size);
                    $this->registry->registerResource($resource, [$className, $methodName]);
                    $discoveredCount['resources']++;
                    break;

                case McpPrompt::class:
                    $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
                    $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName);
                    $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
                    $arguments = [];
                    $paramTags = $this->docBlockParser->getParamTags($docBlock);
                    foreach ($method->getParameters() as $param) {
                        $reflectionType = $param->getType();
                        if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) {
                            continue;
                        }
                        $paramTag = $paramTags['$' . $param->getName()] ?? null;
                        $arguments[] = PromptArgument::make($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, ! $param->isOptional() && ! $param->isDefaultValueAvailable());
                    }
                    $prompt = Prompt::make($name, $description, $arguments);
                    $completionProviders = $this->getCompletionProviders($method);
                    $this->registry->registerPrompt($prompt, [$className, $methodName], $completionProviders);
                    $discoveredCount['prompts']++;
                    break;

                case McpResourceTemplate::class:
                    $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
                    $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName);
                    $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
                    $mimeType = $instance->mimeType;
                    $annotations = $instance->annotations;
                    $resourceTemplate = ResourceTemplate::make($instance->uriTemplate, $name, $description, $mimeType, $annotations);
                    $completionProviders = $this->getCompletionProviders($method);
                    $this->registry->registerResourceTemplate($resourceTemplate, [$className, $methodName], $completionProviders);
                    $discoveredCount['resourceTemplates']++;
                    break;
            }
        } catch (McpServerException $e) {
            $this->logger->error("Failed to process MCP attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e->getMessage(), 'trace' => $e->getPrevious() ? $e->getPrevious()->getTraceAsString() : $e->getTraceAsString()]);
        } catch (Throwable $e) {
            $this->logger->error("Unexpected error processing attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
        }
    }

    private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array
    {
        $completionProviders = [];
        foreach ($reflectionMethod->getParameters() as $param) {
            $reflectionType = $param->getType();
            if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) {
                continue;
            }

            $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF);
            if (!empty($completionAttributes)) {
                $attributeInstance = $completionAttributes[0]->newInstance();

                if ($attributeInstance->provider) {
                    $completionProviders[$param->getName()] = $attributeInstance->provider;
                } elseif ($attributeInstance->providerClass) {
                    $completionProviders[$param->getName()] = $attributeInstance->provider;
                } elseif ($attributeInstance->values) {
                    $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values);
                } elseif ($attributeInstance->enum) {
                    $completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum);
                }
            }
        }

        return $completionProviders;
    }

    /**
     * Attempt to determine the FQCN from a PHP file path.
     * Uses tokenization to extract namespace and class name.
     *
     * @param  string  $filePath  Absolute path to the PHP file.
     * @return class-string|null The FQCN or null if not found/determinable.
     */
    private function getClassFromFile(string $filePath): ?string
    {
        if (! file_exists($filePath) || ! is_readable($filePath)) {
            $this->logger->warning('File does not exist or is not readable.', ['file' => $filePath]);

            return null;
        }

        try {
            $content = file_get_contents($filePath);
            if ($content === false) {
                $this->logger->warning('Failed to read file content.', ['file' => $filePath]);

                return null;
            }
            if (strlen($content) > 500 * 1024) {
                $this->logger->debug('Skipping large file during class discovery.', ['file' => $filePath]);

                return null;
            }

            $tokens = token_get_all($content);
        } catch (Throwable $e) {
            $this->logger->warning("Failed to read or tokenize file during class discovery: {$filePath}", ['exception' => $e->getMessage()]);

            return null;
        }

        $namespace = '';
        $namespaceFound = false;
        $level = 0;
        $potentialClasses = [];

        $tokenCount = count($tokens);
        for ($i = 0; $i < $tokenCount; $i++) {
            if (is_array($tokens[$i]) && $tokens[$i][0] === T_NAMESPACE) {
                $namespace = '';
                for ($j = $i + 1; $j < $tokenCount; $j++) {
                    if ($tokens[$j] === ';' || $tokens[$j] === '{') {
                        $namespaceFound = true;
                        $i = $j;
                        break;
                    }
                    if (is_array($tokens[$j]) && in_array($tokens[$j][0], [T_STRING, T_NAME_QUALIFIED])) {
                        $namespace .= $tokens[$j][1];
                    } elseif ($tokens[$j][0] === T_NS_SEPARATOR) {
                        $namespace .= '\\';
                    }
                }
                if ($namespaceFound) {
                    break;
                }
            }
        }
        $namespace = trim($namespace, '\\');

        for ($i = 0; $i < $tokenCount; $i++) {
            $token = $tokens[$i];
            if ($token === '{') {
                $level++;

                continue;
            }
            if ($token === '}') {
                $level--;

                continue;
            }

            if ($level === ($namespaceFound && str_contains($content, "namespace {$namespace} {") ? 1 : 0)) {
                if (is_array($token) && in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT, defined('T_ENUM') ? T_ENUM : -1])) {
                    for ($j = $i + 1; $j < $tokenCount; $j++) {
                        if (is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) {
                            $className = $tokens[$j][1];
                            $potentialClasses[] = $namespace ? $namespace . '\\' . $className : $className;
                            $i = $j;
                            break;
                        }
                        if ($tokens[$j] === ';' || $tokens[$j] === '{' || $tokens[$j] === ')') {
                            break;
                        }
                    }
                }
            }
        }

        foreach ($potentialClasses as $potentialClass) {
            if (class_exists($potentialClass, true)) {
                return $potentialClass;
            }
        }

        if (! empty($potentialClasses)) {
            if (! class_exists($potentialClasses[0], false)) {
                $this->logger->debug('getClassFromFile returning potential non-class type. Are you sure this class has been autoloaded?', ['file' => $filePath, 'type' => $potentialClasses[0]]);
            }

            return $potentialClasses[0];
        }

        return null;
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Contracts;

use Evenement\EventEmitterInterface;
use PhpMcp\Server\Exception\TransportException;
use PhpMcp\Schema\JsonRpc\Message;
use React\Promise\PromiseInterface;

/**
 * Interface for server-side MCP transports.
 *
 * Implementations handle listening for connections/data and sending raw messages.
 * MUST emit events for lifecycle and messages.
 *
 * --- Expected Emitted Events ---
 * 'ready': () - Optional: Fired when listening starts successfully.
 * 'client_connected': (string $sessionId) - New client connection
 * 'message': (Message $message, string $sessionId, array $context) - Complete message received from a client.
 * 'client_disconnected': (string $sessionId, ?string $reason) - Client connection closed.
 * 'error': (Throwable $error, ?string $sessionId) - Error occurred (general transport error if sessionId is null).
 * 'close': (?string $reason) - Transport listener stopped completely.
 */
interface ServerTransportInterface extends EventEmitterInterface
{
    /**
     * Starts the transport listener (e.g., listens on STDIN, starts HTTP server).
     * Does NOT run the event loop itself. Prepares transport to emit events when loop runs.
     *
     * @throws TransportException on immediate setup failure (e.g., port binding).
     */
    public function listen(): void;

    /**
     * Sends a message to a connected client session with optional context.
     *
     * @param  Message  $message  Message to send.
     * @param  string  $sessionId  Target session identifier.
     * @param  array  $context  Optional context for the message. Eg. streamId for SSE.
     * @return PromiseInterface<void> Resolves on successful send/queue, rejects on specific send error.
     */
    public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface;

    /**
     * Stops the transport listener gracefully and closes all active connections.
     * MUST eventually emit a 'close' event for the transport itself.
     * Individual client disconnects should emit 'client_disconnected' events.
     */
    public function close(): void;
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Contracts;

/**
 * Interface for resumability support via event storage
 */
interface EventStoreInterface
{
    /**
     * Stores a message associated with a specific stream and returns a unique event ID.
     *
     * @param string $streamId The ID of the stream the event belongs to.
     * @param string $message The framed JSON-RPC message to store.
     * @return string The generated event ID for the stored event
     */
    public function storeEvent(string $streamId, string $message): string;

    /**
     * Replays events for a given stream that occurred after a specific event ID.
     *
     * @param string $lastEventId The last event ID the client received for this specific stream.
     * @param callable $sendCallback A function to call for each replayed message.
     *                           The callback will receive: `function(string $eventId, Message $message): void`
     */
    public function replayEventsAfter(string $lastEventId, callable $sendCallback): void;
}

<?php

namespace PhpMcp\Server\Session;

use Psr\Log\LoggerInterface;

class SubscriptionManager
{
    /** @var array<string, array<string, true>>  Key: URI, Value: array of session IDs */
    private array $resourceSubscribers = [];

    /** @var array<string, array<string, true>>  Key: Session ID, Value: array of URIs */
    private array $sessionSubscriptions = [];

    public function __construct(
        private readonly LoggerInterface $logger
    ) {
    }

    /**
     * Subscribe a session to a resource
     */
    public function subscribe(string $sessionId, string $uri): void
    {
        // Add to both mappings for efficient lookup
        $this->resourceSubscribers[$uri][$sessionId] = true;
        $this->sessionSubscriptions[$sessionId][$uri] = true;

        $this->logger->debug('Session subscribed to resource', [
            'sessionId' => $sessionId,
            'uri' => $uri
        ]);
    }

    /**
     * Unsubscribe a session from a resource
     */
    public function unsubscribe(string $sessionId, string $uri): void
    {
        unset($this->resourceSubscribers[$uri][$sessionId]);
        unset($this->sessionSubscriptions[$sessionId][$uri]);

        // Clean up empty arrays
        if (empty($this->resourceSubscribers[$uri])) {
            unset($this->resourceSubscribers[$uri]);
        }

        $this->logger->debug('Session unsubscribed from resource', [
            'sessionId' => $sessionId,
            'uri' => $uri
        ]);
    }

    /**
     * Get all sessions subscribed to a resource
     */
    public function getSubscribers(string $uri): array
    {
        return array_keys($this->resourceSubscribers[$uri] ?? []);
    }

    /**
     * Check if a session is subscribed to a resource
     */
    public function isSubscribed(string $sessionId, string $uri): bool
    {
        return isset($this->sessionSubscriptions[$sessionId][$uri]);
    }

    /**
     * Clean up all subscriptions for a session
     */
    public function cleanupSession(string $sessionId): void
    {
        if (!isset($this->sessionSubscriptions[$sessionId])) {
            return;
        }

        $uris = array_keys($this->sessionSubscriptions[$sessionId]);
        foreach ($uris as $uri) {
            unset($this->resourceSubscribers[$uri][$sessionId]);

            // Clean up empty arrays
            if (empty($this->resourceSubscribers[$uri])) {
                unset($this->resourceSubscribers[$uri]);
            }
        }

        unset($this->sessionSubscriptions[$sessionId]);

        $this->logger->debug('Cleaned up all subscriptions for session', [
            'sessionId' => $sessionId,
            'count' => count($uris)
        ]);
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Defaults;

use PhpMcp\Server\Contracts\CompletionProviderInterface;
use PhpMcp\Server\Contracts\SessionInterface;

class ListCompletionProvider implements CompletionProviderInterface
{
    public function __construct(private array $values) {}

    public function getCompletions(string $currentValue, SessionInterface $session): array
    {
        if (empty($currentValue)) {
            return $this->values;
        }

        return array_values(array_filter(
            $this->values,
            fn(string $value) => str_starts_with($value, $currentValue)
        ));
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Defaults;

use PhpMcp\Server\Contracts\CompletionProviderInterface;
use PhpMcp\Server\Contracts\SessionInterface;

class EnumCompletionProvider implements CompletionProviderInterface
{
    private array $values;

    public function __construct(string $enumClass)
    {
        if (!enum_exists($enumClass)) {
            throw new \InvalidArgumentException("Class {$enumClass} is not an enum");
        }

        $this->values = array_map(
            fn($case) => isset($case->value) && is_string($case->value) ? $case->value : $case->name,
            $enumClass::cases()
        );
    }

    public function getCompletions(string $currentValue, SessionInterface $session): array
    {
        if (empty($currentValue)) {
            return $this->values;
        }

        return array_values(array_filter(
            $this->values,
            fn(string $value) => str_starts_with($value, $currentValue)
        ));
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server;

use JsonException;
use PhpMcp\Schema\JsonRpc\Request;
use PhpMcp\Schema\JsonRpc\Notification;
use PhpMcp\Schema\JsonRpc\Result;
use PhpMcp\Schema\Notification\InitializedNotification;
use PhpMcp\Schema\Request\CallToolRequest;
use PhpMcp\Schema\Request\CompletionCompleteRequest;
use PhpMcp\Schema\Request\GetPromptRequest;
use PhpMcp\Schema\Request\InitializeRequest;
use PhpMcp\Schema\Request\ListPromptsRequest;
use PhpMcp\Schema\Request\ListResourcesRequest;
use PhpMcp\Schema\Request\ListResourceTemplatesRequest;
use PhpMcp\Schema\Request\ListToolsRequest;
use PhpMcp\Schema\Request\PingRequest;
use PhpMcp\Schema\Request\ReadResourceRequest;
use PhpMcp\Schema\Request\ResourceSubscribeRequest;
use PhpMcp\Schema\Request\ResourceUnsubscribeRequest;
use PhpMcp\Schema\Request\SetLogLevelRequest;
use PhpMcp\Server\Configuration;
use PhpMcp\Server\Contracts\SessionInterface;
use PhpMcp\Server\Exception\McpServerException;
use PhpMcp\Schema\Content\TextContent;
use PhpMcp\Schema\Result\CallToolResult;
use PhpMcp\Schema\Result\CompletionCompleteResult;
use PhpMcp\Schema\Result\EmptyResult;
use PhpMcp\Schema\Result\GetPromptResult;
use PhpMcp\Schema\Result\InitializeResult;
use PhpMcp\Schema\Result\ListPromptsResult;
use PhpMcp\Schema\Result\ListResourcesResult;
use PhpMcp\Schema\Result\ListResourceTemplatesResult;
use PhpMcp\Schema\Result\ListToolsResult;
use PhpMcp\Schema\Result\ReadResourceResult;
use PhpMcp\Server\Protocol;
use PhpMcp\Server\Registry;
use PhpMcp\Server\Session\SubscriptionManager;
use PhpMcp\Server\Utils\SchemaValidator;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Throwable;

class Dispatcher
{
    protected ContainerInterface $container;
    protected LoggerInterface $logger;

    public function __construct(
        protected Configuration $configuration,
        protected Registry $registry,
        protected SubscriptionManager $subscriptionManager,
        protected ?SchemaValidator $schemaValidator = null,
    ) {
        $this->container = $this->configuration->container;
        $this->logger = $this->configuration->logger;

        $this->schemaValidator ??= new SchemaValidator($this->logger);
    }

    public function handleRequest(Request $request, Context $context): Result
    {
        switch ($request->method) {
            case 'initialize':
                $request = InitializeRequest::fromRequest($request);
                return $this->handleInitialize($request, $context->session);
            case 'ping':
                $request = PingRequest::fromRequest($request);
                return $this->handlePing($request);
            case 'tools/list':
                $request = ListToolsRequest::fromRequest($request);
                return $this->handleToolList($request);
            case 'tools/call':
                $request = CallToolRequest::fromRequest($request);
                return $this->handleToolCall($request, $context);
            case 'resources/list':
                $request = ListResourcesRequest::fromRequest($request);
                return $this->handleResourcesList($request);
            case 'resources/templates/list':
                $request = ListResourceTemplatesRequest::fromRequest($request);
                return $this->handleResourceTemplateList($request);
            case 'resources/read':
                $request = ReadResourceRequest::fromRequest($request);
                return $this->handleResourceRead($request, $context);
            case 'resources/subscribe':
                $request = ResourceSubscribeRequest::fromRequest($request);
                return $this->handleResourceSubscribe($request, $context->session);
            case 'resources/unsubscribe':
                $request = ResourceUnsubscribeRequest::fromRequest($request);
                return $this->handleResourceUnsubscribe($request, $context->session);
            case 'prompts/list':
                $request = ListPromptsRequest::fromRequest($request);
                return $this->handlePromptsList($request);
            case 'prompts/get':
                $request = GetPromptRequest::fromRequest($request);
                return $this->handlePromptGet($request, $context);
            case 'logging/setLevel':
                $request = SetLogLevelRequest::fromRequest($request);
                return $this->handleLoggingSetLevel($request, $context->session);
            case 'completion/complete':
                $request = CompletionCompleteRequest::fromRequest($request);
                return $this->handleCompletionComplete($request, $context->session);
            default:
                throw McpServerException::methodNotFound("Method '{$request->method}' not found.");
        }
    }

    public function handleNotification(Notification $notification, SessionInterface $session): void
    {
        switch ($notification->method) {
            case 'notifications/initialized':
                $notification = InitializedNotification::fromNotification($notification);
                $this->handleNotificationInitialized($notification, $session);
        }
    }

    public function handleInitialize(InitializeRequest $request, SessionInterface $session): InitializeResult
    {
        if (in_array($request->protocolVersion, Protocol::SUPPORTED_PROTOCOL_VERSIONS)) {
            $protocolVersion = $request->protocolVersion;
        } else {
            $protocolVersion = Protocol::LATEST_PROTOCOL_VERSION;
        }

        $session->set('client_info', $request->clientInfo->toArray());
        $session->set('protocol_version', $protocolVersion);

        $serverInfo = $this->configuration->serverInfo;
        $capabilities = $this->configuration->capabilities;
        $instructions = $this->configuration->instructions;

        return new InitializeResult($protocolVersion, $capabilities, $serverInfo, $instructions);
    }

    public function handlePing(PingRequest $request): EmptyResult
    {
        return new EmptyResult();
    }

    public function handleToolList(ListToolsRequest $request): ListToolsResult
    {
        $limit = $this->configuration->paginationLimit;
        $offset = $this->decodeCursor($request->cursor);
        $allItems = $this->registry->getTools();
        $pagedItems = array_slice($allItems, $offset, $limit);
        $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit);

        return new ListToolsResult(array_values($pagedItems), $nextCursor);
    }

    public function handleToolCall(CallToolRequest $request, Context $context): CallToolResult
    {
        $toolName = $request->name;
        $arguments = $request->arguments;

        $registeredTool = $this->registry->getTool($toolName);
        if (! $registeredTool) {
            throw McpServerException::methodNotFound("Tool '{$toolName}' not found.");
        }

        $inputSchema = $registeredTool->schema->inputSchema;

        $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema);

        if (! empty($validationErrors)) {
            $errorMessages = [];

            foreach ($validationErrors as $errorDetail) {
                $pointer = $errorDetail['pointer'] ?? '';
                $message = $errorDetail['message'] ?? 'Unknown validation error';
                $errorMessages[] = ($pointer !== '/' && $pointer !== '' ? "Property '{$pointer}': " : '') . $message;
            }

            $summaryMessage = "Invalid parameters for tool '{$toolName}': " . implode('; ', array_slice($errorMessages, 0, 3));

            if (count($errorMessages) > 3) {
                $summaryMessage .= '; ...and more errors.';
            }

            throw McpServerException::invalidParams($summaryMessage, data: ['validation_errors' => $validationErrors]);
        }

        try {
            $result = $registeredTool->call($this->container, $arguments, $context);

            return new CallToolResult($result, false);
        } catch (JsonException $e) {
            $this->logger->warning('Failed to JSON encode tool result.', ['tool' => $toolName, 'exception' => $e]);
            $errorMessage = "Failed to serialize tool result: {$e->getMessage()}";

            return new CallToolResult([new TextContent($errorMessage)], true);
        } catch (Throwable $toolError) {
            $this->logger->error('Tool execution failed.', ['tool' => $toolName, 'exception' => $toolError]);
            $errorMessage = "Tool execution failed: {$toolError->getMessage()}";

            return new CallToolResult([new TextContent($errorMessage)], true);
        }
    }

    public function handleResourcesList(ListResourcesRequest $request): ListResourcesResult
    {
        $limit = $this->configuration->paginationLimit;
        $offset = $this->decodeCursor($request->cursor);
        $allItems = $this->registry->getResources();
        $pagedItems = array_slice($allItems, $offset, $limit);
        $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit);

        return new ListResourcesResult(array_values($pagedItems), $nextCursor);
    }

    public function handleResourceTemplateList(ListResourceTemplatesRequest $request): ListResourceTemplatesResult
    {
        $limit = $this->configuration->paginationLimit;
        $offset = $this->decodeCursor($request->cursor);
        $allItems = $this->registry->getResourceTemplates();
        $pagedItems = array_slice($allItems, $offset, $limit);
        $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit);

        return new ListResourceTemplatesResult(array_values($pagedItems), $nextCursor);
    }

    public function handleResourceRead(ReadResourceRequest $request, Context $context): ReadResourceResult
    {
        $uri = $request->uri;

        $registeredResource = $this->registry->getResource($uri);

        if (! $registeredResource) {
            throw McpServerException::invalidParams("Resource URI '{$uri}' not found.");
        }

        try {
            $result = $registeredResource->read($this->container, $uri, $context);

            return new ReadResourceResult($result);
        } catch (JsonException $e) {
            $this->logger->warning('Failed to JSON encode resource content.', ['exception' => $e, 'uri' => $uri]);
            throw McpServerException::internalError("Failed to serialize resource content for '{$uri}'.", $e);
        } catch (McpServerException $e) {
            throw $e;
        } catch (Throwable $e) {
            $this->logger->error('Resource read failed.', ['uri' => $uri, 'exception' => $e]);
            throw McpServerException::resourceReadFailed($uri, $e);
        }
    }

    public function handleResourceSubscribe(ResourceSubscribeRequest $request, SessionInterface $session): EmptyResult
    {
        $this->subscriptionManager->subscribe($session->getId(), $request->uri);
        return new EmptyResult();
    }

    public function handleResourceUnsubscribe(ResourceUnsubscribeRequest $request, SessionInterface $session): EmptyResult
    {
        $this->subscriptionManager->unsubscribe($session->getId(), $request->uri);
        return new EmptyResult();
    }

    public function handlePromptsList(ListPromptsRequest $request): ListPromptsResult
    {
        $limit = $this->configuration->paginationLimit;
        $offset = $this->decodeCursor($request->cursor);
        $allItems = $this->registry->getPrompts();
        $pagedItems = array_slice($allItems, $offset, $limit);
        $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit);

        return new ListPromptsResult(array_values($pagedItems), $nextCursor);
    }

    public function handlePromptGet(GetPromptRequest $request, Context $context): GetPromptResult
    {
        $promptName = $request->name;
        $arguments = $request->arguments;

        $registeredPrompt = $this->registry->getPrompt($promptName);
        if (! $registeredPrompt) {
            throw McpServerException::invalidParams("Prompt '{$promptName}' not found.");
        }

        $arguments = (array) $arguments;

        foreach ($registeredPrompt->schema->arguments as $argDef) {
            if ($argDef->required && ! array_key_exists($argDef->name, $arguments)) {
                throw McpServerException::invalidParams("Missing required argument '{$argDef->name}' for prompt '{$promptName}'.");
            }
        }

        try {
            $result = $registeredPrompt->get($this->container, $arguments, $context);

            return new GetPromptResult($result, $registeredPrompt->schema->description);
        } catch (JsonException $e) {
            $this->logger->warning('Failed to JSON encode prompt messages.', ['exception' => $e, 'promptName' => $promptName]);
            throw McpServerException::internalError("Failed to serialize prompt messages for '{$promptName}'.", $e);
        } catch (McpServerException $e) {
            throw $e;
        } catch (Throwable $e) {
            $this->logger->error('Prompt generation failed.', ['promptName' => $promptName, 'exception' => $e]);
            throw McpServerException::promptGenerationFailed($promptName, $e);
        }
    }

    public function handleLoggingSetLevel(SetLogLevelRequest $request, SessionInterface $session): EmptyResult
    {
        $level = $request->level;

        $session->set('log_level', $level->value);

        $this->logger->info("Log level set to '{$level->value}'.", ['sessionId' => $session->getId()]);

        return new EmptyResult();
    }

    public function handleCompletionComplete(CompletionCompleteRequest $request, SessionInterface $session): CompletionCompleteResult
    {
        $ref = $request->ref;
        $argumentName = $request->argument['name'];
        $currentValue = $request->argument['value'];

        $identifier = null;

        if ($ref->type === 'ref/prompt') {
            $identifier = $ref->name;
            $registeredPrompt = $this->registry->getPrompt($identifier);
            if (! $registeredPrompt) {
                throw McpServerException::invalidParams("Prompt '{$identifier}' not found.");
            }

            $foundArg = false;
            foreach ($registeredPrompt->schema->arguments as $arg) {
                if ($arg->name === $argumentName) {
                    $foundArg = true;
                    break;
                }
            }
            if (! $foundArg) {
                throw McpServerException::invalidParams("Argument '{$argumentName}' not found in prompt '{$identifier}'.");
            }

            return $registeredPrompt->complete($this->container, $argumentName, $currentValue, $session);
        } elseif ($ref->type === 'ref/resource') {
            $identifier = $ref->uri;
            $registeredResourceTemplate = $this->registry->getResourceTemplate($identifier);
            if (! $registeredResourceTemplate) {
                throw McpServerException::invalidParams("Resource template '{$identifier}' not found.");
            }

            $foundArg = false;
            foreach ($registeredResourceTemplate->getVariableNames() as $uriVariableName) {
                if ($uriVariableName === $argumentName) {
                    $foundArg = true;
                    break;
                }
            }

            if (! $foundArg) {
                throw McpServerException::invalidParams("URI variable '{$argumentName}' not found in resource template '{$identifier}'.");
            }

            return $registeredResourceTemplate->complete($this->container, $argumentName, $currentValue, $session);
        } else {
            throw McpServerException::invalidParams("Invalid ref type '{$ref->type}' for completion complete request.");
        }
    }

    public function handleNotificationInitialized(InitializedNotification $notification, SessionInterface $session): EmptyResult
    {
        $session->set('initialized', true);

        return new EmptyResult();
    }

    private function decodeCursor(?string $cursor): int
    {
        if ($cursor === null) {
            return 0;
        }

        $decoded = base64_decode($cursor, true);
        if ($decoded === false) {
            $this->logger->warning('Received invalid pagination cursor (not base64)', ['cursor' => $cursor]);

            return 0;
        }

        if (preg_match('/^offset=(\d+)$/', $decoded, $matches)) {
            return (int) $matches[1];
        }

        $this->logger->warning('Received invalid pagination cursor format', ['cursor' => $decoded]);

        return 0;
    }

    private function encodeNextCursor(int $currentOffset, int $returnedCount, int $totalCount, int $limit): ?string
    {
        $nextOffset = $currentOffset + $returnedCount;
        if ($returnedCount > 0 && $nextOffset < $totalCount) {
            return base64_encode("offset={$nextOffset}");
        }

        return null;
    }
}

<?php

namespace PhpMcp\Server\Defaults;

use DateInterval;
use DateTimeImmutable;
use InvalidArgumentException;
use Psr\SimpleCache\CacheInterface;
use Throwable;

/**
 * Basic PSR-16 file cache implementation.
 *
 * Stores cache entries serialized in a JSON file.
 * Uses file locking for basic concurrency control during writes.
 * Not recommended for high-concurrency environments.
 */
class FileCache implements CacheInterface
{
    /**
     * @param  string  $cacheFile  Absolute path to the cache file.
     *                             The directory will be created if it doesn't exist.
     * @param  int  $filePermission  Optional file mode (octal) for the cache file (default: 0664).
     * @param  int  $dirPermission  Optional directory mode (octal) for the cache directory (default: 0775).
     */
    public function __construct(
        private readonly string $cacheFile,
        private readonly int $filePermission = 0664,
        private readonly int $dirPermission = 0775
    ) {
        $this->ensureDirectoryExists(dirname($this->cacheFile));
    }

    // ---------------------------------------------------------------------
    // PSR-16 Methods
    // ---------------------------------------------------------------------

    public function get(string $key, mixed $default = null): mixed
    {
        $data = $this->readCacheFile();
        $key = $this->sanitizeKey($key);

        if (! isset($data[$key])) {
            return $default;
        }

        if ($this->isExpired($data[$key]['expiry'])) {
            $this->delete($key); // Clean up expired entry

            return $default;
        }

        return $data[$key]['value'] ?? $default;
    }

    public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool
    {
        $data = $this->readCacheFile();
        $key = $this->sanitizeKey($key);

        $data[$key] = [
            'value' => $value,
            'expiry' => $this->calculateExpiry($ttl),
        ];

        return $this->writeCacheFile($data);
    }

    public function delete(string $key): bool
    {
        $data = $this->readCacheFile();
        $key = $this->sanitizeKey($key);

        if (isset($data[$key])) {
            unset($data[$key]);

            return $this->writeCacheFile($data);
        }

        return true; // Key didn't exist, considered successful delete
    }

    public function clear(): bool
    {
        // Write an empty array to the file
        return $this->writeCacheFile([]);
    }

    public function getMultiple(iterable $keys, mixed $default = null): iterable
    {
        $keys = $this->iterableToArray($keys);
        $this->validateKeys($keys);

        $data = $this->readCacheFile();
        $results = [];
        $needsWrite = false;

        foreach ($keys as $key) {
            $sanitizedKey = $this->sanitizeKey($key);
            if (! isset($data[$sanitizedKey])) {
                $results[$key] = $default;

                continue;
            }

            if ($this->isExpired($data[$sanitizedKey]['expiry'])) {
                unset($data[$sanitizedKey]); // Clean up expired entry
                $needsWrite = true;
                $results[$key] = $default;

                continue;
            }

            $results[$key] = $data[$sanitizedKey]['value'] ?? $default;
        }

        if ($needsWrite) {
            $this->writeCacheFile($data);
        }

        return $results;
    }

    public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool
    {
        $values = $this->iterableToArray($values);
        $this->validateKeys(array_keys($values));

        $data = $this->readCacheFile();
        $expiry = $this->calculateExpiry($ttl);

        foreach ($values as $key => $value) {
            $sanitizedKey = $this->sanitizeKey((string) $key);
            $data[$sanitizedKey] = [
                'value' => $value,
                'expiry' => $expiry,
            ];
        }

        return $this->writeCacheFile($data);
    }

    public function deleteMultiple(iterable $keys): bool
    {
        $keys = $this->iterableToArray($keys);
        $this->validateKeys($keys);

        $data = $this->readCacheFile();
        $deleted = false;

        foreach ($keys as $key) {
            $sanitizedKey = $this->sanitizeKey($key);
            if (isset($data[$sanitizedKey])) {
                unset($data[$sanitizedKey]);
                $deleted = true;
            }
        }

        if ($deleted) {
            return $this->writeCacheFile($data);
        }

        return true; // No keys existed or no changes made
    }

    public function has(string $key): bool
    {
        $data = $this->readCacheFile();
        $key = $this->sanitizeKey($key);

        if (! isset($data[$key])) {
            return false;
        }

        if ($this->isExpired($data[$key]['expiry'])) {
            $this->delete($key); // Clean up expired

            return false;
        }

        return true;
    }

    // ---------------------------------------------------------------------
    // Internal Methods
    // ---------------------------------------------------------------------

    private function readCacheFile(): array
    {
        if (! file_exists($this->cacheFile) || filesize($this->cacheFile) === 0) {
            return [];
        }

        $handle = @fopen($this->cacheFile, 'rb');
        if ($handle === false) {
            return [];
        }

        try {
            if (! flock($handle, LOCK_SH)) {
                return [];
            }
            $content = stream_get_contents($handle);
            flock($handle, LOCK_UN);

            if ($content === false || $content === '') {
                return [];
            }

            $data = unserialize($content);
            if ($data === false) {
                return [];
            }

            return $data;
        } finally {
            if (is_resource($handle)) {
                fclose($handle);
            }
        }
    }

    private function writeCacheFile(array $data): bool
    {
        $jsonData = serialize($data);

        if ($jsonData === false) {
            return false;
        }

        $handle = @fopen($this->cacheFile, 'cb');
        if ($handle === false) {
            return false;
        }

        try {
            if (! flock($handle, LOCK_EX)) {
                return false;
            }
            if (! ftruncate($handle, 0)) {
                return false;
            }
            if (fwrite($handle, $jsonData) === false) {
                return false;
            }
            fflush($handle);
            flock($handle, LOCK_UN);
            @chmod($this->cacheFile, $this->filePermission);

            return true;
        } catch (Throwable $e) {
            flock($handle, LOCK_UN); // Ensure lock release on error

            return false;
        } finally {
            if (is_resource($handle)) {
                fclose($handle);
            }
        }
    }

    private function ensureDirectoryExists(string $directory): void
    {
        if (! is_dir($directory)) {
            if (! @mkdir($directory, $this->dirPermission, true)) {
                throw new InvalidArgumentException("Cache directory does not exist and could not be created: {$directory}");
            }
            @chmod($directory, $this->dirPermission);
        }
    }

    private function calculateExpiry(DateInterval|int|null $ttl): ?int
    {
        if ($ttl === null) {
            return null;
        }
        $now = time();
        if (is_int($ttl)) {
            return $ttl <= 0 ? $now - 1 : $now + $ttl;
        }
        if ($ttl instanceof DateInterval) {
            try {
                return (new DateTimeImmutable())->add($ttl)->getTimestamp();
            } catch (Throwable $e) {
                return null;
            }
        }
        throw new InvalidArgumentException('Invalid TTL type provided. Must be null, int, or DateInterval.');
    }

    private function isExpired(?int $expiry): bool
    {
        return $expiry !== null && time() >= $expiry;
    }

    private function sanitizeKey(string $key): string
    {
        if ($key === '') {
            throw new InvalidArgumentException('Cache key cannot be empty.');
        }

        // PSR-16 validation (optional stricter check)
        // if (preg_match('/[{}()\/@:]/', $key)) {
        //     throw new InvalidArgumentException("Cache key \"{$key}\" contains reserved characters.");
        // }
        return $key;
    }

    private function validateKeys(array $keys): void
    {
        foreach ($keys as $key) {
            if (! is_string($key)) {
                throw new InvalidArgumentException('Cache key must be a string, got ' . gettype($key));
            }
            $this->sanitizeKey($key);
        }
    }

    private function iterableToArray(iterable $iterable): array
    {
        if (is_array($iterable)) {
            return $iterable;
        }

        return iterator_to_array($iterable);
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server;

use LogicException;
use PhpMcp\Server\Contracts\LoggerAwareInterface;
use PhpMcp\Server\Contracts\LoopAwareInterface;
use PhpMcp\Server\Contracts\ServerTransportInterface;
use PhpMcp\Server\Exception\ConfigurationException;
use PhpMcp\Server\Exception\DiscoveryException;
use PhpMcp\Server\Session\SessionManager;
use PhpMcp\Server\Utils\Discoverer;
use Throwable;

/**
 * Core MCP Server instance.
 *
 * Holds the configured MCP logic (Configuration, Registry, Protocol)
 * but is transport-agnostic. It relies on a ServerTransportInterface implementation,
 * provided via the listen() method, to handle network communication.
 *
 * Instances should be created via the ServerBuilder.
 */
class Server
{
    protected bool $discoveryRan = false;

    protected bool $isListening = false;

    /**
     *  @internal Use ServerBuilder::make()->...->build().
     *
     * @param  Configuration  $configuration  Core configuration and dependencies.
     * @param  Registry  $registry  Holds registered MCP element definitions.
     * @param  Protocol  $protocol  Handles MCP requests and responses.
     */
    public function __construct(
        protected readonly Configuration $configuration,
        protected readonly Registry $registry,
        protected readonly Protocol $protocol,
        protected readonly SessionManager $sessionManager,
    ) {
    }

    public static function make(): ServerBuilder
    {
        return new ServerBuilder();
    }

    /**
     * Runs the attribute discovery process based on the configuration
     * provided during build time. Caches results if cache is available.
     * Can be called explicitly, but is also called by ServerBuilder::build()
     * if discovery paths are configured.
     *
     * @param  bool  $force  Re-run discovery even if already run.
     * @param  bool  $useCache  Attempt to load from/save to cache. Defaults to true if cache is available.
     *
     * @throws DiscoveryException If discovery process encounters errors.
     * @throws ConfigurationException If discovery paths were not configured.
     */
    public function discover(
        string $basePath,
        array $scanDirs = ['.', 'src'],
        array $excludeDirs = [],
        bool $force = false,
        bool $saveToCache = true,
        ?Discoverer $discoverer = null
    ): void {
        $realBasePath = realpath($basePath);
        if ($realBasePath === false || ! is_dir($realBasePath)) {
            throw new \InvalidArgumentException("Invalid discovery base path provided to discover(): {$basePath}");
        }

        $excludeDirs = array_merge($excludeDirs, ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules', '.git', '.svn']);

        if ($this->discoveryRan && ! $force) {
            $this->configuration->logger->debug('Discovery skipped: Already run or loaded from cache.');

            return;
        }

        $cacheAvailable = $this->configuration->cache !== null;
        $shouldSaveCache = $saveToCache && $cacheAvailable;

        $this->configuration->logger->info('Starting MCP element discovery...', [
            'basePath' => $realBasePath,
            'force' => $force,
            'saveToCache' => $shouldSaveCache,
        ]);

        $this->registry->clear();

        try {
            $discoverer ??= new Discoverer($this->registry, $this->configuration->logger);

            $discoverer->discover($realBasePath, $scanDirs, $excludeDirs);

            $this->discoveryRan = true;

            if ($shouldSaveCache) {
                $this->registry->save();
            }
        } catch (Throwable $e) {
            $this->discoveryRan = false;
            $this->configuration->logger->critical('MCP element discovery failed.', ['exception' => $e]);
            throw new DiscoveryException("Element discovery failed: {$e->getMessage()}", $e->getCode(), $e);
        }
    }

    /**
     * Binds the server's MCP logic to the provided transport and starts the transport's listener,
     * then runs the event loop, making this a BLOCKING call suitable for standalone servers.
     *
     * For framework integration where the loop is managed externally, use `getProtocol()`
     * and bind it to your framework's transport mechanism manually.
     *
     * @param  ServerTransportInterface  $transport  The transport to listen with.
     *
     * @throws LogicException If called after already listening.
     * @throws Throwable If transport->listen() fails immediately.
     */
    public function listen(ServerTransportInterface $transport, bool $runLoop = true): void
    {
        if ($this->isListening) {
            throw new LogicException('Server is already listening via a transport.');
        }

        $this->warnIfNoElements();

        if ($transport instanceof LoggerAwareInterface) {
            $transport->setLogger($this->configuration->logger);
        }
        if ($transport instanceof LoopAwareInterface) {
            $transport->setLoop($this->configuration->loop);
        }

        $protocol = $this->getProtocol();

        $closeHandlerCallback = function (?string $reason = null) use ($protocol) {
            $this->isListening = false;
            $this->configuration->logger->info('Transport closed.', ['reason' => $reason ?? 'N/A']);
            $protocol->unbindTransport();
            $this->configuration->loop->stop();
        };

        $transport->once('close', $closeHandlerCallback);

        $protocol->bindTransport($transport);

        try {
            $transport->listen();

            $this->isListening = true;

            if ($runLoop) {
                $this->sessionManager->startGcTimer();

                $this->configuration->loop->run();

                $this->endListen($transport);
            }
        } catch (Throwable $e) {
            $this->configuration->logger->critical('Failed to start listening or event loop crashed.', ['exception' => $e->getMessage()]);
            $this->endListen($transport);
            throw $e;
        }
    }

    public function endListen(ServerTransportInterface $transport): void
    {
        $protocol = $this->getProtocol();

        $protocol->unbindTransport();

        $this->sessionManager->stopGcTimer();

        $transport->removeAllListeners('close');
        $transport->close();

        $this->isListening = false;
        $this->configuration->logger->info("Server '{$this->configuration->serverInfo->name}' listener shut down.");
    }

    /**
     * Warns if no MCP elements are registered and discovery has not been run.
     */
    protected function warnIfNoElements(): void
    {
        if (! $this->registry->hasElements() && ! $this->discoveryRan) {
            $this->configuration->logger->warning(
                'Starting listener, but no MCP elements are registered and discovery has not been run. ' .
                    'Call $server->discover(...) at least once to find and cache elements before listen().'
            );
        } elseif (! $this->registry->hasElements() && $this->discoveryRan) {
            $this->configuration->logger->warning(
                'Starting listener, but no MCP elements were found after discovery/cache load.'
            );
        }
    }

    /**
     * Gets the Configuration instance associated with this server.
     */
    public function getConfiguration(): Configuration
    {
        return $this->configuration;
    }

    /**
     * Gets the Registry instance associated with this server.
     */
    public function getRegistry(): Registry
    {
        return $this->registry;
    }

    /**
     * Gets the Protocol instance associated with this server.
     */
    public function getProtocol(): Protocol
    {
        return $this->protocol;
    }

    public function getSessionManager(): SessionManager
    {
        return $this->sessionManager;
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Transports;

use Evenement\EventEmitterTrait;
use PhpMcp\Schema\JsonRpc\Parser;
use PhpMcp\Server\Contracts\LoggerAwareInterface;
use PhpMcp\Server\Contracts\LoopAwareInterface;
use PhpMcp\Server\Contracts\ServerTransportInterface;
use PhpMcp\Server\Exception\TransportException;
use PhpMcp\Schema\JsonRpc\Error;
use PhpMcp\Schema\JsonRpc\Message;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use React\ChildProcess\Process;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use React\Stream\ReadableResourceStream;
use React\Stream\ReadableStreamInterface;
use React\Stream\WritableResourceStream;
use React\Stream\WritableStreamInterface;
use Throwable;

use function React\Promise\reject;

/**
 * Implementation of the STDIO server transport using ReactPHP Process and Streams.
 * Listens on STDIN, writes to STDOUT, and emits events for the Protocol.
 */
class StdioServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface
{
    use EventEmitterTrait;

    protected LoggerInterface $logger;

    protected LoopInterface $loop;

    protected ?Process $process = null;

    protected ?ReadableStreamInterface $stdin = null;

    protected ?WritableStreamInterface $stdout = null;

    protected string $buffer = '';

    protected bool $closing = false;

    protected bool $listening = false;

    private const CLIENT_ID = 'stdio';

    /**
     * Constructor takes optional stream resources.
     * Defaults to STDIN and STDOUT if not provided.
     * Dependencies like Logger and Loop are injected via setters.
     *
     * @param  resource|null  $inputStreamResource  The readable resource (e.g., STDIN).
     * @param  resource|null  $outputStreamResource  The writable resource (e.g., STDOUT).
     *
     * @throws TransportException If provided resources are invalid.
     */
    public function __construct(
        protected $inputStreamResource = STDIN,
        protected $outputStreamResource = STDOUT
    ) {
        if (str_contains(PHP_OS, 'WIN') && ($this->inputStreamResource === STDIN && $this->outputStreamResource === STDOUT)) {
            $message = 'STDIN and STDOUT are not supported as input and output stream resources' .
                'on Windows due to PHP\'s limitations with non blocking pipes.' .
                'Please use WSL or HttpServerTransport, or if you are advanced, provide your own stream resources.';

            throw new TransportException($message);
        }

        // if (str_contains(PHP_OS, 'WIN')) {
        //     $this->inputStreamResource = pclose(popen('winpty -c "'.$this->inputStreamResource.'"', 'r'));
        //     $this->outputStreamResource = pclose(popen('winpty -c "'.$this->outputStreamResource.'"', 'w'));
        // }

        if (! is_resource($this->inputStreamResource) || get_resource_type($this->inputStreamResource) !== 'stream') {
            throw new TransportException('Invalid input stream resource provided.');
        }

        if (! is_resource($this->outputStreamResource) || get_resource_type($this->outputStreamResource) !== 'stream') {
            throw new TransportException('Invalid output stream resource provided.');
        }

        $this->logger = new NullLogger();
        $this->loop = Loop::get();
    }

    public function setLogger(LoggerInterface $logger): void
    {
        $this->logger = $logger;
    }

    public function setLoop(LoopInterface $loop): void
    {
        $this->loop = $loop;
    }

    /**
     * Starts listening on STDIN.
     *
     * @throws TransportException If already listening or streams cannot be opened.
     */
    public function listen(): void
    {
        if ($this->listening) {
            throw new TransportException('Stdio transport is already listening.');
        }

        if ($this->closing) {
            throw new TransportException('Cannot listen, transport is closing/closed.');
        }

        try {
            $this->stdin = new ReadableResourceStream($this->inputStreamResource, $this->loop);
            $this->stdout = new WritableResourceStream($this->outputStreamResource, $this->loop);
        } catch (Throwable $e) {
            $this->logger->error('Failed to open STDIN/STDOUT streams.', ['exception' => $e]);
            throw new TransportException("Failed to open standard streams: {$e->getMessage()}", 0, $e);
        }

        $this->stdin->on('data', function ($chunk) {
            $this->buffer .= $chunk;
            $this->processBuffer();
        });

        $this->stdin->on('error', function (Throwable $error) {
            $this->logger->error('STDIN stream error.', ['error' => $error->getMessage()]);
            $this->emit('error', [new TransportException("STDIN error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]);
            $this->close();
        });

        $this->stdin->on('close', function () {
            $this->logger->info('STDIN stream closed.');
            $this->emit('client_disconnected', [self::CLIENT_ID, 'STDIN Closed']);
            $this->close();
        });

        $this->stdout->on('error', function (Throwable $error) {
            $this->logger->error('STDOUT stream error.', ['error' => $error->getMessage()]);
            $this->emit('error', [new TransportException("STDOUT error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]);
            $this->close();
        });

        try {
            $signalHandler = function (int $signal) {
                $this->logger->info("Received signal {$signal}, shutting down.");
                $this->close();
            };
            $this->loop->addSignal(SIGTERM, $signalHandler);
            $this->loop->addSignal(SIGINT, $signalHandler);
        } catch (Throwable $e) {
            $this->logger->debug('Signal handling not supported by current event loop.');
        }

        $this->logger->info('Server is up and listening on STDIN 🚀');

        $this->listening = true;
        $this->closing = false;
        $this->emit('ready');
        $this->emit('client_connected', [self::CLIENT_ID]);
    }

    /** Processes the internal buffer to find complete lines/frames. */
    private function processBuffer(): void
    {
        while (str_contains($this->buffer, "\n")) {
            $pos = strpos($this->buffer, "\n");
            $line = substr($this->buffer, 0, $pos);
            $this->buffer = substr($this->buffer, $pos + 1);

            $trimmedLine = trim($line);
            if (empty($trimmedLine)) {
                continue;
            }

            try {
                $message = Parser::parse($trimmedLine);
            } catch (Throwable $e) {
                $this->logger->error('Error parsing message', ['exception' => $e]);
                $error = Error::forParseError("Invalid JSON: " . $e->getMessage());
                $this->sendMessage($error, self::CLIENT_ID);
                continue;
            }

            $this->emit('message', [$message, self::CLIENT_ID]);
        }
    }

    /**
     * Sends a raw, framed message to STDOUT.
     */
    public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface
    {
        if ($this->closing || ! $this->stdout || ! $this->stdout->isWritable()) {
            return reject(new TransportException('Stdio transport is closed or STDOUT is not writable.'));
        }

        $deferred = new Deferred();
        $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        $written = $this->stdout->write($json . "\n");

        if ($written) {
            $deferred->resolve(null);
        } else {
            $this->logger->debug('STDOUT buffer full, waiting for drain.');
            $this->stdout->once('drain', function () use ($deferred) {
                $this->logger->debug('STDOUT drained.');
                $deferred->resolve(null);
            });
        }

        return $deferred->promise();
    }

    /**
     * Stops listening and cleans up resources.
     */
    public function close(): void
    {
        if ($this->closing) {
            return;
        }
        $this->closing = true;
        $this->listening = false;
        $this->logger->info('Closing Stdio transport...');

        $this->stdin?->close();
        $this->stdout?->close();

        $this->stdin = null;
        $this->stdout = null;

        $this->emit('close', ['StdioTransport closed.']);
        $this->removeAllListeners();
    }
}

<?php

namespace PhpMcp\Server\Utils;

use phpDocumentor\Reflection\DocBlock\Tags\Param;
use PhpMcp\Server\Attributes\Schema;
use PhpMcp\Server\Context;
use ReflectionEnum;
use ReflectionIntersectionType;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionType;
use ReflectionUnionType;
use stdClass;

/**
 * Generates JSON Schema for method parameters with intelligent Schema attribute handling.
 *
 * Priority system:
 * 1. Schema attributes (method-level and parameter-level)
 * 2. Reflection type information
 * 3. DocBlock type information
 */
class SchemaGenerator
{
    private DocBlockParser $docBlockParser;

    public function __construct(DocBlockParser $docBlockParser)
    {
        $this->docBlockParser = $docBlockParser;
    }

    /**
     * Generates a JSON Schema object (as a PHP array) for a method's or function's parameters.
     */
    public function generate(\ReflectionMethod|\ReflectionFunction $reflection): array
    {
        $methodSchema = $this->extractMethodLevelSchema($reflection);

        if ($methodSchema && isset($methodSchema['definition'])) {
            return $methodSchema['definition'];
        }

        $parametersInfo = $this->parseParametersInfo($reflection);

        return $this->buildSchemaFromParameters($parametersInfo, $methodSchema);
    }

    /**
     * Extracts method-level or function-level Schema attribute.
     */
    private function extractMethodLevelSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array
    {
        $schemaAttrs = $reflection->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF);
        if (empty($schemaAttrs)) {
            return null;
        }

        $schemaAttr = $schemaAttrs[0]->newInstance();
        return $schemaAttr->toArray();
    }

    /**
     * Extracts parameter-level Schema attribute.
     */
    private function extractParameterLevelSchema(ReflectionParameter $parameter): array
    {
        $schemaAttrs = $parameter->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF);
        if (empty($schemaAttrs)) {
            return [];
        }

        $schemaAttr = $schemaAttrs[0]->newInstance();
        return $schemaAttr->toArray();
    }

    /**
     * Builds the final schema from parameter information and method-level schema.
     *
     * @param array<int, array{
     *     name: string,
     *     doc_block_tag: Param|null,
     *     reflection_param: ReflectionParameter,
     *     reflection_type_object: ReflectionType|null,
     *     type_string: string,
     *     description: string|null,
     *     required: bool,
     *     allows_null: bool,
     *     default_value: mixed|null,
     *     has_default: bool,
     *     is_variadic: bool,
     *     parameter_schema: array<string, mixed>
     * }> $parametersInfo
     *
     * @param array<string, mixed>|null $methodSchema
     *
     * @return array<string, mixed>
     */
    private function buildSchemaFromParameters(array $parametersInfo, ?array $methodSchema): array
    {
        $schema = [
            'type' => 'object',
            'properties' => [],
            'required' => [],
        ];

        // Apply method-level schema as base
        if ($methodSchema) {
            $schema = array_merge($schema, $methodSchema);
            if (!isset($schema['type'])) {
                $schema['type'] = 'object';
            }
            if (!isset($schema['properties'])) {
                $schema['properties'] = [];
            }
            if (!isset($schema['required'])) {
                $schema['required'] = [];
            }
        }

        foreach ($parametersInfo as $paramInfo) {
            $paramName = $paramInfo['name'];

            $methodLevelParamSchema = $schema['properties'][$paramName] ?? null;

            $paramSchema = $this->buildParameterSchema($paramInfo, $methodLevelParamSchema);

            $schema['properties'][$paramName] = $paramSchema;

            if ($paramInfo['required'] && !in_array($paramName, $schema['required'])) {
                $schema['required'][] = $paramName;
            } elseif (!$paramInfo['required'] && ($key = array_search($paramName, $schema['required'])) !== false) {
                unset($schema['required'][$key]);
                $schema['required'] = array_values($schema['required']); // Re-index
            }
        }

        // Clean up empty properties
        if (empty($schema['properties'])) {
            $schema['properties'] = new stdClass();
        }
        if (empty($schema['required'])) {
            unset($schema['required']);
        }

        return $schema;
    }

    /**
     * Builds the final schema for a single parameter by merging all three levels.
     *
     * @param array{
     *     name: string,
     *     doc_block_tag: Param|null,
     *     reflection_param: ReflectionParameter,
     *     reflection_type_object: ReflectionType|null,
     *     type_string: string,
     *     description: string|null,
     *     required: bool,
     *     allows_null: bool,
     *     default_value: mixed|null,
     *     has_default: bool,
     *     is_variadic: bool,
     *     parameter_schema: array<string, mixed>
     * } $paramInfo
     * @param array<string, mixed>|null $methodLevelParamSchema
     */
    private function buildParameterSchema(array $paramInfo, ?array $methodLevelParamSchema = null): array
    {
        if ($paramInfo['is_variadic']) {
            return $this->buildVariadicParameterSchema($paramInfo);
        }

        $inferredSchema = $this->buildInferredParameterSchema($paramInfo);

        // Method-level takes precedence over inferred schema
        $mergedSchema = $inferredSchema;
        if ($methodLevelParamSchema) {
            $mergedSchema = $this->mergeSchemas($inferredSchema, $methodLevelParamSchema);
        }

        // Parameter-level takes highest precedence
        $parameterLevelSchema = $paramInfo['parameter_schema'];
        if (!empty($parameterLevelSchema)) {
            $mergedSchema = $this->mergeSchemas($mergedSchema, $parameterLevelSchema);
        }

        return $mergedSchema;
    }

    /**
     * Merge two schemas where the dominant schema takes precedence over the recessive one.
     *
     * @param array $recessiveSchema The schema with lower precedence
     * @param array $dominantSchema The schema with higher precedence
     */
    private function mergeSchemas(array $recessiveSchema, array $dominantSchema): array
    {
        $mergedSchema = array_merge($recessiveSchema, $dominantSchema);

        return $mergedSchema;
    }

    /**
     * Builds parameter schema from inferred type and docblock information only.
     * Returns empty array for variadic parameters (handled separately).
     */
    private function buildInferredParameterSchema(array $paramInfo): array
    {
        $paramSchema = [];

        // Variadic parameters are handled separately
        if ($paramInfo['is_variadic']) {
            return [];
        }

        // Infer JSON Schema types
        $jsonTypes = $this->inferParameterTypes($paramInfo);

        if (count($jsonTypes) === 1) {
            $paramSchema['type'] = $jsonTypes[0];
        } elseif (count($jsonTypes) > 1) {
            $paramSchema['type'] = $jsonTypes;
        }

        // Add description from docblock
        if ($paramInfo['description']) {
            $paramSchema['description'] = $paramInfo['description'];
        }

        // Add default value only if parameter actually has a default
        if ($paramInfo['has_default']) {
            $paramSchema['default'] = $paramInfo['default_value'];
        }

        // Handle enums
        $paramSchema = $this->applyEnumConstraints($paramSchema, $paramInfo);

        // Handle array items
        $paramSchema = $this->applyArrayConstraints($paramSchema, $paramInfo);

        return $paramSchema;
    }

    /**
     * Builds schema for variadic parameters.
     */
    private function buildVariadicParameterSchema(array $paramInfo): array
    {
        $paramSchema = ['type' => 'array'];

        // Apply parameter-level Schema attributes first
        if (!empty($paramInfo['parameter_schema'])) {
            $paramSchema = array_merge($paramSchema, $paramInfo['parameter_schema']);
            // Ensure type is always array for variadic
            $paramSchema['type'] = 'array';
        }

        if ($paramInfo['description']) {
            $paramSchema['description'] = $paramInfo['description'];
        }

        // If no items specified by Schema attribute, infer from type
        if (!isset($paramSchema['items'])) {
            $itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']);
            $nonNullItemTypes = array_filter($itemJsonTypes, fn($t) => $t !== 'null');

            if (count($nonNullItemTypes) === 1) {
                $paramSchema['items'] = ['type' => $nonNullItemTypes[0]];
            }
        }

        return $paramSchema;
    }

    /**
     * Infers JSON Schema types for a parameter.
     */
    private function inferParameterTypes(array $paramInfo): array
    {
        $jsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']);

        if ($paramInfo['allows_null'] && strtolower($paramInfo['type_string']) !== 'mixed' && !in_array('null', $jsonTypes)) {
            $jsonTypes[] = 'null';
        }

        if (count($jsonTypes) > 1) {
            // Sort but ensure null comes first for consistency
            $nullIndex = array_search('null', $jsonTypes);
            if ($nullIndex !== false) {
                unset($jsonTypes[$nullIndex]);
                sort($jsonTypes);
                array_unshift($jsonTypes, 'null');
            } else {
                sort($jsonTypes);
            }
        }

        return $jsonTypes;
    }

    /**
     * Applies enum constraints to parameter schema.
     */
    private function applyEnumConstraints(array $paramSchema, array $paramInfo): array
    {
        $reflectionType = $paramInfo['reflection_type_object'];

        if (!($reflectionType instanceof ReflectionNamedType) || $reflectionType->isBuiltin() || !enum_exists($reflectionType->getName())) {
            return $paramSchema;
        }

        $enumClass = $reflectionType->getName();
        $enumReflection = new ReflectionEnum($enumClass);
        $backingTypeReflection = $enumReflection->getBackingType();

        if ($enumReflection->isBacked() && $backingTypeReflection instanceof ReflectionNamedType) {
            $paramSchema['enum'] = array_column($enumClass::cases(), 'value');
            $jsonBackingType = match ($backingTypeReflection->getName()) {
                'int' => 'integer',
                'string' => 'string',
                default => null,
            };

            if ($jsonBackingType) {
                if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) {
                    $paramSchema['type'] = ['null', $jsonBackingType];
                } else {
                    $paramSchema['type'] = $jsonBackingType;
                }
            }
        } else {
            // Non-backed enum - use names as enum values
            $paramSchema['enum'] = array_column($enumClass::cases(), 'name');
            if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) {
                $paramSchema['type'] = ['null', 'string'];
            } else {
                $paramSchema['type'] = 'string';
            }
        }

        return $paramSchema;
    }

    /**
     * Applies array-specific constraints to parameter schema.
     */
    private function applyArrayConstraints(array $paramSchema, array $paramInfo): array
    {
        if (!isset($paramSchema['type'])) {
            return $paramSchema;
        }

        $typeString = $paramInfo['type_string'];
        $allowsNull = $paramInfo['allows_null'];

        // Handle object-like arrays using array{} syntax
        if (preg_match('/^array\s*{/i', $typeString)) {
            $objectSchema = $this->inferArrayItemsType($typeString);
            if (is_array($objectSchema) && isset($objectSchema['properties'])) {
                $paramSchema = array_merge($paramSchema, $objectSchema);
                $paramSchema['type'] = $allowsNull ? ['object', 'null'] : 'object';
            }
        }
        // Handle regular arrays
        elseif (in_array('array', $this->mapPhpTypeToJsonSchemaType($typeString))) {
            $itemsType = $this->inferArrayItemsType($typeString);
            if ($itemsType !== 'any') {
                if (is_string($itemsType)) {
                    $paramSchema['items'] = ['type' => $itemsType];
                } else {
                    if (!isset($itemsType['type']) && isset($itemsType['properties'])) {
                        $itemsType = array_merge(['type' => 'object'], $itemsType);
                    }
                    $paramSchema['items'] = $itemsType;
                }
            }

            if ($allowsNull) {
                $paramSchema['type'] = ['array', 'null'];
                sort($paramSchema['type']);
            } else {
                $paramSchema['type'] = 'array';
            }
        }

        return $paramSchema;
    }

    /**
     * Parses detailed information about a method's parameters.
     *
     * @return array<int, array{
     *     name: string,
     *     doc_block_tag: Param|null,
     *     reflection_param: ReflectionParameter,
     *     reflection_type_object: ReflectionType|null,
     *     type_string: string,
     *     description: string|null,
     *     required: bool,
     *     allows_null: bool,
     *     default_value: mixed|null,
     *     has_default: bool,
     *     is_variadic: bool,
     *     parameter_schema: array<string, mixed>
     * }>
     */
    private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $reflection): array
    {
        $docComment = $reflection->getDocComment() ?: null;
        $docBlock = $this->docBlockParser->parseDocBlock($docComment);
        $paramTags = $this->docBlockParser->getParamTags($docBlock);
        $parametersInfo = [];

        foreach ($reflection->getParameters() as $rp) {
            $paramName = $rp->getName();
            $paramTag = $paramTags['$' . $paramName] ?? null;

            $reflectionType = $rp->getType();

            if ($reflectionType instanceof ReflectionNamedType && $reflectionType?->getName() === Context::class) {
                continue;
            }

            $typeString = $this->getParameterTypeString($rp, $paramTag);
            $description = $this->docBlockParser->getParamDescription($paramTag);
            $hasDefault = $rp->isDefaultValueAvailable();
            $defaultValue = $hasDefault ? $rp->getDefaultValue() : null;
            $isVariadic = $rp->isVariadic();

            $parameterSchema = $this->extractParameterLevelSchema($rp);

            if ($defaultValue instanceof \BackedEnum) {
                $defaultValue = $defaultValue->value;
            }

            if ($defaultValue instanceof \UnitEnum) {
                $defaultValue = $defaultValue->name;
            }

            $allowsNull = false;
            if ($reflectionType && $reflectionType->allowsNull()) {
                $allowsNull = true;
            } elseif ($hasDefault && $defaultValue === null) {
                $allowsNull = true;
            } elseif (str_contains($typeString, 'null') || strtolower($typeString) === 'mixed') {
                $allowsNull = true;
            }

            $parametersInfo[] = [
                'name' => $paramName,
                'doc_block_tag' => $paramTag,
                'reflection_param' => $rp,
                'reflection_type_object' => $reflectionType,
                'type_string' => $typeString,
                'description' => $description,
                'required' => !$rp->isOptional(),
                'allows_null' => $allowsNull,
                'default_value' => $defaultValue,
                'has_default' => $hasDefault,
                'is_variadic' => $isVariadic,
                'parameter_schema' => $parameterSchema,
            ];
        }

        return $parametersInfo;
    }

    /**
     * Determines the type string for a parameter, prioritizing DocBlock.
     */
    private function getParameterTypeString(ReflectionParameter $rp, ?Param $paramTag): string
    {
        $docBlockType = $this->docBlockParser->getParamTypeString($paramTag);
        $isDocBlockTypeGeneric = false;

        if ($docBlockType !== null) {
            if (in_array(strtolower($docBlockType), ['mixed', 'unknown', ''])) {
                $isDocBlockTypeGeneric = true;
            }
        } else {
            $isDocBlockTypeGeneric = true; // No tag or no type in tag implies generic
        }

        $reflectionType = $rp->getType();
        $reflectionTypeString = null;
        if ($reflectionType) {
            $reflectionTypeString = $this->getTypeStringFromReflection($reflectionType, $rp->allowsNull());
        }

        // Prioritize Reflection if DocBlock type is generic AND Reflection provides a more specific type
        if ($isDocBlockTypeGeneric && $reflectionTypeString !== null && $reflectionTypeString !== 'mixed') {
            return $reflectionTypeString;
        }

        // Otherwise, use the DocBlock type if it was valid and non-generic
        if ($docBlockType !== null && !$isDocBlockTypeGeneric) {
            // Consider if DocBlock adds nullability missing from reflection
            if (stripos($docBlockType, 'null') !== false && $reflectionTypeString && stripos($reflectionTypeString, 'null') === false && !str_ends_with($reflectionTypeString, '|null')) {
                // If reflection didn't capture null, but docblock did, append |null (if not already mixed)
                if ($reflectionTypeString !== 'mixed') {
                    return $reflectionTypeString . '|null';
                }
            }

            return $docBlockType;
        }

        // Fallback to Reflection type even if it was generic ('mixed')
        if ($reflectionTypeString !== null) {
            return $reflectionTypeString;
        }

        // Default to 'mixed' if nothing else found
        return 'mixed';
    }

    /**
     * Converts a ReflectionType object into a type string representation.
     */
    private function getTypeStringFromReflection(?ReflectionType $type, bool $nativeAllowsNull): string
    {
        if ($type === null) {
            return 'mixed';
        }

        $types = [];
        if ($type instanceof ReflectionUnionType) {
            foreach ($type->getTypes() as $innerType) {
                $types[] = $this->getTypeStringFromReflection($innerType, $innerType->allowsNull());
            }
            if ($nativeAllowsNull) {
                $types = array_filter($types, fn($t) => strtolower($t) !== 'null');
            }
            $typeString = implode('|', array_unique(array_filter($types)));
        } elseif ($type instanceof ReflectionIntersectionType) {
            foreach ($type->getTypes() as $innerType) {
                $types[] = $this->getTypeStringFromReflection($innerType, false);
            }
            $typeString = implode('&', array_unique(array_filter($types)));
        } elseif ($type instanceof ReflectionNamedType) {
            $typeString = $type->getName();
        } else {
            return 'mixed';
        }

        $typeString = match (strtolower($typeString)) {
            'bool' => 'boolean',
            'int' => 'integer',
            'float', 'double' => 'number',
            'str' => 'string',
            default => $typeString,
        };

        $isNullable = $nativeAllowsNull;
        if ($type instanceof ReflectionNamedType && $type->getName() === 'mixed') {
            $isNullable = true;
        }

        if ($type instanceof ReflectionUnionType && !$nativeAllowsNull) {
            foreach ($type->getTypes() as $innerType) {
                if ($innerType instanceof ReflectionNamedType && strtolower($innerType->getName()) === 'null') {
                    $isNullable = true;
                    break;
                }
            }
        }

        if ($isNullable && $typeString !== 'mixed' && stripos($typeString, 'null') === false) {
            if (!str_ends_with($typeString, '|null') && !str_ends_with($typeString, '&null')) {
                $typeString .= '|null';
            }
        }

        // Remove leading backslash from class names, but handle built-ins like 'int' or unions like 'int|string'
        if (str_contains($typeString, '\\')) {
            $parts = preg_split('/([|&])/', $typeString, -1, PREG_SPLIT_DELIM_CAPTURE);
            $processedParts = array_map(fn($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts);
            $typeString = implode('', $processedParts);
        }

        return $typeString ?: 'mixed';
    }

    /**
     * Maps a PHP type string (potentially a union) to an array of JSON Schema type names.
     */
    private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array
    {
        $normalizedType = strtolower(trim($phpTypeString));

        // PRIORITY 1: Check for array{} syntax which should be treated as object
        if (preg_match('/^array\s*{/i', $normalizedType)) {
            return ['object'];
        }

        // PRIORITY 2: Check for array syntax first (T[] or generics)
        if (
            str_contains($normalizedType, '[]') ||
            preg_match('/^(array|list|iterable|collection)</i', $normalizedType)
        ) {
            return ['array'];
        }

        // PRIORITY 3: Handle unions (recursive)
        if (str_contains($normalizedType, '|')) {
            $types = explode('|', $normalizedType);
            $jsonTypes = [];
            foreach ($types as $type) {
                $mapped = $this->mapPhpTypeToJsonSchemaType(trim($type));
                $jsonTypes = array_merge($jsonTypes, $mapped);
            }

            return array_values(array_unique($jsonTypes));
        }

        // PRIORITY 4: Handle simple built-in types
        return match ($normalizedType) {
            'string', 'scalar' => ['string'],
            '?string' => ['null', 'string'],
            'int', 'integer' => ['integer'],
            '?int', '?integer' => ['null', 'integer'],
            'float', 'double', 'number' => ['number'],
            '?float', '?double', '?number' => ['null', 'number'],
            'bool', 'boolean' => ['boolean'],
            '?bool', '?boolean' => ['null', 'boolean'],
            'array' => ['array'],
            '?array' => ['null', 'array'],
            'object', 'stdclass' => ['object'],
            '?object', '?stdclass' => ['null', 'object'],
            'null' => ['null'],
            'resource', 'callable' => ['object'],
            'mixed' => [],
            'void', 'never' => [],
            default => ['object'],
        };
    }

    /**
     * Infers the 'items' schema type for an array based on DocBlock type hints.
     */
    private function inferArrayItemsType(string $phpTypeString): string|array
    {
        $normalizedType = trim($phpTypeString);

        // Case 1: Simple T[] syntax (e.g., string[], int[], bool[], etc.)
        if (preg_match('/^(\\??)([\w\\\\]+)\\s*\\[\\]$/i', $normalizedType, $matches)) {
            $itemType = strtolower($matches[2]);
            return $this->mapSimpleTypeToJsonSchema($itemType);
        }

        // Case 2: Generic array<T> syntax (e.g., array<string>, array<int>, etc.)
        if (preg_match('/^(\\??)array\s*<\s*([\w\\\\|]+)\s*>$/i', $normalizedType, $matches)) {
            $itemType = strtolower($matches[2]);
            return $this->mapSimpleTypeToJsonSchema($itemType);
        }

        // Case 3: Nested array<array<T>> syntax or T[][] syntax
        if (
            preg_match('/^(\\??)array\s*<\s*array\s*<\s*([\w\\\\|]+)\s*>\s*>$/i', $normalizedType, $matches) ||
            preg_match('/^(\\??)([\w\\\\]+)\s*\[\]\[\]$/i', $normalizedType, $matches)
        ) {
            $innerType = $this->mapSimpleTypeToJsonSchema(isset($matches[2]) ? strtolower($matches[2]) : 'any');
            // Return a schema for array with items being arrays
            return [
                'type' => 'array',
                'items' => [
                    'type' => $innerType
                ]
            ];
        }

        // Case 4: Object-like array syntax (e.g., array{name: string, age: int})
        if (preg_match('/^(\\??)array\s*\{(.+)\}$/is', $normalizedType, $matches)) {
            return $this->parseObjectLikeArray($matches[2]);
        }

        return 'any';
    }

    /**
     * Parses object-like array syntax into a JSON Schema object
     */
    private function parseObjectLikeArray(string $propertiesStr): array
    {
        $properties = [];
        $required = [];

        // Parse properties from the string, handling nested structures
        $depth = 0;
        $buffer = '';

        for ($i = 0; $i < strlen($propertiesStr); $i++) {
            $char = $propertiesStr[$i];

            // Track nested braces
            if ($char === '{') {
                $depth++;
                $buffer .= $char;
            } elseif ($char === '}') {
                $depth--;
                $buffer .= $char;
            }
            // Property separator (comma)
            elseif ($char === ',' && $depth === 0) {
                // Process the completed property
                $this->parsePropertyDefinition(trim($buffer), $properties, $required);
                $buffer = '';
            } else {
                $buffer .= $char;
            }
        }

        // Process the last property
        if (!empty($buffer)) {
            $this->parsePropertyDefinition(trim($buffer), $properties, $required);
        }

        if (!empty($properties)) {
            return [
                'type' => 'object',
                'properties' => $properties,
                'required' => $required
            ];
        }

        return ['type' => 'object'];
    }

    /**
     * Parses a single property definition from an object-like array syntax
     */
    private function parsePropertyDefinition(string $propDefinition, array &$properties, array &$required): void
    {
        // Match property name and type
        if (preg_match('/^([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s*:\s*(.+)$/i', $propDefinition, $matches)) {
            $propName = $matches[1];
            $propType = trim($matches[2]);

            // Add to required properties
            $required[] = $propName;

            // Check for nested array{} syntax
            if (preg_match('/^array\s*\{(.+)\}$/is', $propType, $nestedMatches)) {
                $nestedSchema = $this->parseObjectLikeArray($nestedMatches[1]);
                $properties[$propName] = $nestedSchema;
            }
            // Check for array<T> or T[] syntax
            elseif (
                preg_match('/^array\s*<\s*([\w\\\\|]+)\s*>$/i', $propType, $arrayMatches) ||
                preg_match('/^([\w\\\\]+)\s*\[\]$/i', $propType, $arrayMatches)
            ) {
                $itemType = $arrayMatches[1] ?? 'any';
                $properties[$propName] = [
                    'type' => 'array',
                    'items' => [
                        'type' => $this->mapSimpleTypeToJsonSchema($itemType)
                    ]
                ];
            }
            // Simple type
            else {
                $properties[$propName] = ['type' => $this->mapSimpleTypeToJsonSchema($propType)];
            }
        }
    }

    /**
     * Helper method to map basic PHP types to JSON Schema types
     */
    private function mapSimpleTypeToJsonSchema(string $type): string
    {
        return match (strtolower($type)) {
            'string' => 'string',
            'int', 'integer' => 'integer',
            'bool', 'boolean' => 'boolean',
            'float', 'double', 'number' => 'number',
            'array' => 'array',
            'object', 'stdclass' => 'object',
            default => in_array(strtolower($type), ['datetime', 'datetimeinterface']) ? 'string' : 'object',
        };
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Exception;

use Exception;
use PhpMcp\Schema\Constants;
use PhpMcp\Schema\JsonRpc\Error as JsonRpcError;
use Throwable;

/**
 * Base exception for all MCP Server library errors.
 */
class McpServerException extends Exception
{
    // MCP reserved range: -32000 to -32099 (Server error)
    // Add specific server-side codes if needed later, e.g.:
    // public const CODE_RESOURCE_ACTION_FAILED = -32000;
    // public const CODE_TOOL_EXECUTION_FAILED = -32001;

    /**
     * Additional data associated with the error, suitable for JSON-RPC 'data' field.
     *
     * @var mixed|null
     */
    protected mixed $data = null;

    /**
     * @param  string  $message  Error message.
     * @param  int  $code  Error code (use constants or appropriate HTTP status codes if applicable).
     * @param  mixed|null  $data  Additional data.
     * @param  ?Throwable  $previous  Previous exception.
     */
    public function __construct(
        string $message = '',
        int $code = 0,
        mixed $data = null,
        ?Throwable $previous = null
    ) {
        parent::__construct($message, $code, $previous);
        $this->data = $data;
    }

    /**
     * Get additional error data.
     *
     * @return mixed|null
     */
    public function getData(): mixed
    {
        return $this->data;
    }

    /**
     * Formats the exception into a JSON-RPC 2.0 error object structure.
     * Specific exceptions should override this or provide factories with correct codes.
     */
    public function toJsonRpcError(string|int $id): JsonRpcError
    {
        $code = ($this->code >= -32768 && $this->code <= -32000) ? $this->code : Constants::INTERNAL_ERROR;

        return new JsonRpcError(
            jsonrpc: '2.0',
            id: $id,
            code: $code,
            message: $this->getMessage(),
            data: $this->getData()
        );
    }

    public static function parseError(string $details, ?Throwable $previous = null): self
    {
        return new ProtocolException('Parse error: ' . $details, Constants::PARSE_ERROR, null, $previous);
    }

    public static function invalidRequest(?string $details = 'Invalid Request', ?Throwable $previous = null): self
    {
        return new ProtocolException($details, Constants::INVALID_REQUEST, null, $previous);
    }

    public static function methodNotFound(string $methodName, ?string $message = null, ?Throwable $previous = null): self
    {
        return new ProtocolException($message ?? "Method not found: {$methodName}", Constants::METHOD_NOT_FOUND, null, $previous);
    }

    public static function invalidParams(string $message = 'Invalid params', $data = null, ?Throwable $previous = null): self
    {
        // Pass data (e.g., validation errors) through
        return new ProtocolException($message, Constants::INVALID_PARAMS, $data, $previous);
    }

    public static function internalError(?string $details = 'Internal server error', ?Throwable $previous = null): self
    {
        $message = 'Internal error';
        if ($details && is_string($details)) {
            $message .= ': ' . $details;
        } elseif ($previous && $details === null) {
            $message .= ' (See server logs)';
        }

        return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous);
    }

    public static function toolExecutionFailed(string $toolName, ?Throwable $previous = null): self
    {
        $message = "Execution failed for tool '{$toolName}'";
        if ($previous) {
            $message .= ': ' . $previous->getMessage();
        }

        return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous);
    }

    public static function resourceReadFailed(string $uri, ?Throwable $previous = null): self
    {
        $message = "Failed to read resource '{$uri}'";
        if ($previous) {
            $message .= ': ' . $previous->getMessage();
        }

        return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous);
    }

    public static function promptGenerationFailed(string $promptName, ?Throwable $previous = null): self
    {
        $message = "Failed to generate prompt '{$promptName}'";
        if ($previous) {
            $message .= ': ' . $previous->getMessage();
        }

        return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous);
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server;

use Evenement\EventEmitterInterface;
use Evenement\EventEmitterTrait;
use PhpMcp\Schema\Prompt;
use PhpMcp\Schema\Resource;
use PhpMcp\Schema\ResourceTemplate;
use PhpMcp\Schema\Tool;
use PhpMcp\Server\Elements\RegisteredPrompt;
use PhpMcp\Server\Elements\RegisteredResource;
use PhpMcp\Server\Elements\RegisteredResourceTemplate;
use PhpMcp\Server\Elements\RegisteredTool;
use PhpMcp\Server\Exception\DefinitionException;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException;
use Throwable;

class Registry implements EventEmitterInterface
{
    use EventEmitterTrait;

    private const DISCOVERED_ELEMENTS_CACHE_KEY = 'mcp_server_discovered_elements';

    /** @var array<string, RegisteredTool> */
    private array $tools = [];

    /** @var array<string, RegisteredResource> */
    private array $resources = [];

    /** @var array<string, RegisteredPrompt> */
    private array $prompts = [];

    /** @var array<string, RegisteredResourceTemplate> */
    private array $resourceTemplates = [];

    private array $listHashes = [
        'tools' => '',
        'resources' => '',
        'resource_templates' => '',
        'prompts' => '',
    ];

    private bool $notificationsEnabled = true;

    public function __construct(
        protected LoggerInterface $logger,
        protected ?CacheInterface $cache = null,
    ) {
        $this->load();
        $this->computeAllHashes();
    }

    /**
     * Compute hashes for all lists for change detection
     */
    private function computeAllHashes(): void
    {
        $this->listHashes['tools'] = $this->computeHash($this->tools);
        $this->listHashes['resources'] = $this->computeHash($this->resources);
        $this->listHashes['resource_templates'] = $this->computeHash($this->resourceTemplates);
        $this->listHashes['prompts'] = $this->computeHash($this->prompts);
    }

    /**
     * Compute a stable hash for a collection
     */
    private function computeHash(array $collection): string
    {
        if (empty($collection)) {
            return '';
        }

        ksort($collection);
        return md5(json_encode($collection));
    }

    public function load(): void
    {
        if ($this->cache === null) {
            return;
        }

        $this->clear(false);

        try {
            $cached = $this->cache->get(self::DISCOVERED_ELEMENTS_CACHE_KEY);

            if (!is_array($cached)) {
                $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]);
                return;
            }

            $loadCount = 0;

            foreach ($cached['tools'] ?? [] as $toolData) {
                $cachedTool = RegisteredTool::fromArray(json_decode($toolData, true));
                if ($cachedTool === false) {
                    $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]);
                    continue;
                }

                $toolName = $cachedTool->schema->name;
                $existingTool = $this->tools[$toolName] ?? null;

                if ($existingTool && $existingTool->isManual) {
                    $this->logger->debug("Skipping cached tool '{$toolName}' as manual version exists.");
                    continue;
                }

                $this->tools[$toolName] = $cachedTool;
                $loadCount++;
            }

            foreach ($cached['resources'] ?? [] as $resourceData) {
                $cachedResource = RegisteredResource::fromArray(json_decode($resourceData, true));
                if ($cachedResource === false) {
                    $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]);
                    continue;
                }

                $uri = $cachedResource->schema->uri;
                $existingResource = $this->resources[$uri] ?? null;

                if ($existingResource && $existingResource->isManual) {
                    $this->logger->debug("Skipping cached resource '{$uri}' as manual version exists.");
                    continue;
                }

                $this->resources[$uri] = $cachedResource;
                $loadCount++;
            }

            foreach ($cached['prompts'] ?? [] as $promptData) {
                $cachedPrompt = RegisteredPrompt::fromArray(json_decode($promptData, true));
                if ($cachedPrompt === false) {
                    $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]);
                    continue;
                }

                $promptName = $cachedPrompt->schema->name;
                $existingPrompt = $this->prompts[$promptName] ?? null;

                if ($existingPrompt && $existingPrompt->isManual) {
                    $this->logger->debug("Skipping cached prompt '{$promptName}' as manual version exists.");
                    continue;
                }

                $this->prompts[$promptName] = $cachedPrompt;
                $loadCount++;
            }

            foreach ($cached['resourceTemplates'] ?? [] as $templateData) {
                $cachedTemplate = RegisteredResourceTemplate::fromArray(json_decode($templateData, true));
                if ($cachedTemplate === false) {
                    $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]);
                    continue;
                }

                $uriTemplate = $cachedTemplate->schema->uriTemplate;
                $existingTemplate = $this->resourceTemplates[$uriTemplate] ?? null;

                if ($existingTemplate && $existingTemplate->isManual) {
                    $this->logger->debug("Skipping cached template '{$uriTemplate}' as manual version exists.");
                    continue;
                }

                $this->resourceTemplates[$uriTemplate] = $cachedTemplate;
                $loadCount++;
            }

            $this->logger->debug("Loaded {$loadCount} elements from cache.");
        } catch (CacheInvalidArgumentException $e) {
            $this->logger->error('Invalid registry cache key used.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]);
        } catch (Throwable $e) {
            $this->logger->error('Unexpected error loading from cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]);
        }
    }

    public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void
    {
        $toolName = $tool->name;
        $existing = $this->tools[$toolName] ?? null;

        if ($existing && ! $isManual && $existing->isManual) {
            $this->logger->debug("Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one.");

            return;
        }

        $this->tools[$toolName] = RegisteredTool::make($tool, $handler, $isManual);

        $this->checkAndEmitChange('tools', $this->tools);
    }

    public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void
    {
        $uri = $resource->uri;
        $existing = $this->resources[$uri] ?? null;

        if ($existing && ! $isManual && $existing->isManual) {
            $this->logger->debug("Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one.");

            return;
        }

        $this->resources[$uri] = RegisteredResource::make($resource, $handler, $isManual);

        $this->checkAndEmitChange('resources', $this->resources);
    }

    public function registerResourceTemplate(
        ResourceTemplate $template,
        callable|array|string $handler,
        array $completionProviders = [],
        bool $isManual = false,
    ): void {
        $uriTemplate = $template->uriTemplate;
        $existing = $this->resourceTemplates[$uriTemplate] ?? null;

        if ($existing && ! $isManual && $existing->isManual) {
            $this->logger->debug("Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one.");

            return;
        }

        $this->resourceTemplates[$uriTemplate] = RegisteredResourceTemplate::make($template, $handler, $isManual, $completionProviders);

        $this->checkAndEmitChange('resource_templates', $this->resourceTemplates);
    }

    public function registerPrompt(
        Prompt $prompt,
        callable|array|string $handler,
        array $completionProviders = [],
        bool $isManual = false,
    ): void {
        $promptName = $prompt->name;
        $existing = $this->prompts[$promptName] ?? null;

        if ($existing && ! $isManual && $existing->isManual) {
            $this->logger->debug("Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one.");

            return;
        }

        $this->prompts[$promptName] = RegisteredPrompt::make($prompt, $handler, $isManual, $completionProviders);

        $this->checkAndEmitChange('prompts', $this->prompts);
    }

    public function enableNotifications(): void
    {
        $this->notificationsEnabled = true;
    }

    public function disableNotifications(): void
    {
        $this->notificationsEnabled = false;
    }

    /**
     * Check if a list has changed and emit event if needed
     */
    private function checkAndEmitChange(string $listType, array $collection): void
    {
        if (! $this->notificationsEnabled) {
            return;
        }

        $newHash = $this->computeHash($collection);

        if ($newHash !== $this->listHashes[$listType]) {
            $this->listHashes[$listType] = $newHash;
            $this->emit('list_changed', [$listType]);
        }
    }

    public function save(): bool
    {
        if ($this->cache === null) {
            return false;
        }

        $discoveredData = [
            'tools' => [],
            'resources' => [],
            'prompts' => [],
            'resourceTemplates' => [],
        ];

        foreach ($this->tools as $name => $tool) {
            if (! $tool->isManual) {
                if ($tool->handler instanceof \Closure) {
                    $this->logger->warning("Skipping closure tool from cache: {$name}");
                    continue;
                }
                $discoveredData['tools'][$name] = json_encode($tool);
            }
        }

        foreach ($this->resources as $uri => $resource) {
            if (! $resource->isManual) {
                if ($resource->handler instanceof \Closure) {
                    $this->logger->warning("Skipping closure resource from cache: {$uri}");
                    continue;
                }
                $discoveredData['resources'][$uri] = json_encode($resource);
            }
        }

        foreach ($this->prompts as $name => $prompt) {
            if (! $prompt->isManual) {
                if ($prompt->handler instanceof \Closure) {
                    $this->logger->warning("Skipping closure prompt from cache: {$name}");
                    continue;
                }
                $discoveredData['prompts'][$name] = json_encode($prompt);
            }
        }

        foreach ($this->resourceTemplates as $uriTemplate => $template) {
            if (! $template->isManual) {
                if ($template->handler instanceof \Closure) {
                    $this->logger->warning("Skipping closure template from cache: {$uriTemplate}");
                    continue;
                }
                $discoveredData['resourceTemplates'][$uriTemplate] = json_encode($template);
            }
        }

        try {
            $success = $this->cache->set(self::DISCOVERED_ELEMENTS_CACHE_KEY, $discoveredData);

            if ($success) {
                $this->logger->debug('Registry elements saved to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]);
            } else {
                $this->logger->warning('Registry cache set operation returned false.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]);
            }

            return $success;
        } catch (CacheInvalidArgumentException $e) {
            $this->logger->error('Invalid cache key or value during save.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]);

            return false;
        } catch (Throwable $e) {
            $this->logger->error('Unexpected error saving to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]);

            return false;
        }
    }

    /** Checks if any elements (manual or discovered) are currently registered. */
    public function hasElements(): bool
    {
        return ! empty($this->tools)
            || ! empty($this->resources)
            || ! empty($this->prompts)
            || ! empty($this->resourceTemplates);
    }

    /**
     * Clear discovered elements from registry
     *
     * @param bool $includeCache Whether to clear the cache as well (default: true)
     */
    public function clear(bool $includeCache = true): void
    {
        if ($includeCache && $this->cache !== null) {
            try {
                $this->cache->delete(self::DISCOVERED_ELEMENTS_CACHE_KEY);
                $this->logger->debug('Registry cache cleared.');
            } catch (Throwable $e) {
                $this->logger->error('Error clearing registry cache.', ['exception' => $e]);
            }
        }

        $clearCount = 0;

        foreach ($this->tools as $name => $tool) {
            if (! $tool->isManual) {
                unset($this->tools[$name]);
                $clearCount++;
            }
        }
        foreach ($this->resources as $uri => $resource) {
            if (! $resource->isManual) {
                unset($this->resources[$uri]);
                $clearCount++;
            }
        }
        foreach ($this->prompts as $name => $prompt) {
            if (! $prompt->isManual) {
                unset($this->prompts[$name]);
                $clearCount++;
            }
        }
        foreach ($this->resourceTemplates as $uriTemplate => $template) {
            if (! $template->isManual) {
                unset($this->resourceTemplates[$uriTemplate]);
                $clearCount++;
            }
        }

        if ($clearCount > 0) {
            $this->logger->debug("Removed {$clearCount} discovered elements from internal registry.");
        }
    }

    /** @return RegisteredTool|null */
    public function getTool(string $name): ?RegisteredTool
    {
        return $this->tools[$name] ?? null;
    }

    /** @return RegisteredResource|RegisteredResourceTemplate|null */
    public function getResource(string $uri, bool $includeTemplates = true): RegisteredResource|RegisteredResourceTemplate|null
    {
        $registration = $this->resources[$uri] ?? null;
        if ($registration) {
            return $registration;
        }

        if (! $includeTemplates) {
            return null;
        }

        foreach ($this->resourceTemplates as $template) {
            if ($template->matches($uri)) {
                return $template;
            }
        }

        $this->logger->debug('No resource matched URI.', ['uri' => $uri]);

        return null;
    }

    /** @return RegisteredResourceTemplate|null */
    public function getResourceTemplate(string $uriTemplate): ?RegisteredResourceTemplate
    {
        return $this->resourceTemplates[$uriTemplate] ?? null;
    }

    /** @return RegisteredPrompt|null */
    public function getPrompt(string $name): ?RegisteredPrompt
    {
        return $this->prompts[$name] ?? null;
    }

    /** @return array<string, Tool> */
    public function getTools(): array
    {
        return array_map(fn($tool) => $tool->schema, $this->tools);
    }

    /** @return array<string, Resource> */
    public function getResources(): array
    {
        return array_map(fn($resource) => $resource->schema, $this->resources);
    }

    /** @return array<string, Prompt> */
    public function getPrompts(): array
    {
        return array_map(fn($prompt) => $prompt->schema, $this->prompts);
    }

    /** @return array<string, ResourceTemplate> */
    public function getResourceTemplates(): array
    {
        return array_map(fn($template) => $template->schema, $this->resourceTemplates);
    }
}

<?php

namespace PhpMcp\Server\Defaults;

use DateInterval;
use DateTime;
use Psr\SimpleCache\CacheInterface;

/**
 * Very basic PSR-16 array cache implementation (not for production).
 */
class ArrayCache implements CacheInterface
{
    private array $store = [];

    private array $expiries = [];

    public function get(string $key, mixed $default = null): mixed
    {
        if (! $this->has($key)) {
            return $default;
        }

        return $this->store[$key];
    }

    public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool
    {
        $this->store[$key] = $value;
        $this->expiries[$key] = $this->calculateExpiry($ttl);

        return true;
    }

    public function delete(string $key): bool
    {
        unset($this->store[$key], $this->expiries[$key]);

        return true;
    }

    public function clear(): bool
    {
        $this->store = [];
        $this->expiries = [];

        return true;
    }

    public function getMultiple(iterable $keys, mixed $default = null): iterable
    {
        $result = [];
        foreach ($keys as $key) {
            $result[$key] = $this->get($key, $default);
        }

        return $result;
    }

    public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool
    {
        $expiry = $this->calculateExpiry($ttl);
        foreach ($values as $key => $value) {
            $this->store[$key] = $value;
            $this->expiries[$key] = $expiry;
        }

        return true;
    }

    public function deleteMultiple(iterable $keys): bool
    {
        foreach ($keys as $key) {
            unset($this->store[$key], $this->expiries[$key]);
        }

        return true;
    }

    public function has(string $key): bool
    {
        if (! isset($this->store[$key])) {
            return false;
        }
        // Check expiry
        if (isset($this->expiries[$key]) && $this->expiries[$key] !== null && time() >= $this->expiries[$key]) {
            $this->delete($key);

            return false;
        }

        return true;
    }

    private function calculateExpiry(DateInterval|int|null $ttl): ?int
    {
        if ($ttl === null) {
            return null; // No expiry
        }
        if (is_int($ttl)) {
            return time() + $ttl;
        }
        if ($ttl instanceof DateInterval) {
            return (new DateTime())->add($ttl)->getTimestamp();
        }

        // Invalid TTL type, treat as no expiry
        return null;
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Elements;

use PhpMcp\Schema\Content\BlobResourceContents;
use PhpMcp\Schema\Content\EmbeddedResource;
use PhpMcp\Schema\Content\ResourceContents;
use PhpMcp\Schema\Content\TextResourceContents;
use PhpMcp\Schema\ResourceTemplate;
use PhpMcp\Schema\Result\CompletionCompleteResult;
use PhpMcp\Server\Context;
use PhpMcp\Server\Contracts\SessionInterface;
use Psr\Container\ContainerInterface;
use Throwable;

class RegisteredResourceTemplate extends RegisteredElement
{
    protected array $variableNames;
    protected array $uriVariables;
    protected string $uriTemplateRegex;

    public function __construct(
        public readonly ResourceTemplate $schema,
        callable|array|string $handler,
        bool $isManual = false,
        public readonly array $completionProviders = []
    ) {
        parent::__construct($handler, $isManual);

        $this->compileTemplate();
    }

    public static function make(ResourceTemplate $schema, callable|array|string $handler, bool $isManual = false, array $completionProviders = []): self
    {
        return new self($schema, $handler, $isManual, $completionProviders);
    }

    /**
     * Gets the resource template.
     *
     * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
     */
    public function read(ContainerInterface $container, string $uri, Context $context): array
    {
        $arguments = array_merge($this->uriVariables, ['uri' => $uri]);

        $result = $this->handle($container, $arguments, $context);

        return $this->formatResult($result, $uri, $this->schema->mimeType);
    }

    public function complete(ContainerInterface $container, string $argument, string $value, SessionInterface $session): CompletionCompleteResult
    {
        $providerClassOrInstance = $this->completionProviders[$argument] ?? null;
        if ($providerClassOrInstance === null) {
            return new CompletionCompleteResult([]);
        }

        if (is_string($providerClassOrInstance)) {
            if (! class_exists($providerClassOrInstance)) {
                throw new \RuntimeException("Completion provider class '{$providerClassOrInstance}' does not exist.");
            }

            $provider = $container->get($providerClassOrInstance);
        } else {
            $provider = $providerClassOrInstance;
        }

        $completions = $provider->getCompletions($value, $session);

        $total = count($completions);
        $hasMore = $total > 100;

        $pagedCompletions = array_slice($completions, 0, 100);

        return new CompletionCompleteResult($pagedCompletions, $total, $hasMore);
    }

    public function getVariableNames(): array
    {
        return $this->variableNames;
    }

    public function matches(string $uri): bool
    {
        if (preg_match($this->uriTemplateRegex, $uri, $matches)) {
            $variables = [];
            foreach ($this->variableNames as $varName) {
                if (isset($matches[$varName])) {
                    $variables[$varName] = $matches[$varName];
                }
            }

            $this->uriVariables = $variables;

            return true;
        }

        return false;
    }

    private function compileTemplate(): void
    {
        $this->variableNames = [];
        $regexParts = [];

        $segments = preg_split('/(\{\w+\})/', $this->schema->uriTemplate, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);

        foreach ($segments as $segment) {
            if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) {
                $varName = $matches[1];
                $this->variableNames[] = $varName;
                $regexParts[] = '(?P<' . $varName . '>[^/]+)';
            } else {
                $regexParts[] = preg_quote($segment, '#');
            }
        }

        $this->uriTemplateRegex = '#^' . implode('', $regexParts) . '$#';
    }

    /**
     * Formats the raw result of a resource read operation into MCP ResourceContent items.
     *
     * @param  mixed  $readResult  The raw result from the resource handler method.
     * @param  string  $uri  The URI of the resource that was read.
     * @param  ?string  $defaultMimeType  The default MIME type from the ResourceDefinition.
     * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
     *
     * @throws \RuntimeException If the result cannot be formatted.
     *
     * Supported result types:
     * - ResourceContent: Used as-is
     * - EmbeddedResource: Resource is extracted from the EmbeddedResource
     * - string: Converted to text content with guessed or provided MIME type
     * - stream resource: Read and converted to blob with provided MIME type
     * - array with 'blob' key: Used as blob content
     * - array with 'text' key: Used as text content
     * - SplFileInfo: Read and converted to blob
     * - array: Converted to JSON if MIME type is application/json or contains 'json'
     *          For other MIME types, will try to convert to JSON with a warning
     */
    protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array
    {
        if ($readResult instanceof ResourceContents) {
            return [$readResult];
        }

        if ($readResult instanceof EmbeddedResource) {
            return [$readResult->resource];
        }

        if (is_array($readResult)) {
            if (empty($readResult)) {
                return [TextResourceContents::make($uri, 'application/json', '[]')];
            }

            $allAreResourceContents = true;
            $hasResourceContents = false;
            $allAreEmbeddedResource = true;
            $hasEmbeddedResource = false;

            foreach ($readResult as $item) {
                if ($item instanceof ResourceContents) {
                    $hasResourceContents = true;
                    $allAreEmbeddedResource = false;
                } elseif ($item instanceof EmbeddedResource) {
                    $hasEmbeddedResource = true;
                    $allAreResourceContents = false;
                } else {
                    $allAreResourceContents = false;
                    $allAreEmbeddedResource = false;
                }
            }

            if ($allAreResourceContents && $hasResourceContents) {
                return $readResult;
            }

            if ($allAreEmbeddedResource && $hasEmbeddedResource) {
                return array_map(fn($item) => $item->resource, $readResult);
            }

            if ($hasResourceContents || $hasEmbeddedResource) {
                $result = [];
                foreach ($readResult as $item) {
                    if ($item instanceof ResourceContents) {
                        $result[] = $item;
                    } elseif ($item instanceof EmbeddedResource) {
                        $result[] = $item->resource;
                    } else {
                        $result = array_merge($result, $this->formatResult($item, $uri, $mimeType));
                    }
                }
                return $result;
            }
        }

        if (is_string($readResult)) {
            $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult);

            return [TextResourceContents::make($uri, $mimeType, $readResult)];
        }

        if (is_resource($readResult) && get_resource_type($readResult) === 'stream') {
            $result = BlobResourceContents::fromStream(
                $uri,
                $readResult,
                $mimeType ?? 'application/octet-stream'
            );

            @fclose($readResult);

            return [$result];
        }

        if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) {
            $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream';

            return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])];
        }

        if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) {
            $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain';

            return [TextResourceContents::make($uri, $mimeType, $readResult['text'])];
        }

        if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) {
            if ($mimeType && str_contains(strtolower($mimeType), 'text')) {
                return [TextResourceContents::make($uri, $mimeType, file_get_contents($readResult->getPathname()))];
            }

            return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)];
        }

        if (is_array($readResult)) {
            if ($mimeType && (str_contains(strtolower($mimeType), 'json') ||
                $mimeType === 'application/json')) {
                try {
                    $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);

                    return [TextResourceContents::make($uri, $mimeType, $jsonString)];
                } catch (\JsonException $e) {
                    throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
                }
            }

            try {
                $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
                $mimeType = $mimeType ?? 'application/json';

                return [TextResourceContents::make($uri, $mimeType, $jsonString)];
            } catch (\JsonException $e) {
                throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
            }
        }

        throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult));
    }

    /** Guesses MIME type from string content (very basic) */
    private function guessMimeTypeFromString(string $content): string
    {
        $trimmed = ltrim($content);

        if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) {
            if (str_contains($trimmed, '<html')) {
                return 'text/html';
            }
            if (str_contains($trimmed, '<?xml')) {
                return 'application/xml';
            }

            return 'text/plain';
        }

        if (str_starts_with($trimmed, '{') && str_ends_with(rtrim($content), '}')) {
            return 'application/json';
        }

        if (str_starts_with($trimmed, '[') && str_ends_with(rtrim($content), ']')) {
            return 'application/json';
        }

        return 'text/plain';
    }

    public function toArray(): array
    {
        $completionProviders = [];
        foreach ($this->completionProviders as $argument => $provider) {
            $completionProviders[$argument] = serialize($provider);
        }

        return [
            'schema' => $this->schema->toArray(),
            'completionProviders' => $completionProviders,
            ...parent::toArray(),
        ];
    }

    public static function fromArray(array $data): self|false
    {
        try {
            if (! isset($data['schema']) || ! isset($data['handler'])) {
                return false;
            }

            $completionProviders = [];
            foreach ($data['completionProviders'] ?? [] as $argument => $provider) {
                $completionProviders[$argument] = unserialize($provider);
            }

            return new self(
                ResourceTemplate::fromArray($data['schema']),
                $data['handler'],
                $data['isManual'] ?? false,
                $completionProviders,
            );
        } catch (Throwable $e) {
            return false;
        }
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Defaults;

use DateTimeImmutable;
use Psr\Clock\ClockInterface;

class SystemClock implements ClockInterface
{
    public function now(): \DateTimeImmutable
    {
        return new \DateTimeImmutable();
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Contracts;

use JsonSerializable;

interface SessionInterface extends JsonSerializable
{
    /**
     * Get the session ID.
     */
    public function getId(): string;

    /**
     * Save the session.
     */
    public function save(): void;

    /**
     * Get a specific attribute from the session.
     * Supports dot notation for nested access.
     */
    public function get(string $key, mixed $default = null): mixed;

    /**
     * Set a specific attribute in the session.
     * Supports dot notation for nested access.
     */
    public function set(string $key, mixed $value, bool $overwrite = true): void;

    /**
     * Check if an attribute exists in the session.
     * Supports dot notation for nested access.
     */
    public function has(string $key): bool;

    /**
     * Remove an attribute from the session.
     * Supports dot notation for nested access.
     */
    public function forget(string $key): void;

    /**
     * Remove all attributes from the session.
     */
    public function clear(): void;

    /**
     * Get an attribute's value and then remove it from the session.
     * Supports dot notation for nested access.
     */
    public function pull(string $key, mixed $default = null): mixed;

    /**
     * Get all attributes of the session.
     */
    public function all(): array;

    /**
     * Set all attributes of the session, typically for hydration.
     * This will overwrite existing attributes.
     */
    public function hydrate(array $attributes): void;

    /**
     * Add a message to the session's queue.
     */
    public function queueMessage(string $message): void;

    /**
     * Retrieve and remove all messages from the queue.
     * @return array<string>
     */
    public function dequeueMessages(): array;

    /**
     * Check if there are any messages in the queue.
     */
    public function hasQueuedMessages(): bool;

    /**
     * Get the session handler instance.
     *
     * @return SessionHandlerInterface
     */
    public function getHandler(): SessionHandlerInterface;
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Elements;

use PhpMcp\Schema\Content\AudioContent;
use PhpMcp\Schema\Content\BlobResourceContents;
use PhpMcp\Schema\Content\Content;
use PhpMcp\Schema\Content\EmbeddedResource;
use PhpMcp\Schema\Content\ImageContent;
use PhpMcp\Schema\Prompt;
use PhpMcp\Schema\Content\PromptMessage;
use PhpMcp\Schema\Content\TextContent;
use PhpMcp\Schema\Content\TextResourceContents;
use PhpMcp\Schema\Enum\Role;
use PhpMcp\Schema\Result\CompletionCompleteResult;
use PhpMcp\Server\Context;
use PhpMcp\Server\Contracts\CompletionProviderInterface;
use PhpMcp\Server\Contracts\SessionInterface;
use Psr\Container\ContainerInterface;
use Throwable;

class RegisteredPrompt extends RegisteredElement
{
    public function __construct(
        public readonly Prompt $schema,
        callable|array|string $handler,
        bool $isManual = false,
        public readonly array $completionProviders = []
    ) {
        parent::__construct($handler, $isManual);
    }

    public static function make(Prompt $schema, callable|array|string $handler, bool $isManual = false, array $completionProviders = []): self
    {
        return new self($schema, $handler, $isManual, $completionProviders);
    }

    /**
     * Gets the prompt messages.
     *
     * @param  ContainerInterface  $container
     * @param  array  $arguments
     * @return PromptMessage[]
     */
    public function get(ContainerInterface $container, array $arguments, Context $context): array
    {
        $result = $this->handle($container, $arguments, $context);

        return $this->formatResult($result);
    }

    public function complete(ContainerInterface $container, string $argument, string $value, SessionInterface $session): CompletionCompleteResult
    {
        $providerClassOrInstance = $this->completionProviders[$argument] ?? null;
        if ($providerClassOrInstance === null) {
            return new CompletionCompleteResult([]);
        }

        if (is_string($providerClassOrInstance)) {
            if (! class_exists($providerClassOrInstance)) {
                throw new \RuntimeException("Completion provider class '{$providerClassOrInstance}' does not exist.");
            }

            $provider = $container->get($providerClassOrInstance);
        } else {
            $provider = $providerClassOrInstance;
        }

        $completions = $provider->getCompletions($value, $session);

        $total = count($completions);
        $hasMore = $total > 100;

        $pagedCompletions = array_slice($completions, 0, 100);

        return new CompletionCompleteResult($pagedCompletions, $total, $hasMore);
    }

    /**
     * Formats the raw result of a prompt generator into an array of MCP PromptMessages.
     *
     * @param  mixed  $promptGenerationResult  Expected: array of message structures.
     * @return PromptMessage[] Array of PromptMessage objects.
     *
     * @throws \RuntimeException If the result cannot be formatted.
     * @throws \JsonException If JSON encoding fails.
     */
    protected function formatResult(mixed $promptGenerationResult): array
    {
        if ($promptGenerationResult instanceof PromptMessage) {
            return [$promptGenerationResult];
        }

        if (! is_array($promptGenerationResult)) {
            throw new \RuntimeException('Prompt generator method must return an array of messages.');
        }

        if (empty($promptGenerationResult)) {
            return [];
        }

        if (is_array($promptGenerationResult)) {
            $allArePromptMessages = true;
            $hasPromptMessages = false;

            foreach ($promptGenerationResult as $item) {
                if ($item instanceof PromptMessage) {
                    $hasPromptMessages = true;
                } else {
                    $allArePromptMessages = false;
                }
            }

            if ($allArePromptMessages && $hasPromptMessages) {
                return $promptGenerationResult;
            }

            if ($hasPromptMessages) {
                $result = [];
                foreach ($promptGenerationResult as $index => $item) {
                    if ($item instanceof PromptMessage) {
                        $result[] = $item;
                    } else {
                        $result = array_merge($result, $this->formatResult($item));
                    }
                }
                return $result;
            }

            if (! array_is_list($promptGenerationResult)) {
                if (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) {
                    $result = [];
                    if (isset($promptGenerationResult['user'])) {
                        $userContent = $this->formatContent($promptGenerationResult['user']);
                        $result[] = PromptMessage::make(Role::User, $userContent);
                    }
                    if (isset($promptGenerationResult['assistant'])) {
                        $assistantContent = $this->formatContent($promptGenerationResult['assistant']);
                        $result[] = PromptMessage::make(Role::Assistant, $assistantContent);
                    }
                    return $result;
                }

                if (isset($promptGenerationResult['role']) && isset($promptGenerationResult['content'])) {
                    return [$this->formatMessage($promptGenerationResult)];
                }

                throw new \RuntimeException('Associative array must contain either role/content keys or user/assistant keys.');
            }

            $formattedMessages = [];
            foreach ($promptGenerationResult as $index => $message) {
                if ($message instanceof PromptMessage) {
                    $formattedMessages[] = $message;
                } else {
                    $formattedMessages[] = $this->formatMessage($message, $index);
                }
            }
            return $formattedMessages;
        }

        throw new \RuntimeException('Invalid prompt generation result format.');
    }

    /**
     * Formats a single message into a PromptMessage.
     */
    private function formatMessage(mixed $message, ?int $index = null): PromptMessage
    {
        $indexStr = $index !== null ? " at index {$index}" : '';

        if (! is_array($message) || ! array_key_exists('role', $message) || ! array_key_exists('content', $message)) {
            throw new \RuntimeException("Invalid message format{$indexStr}. Expected an array with 'role' and 'content' keys.");
        }

        $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']);
        if ($role === null) {
            throw new \RuntimeException("Invalid role '{$message['role']}' in prompt message{$indexStr}. Only 'user' or 'assistant' are supported.");
        }

        $content = $this->formatContent($message['content'], $index);

        return new PromptMessage($role, $content);
    }

    /**
     * Formats content into a proper Content object.
     */
    private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource
    {
        $indexStr = $index !== null ? " at index {$index}" : '';

        if ($content instanceof Content) {
            if (
                $content instanceof TextContent || $content instanceof ImageContent ||
                $content instanceof AudioContent || $content instanceof EmbeddedResource
            ) {
                return $content;
            }
            throw new \RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource.");
        }

        if (is_string($content)) {
            return TextContent::make($content);
        }

        if (is_array($content) && isset($content['type'])) {
            return $this->formatTypedContent($content, $index);
        }

        if (is_scalar($content) || $content === null) {
            $stringContent = $content === null ? '(null)' : (is_bool($content) ? ($content ? 'true' : 'false') : (string)$content);
            return TextContent::make($stringContent);
        }

        $jsonContent = json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
        return TextContent::make($jsonContent);
    }

    /**
     * Formats typed content arrays into Content objects.
     */
    private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource
    {
        $indexStr = $index !== null ? " at index {$index}" : '';
        $type = $content['type'];

        return match ($type) {
            'text' => $this->formatTextContent($content, $indexStr),
            'image' => $this->formatImageContent($content, $indexStr),
            'audio' => $this->formatAudioContent($content, $indexStr),
            'resource' => $this->formatResourceContent($content, $indexStr),
            default => throw new \RuntimeException("Invalid content type '{$type}'{$indexStr}.")
        };
    }

    private function formatTextContent(array $content, string $indexStr): TextContent
    {
        if (! isset($content['text']) || ! is_string($content['text'])) {
            throw new \RuntimeException("Invalid 'text' content{$indexStr}: Missing or invalid 'text' string.");
        }
        return TextContent::make($content['text']);
    }

    private function formatImageContent(array $content, string $indexStr): ImageContent
    {
        if (! isset($content['data']) || ! is_string($content['data'])) {
            throw new \RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'data' string (base64).");
        }
        if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) {
            throw new \RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'mimeType' string.");
        }
        return ImageContent::make($content['data'], $content['mimeType']);
    }

    private function formatAudioContent(array $content, string $indexStr): AudioContent
    {
        if (! isset($content['data']) || ! is_string($content['data'])) {
            throw new \RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'data' string (base64).");
        }
        if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) {
            throw new \RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'mimeType' string.");
        }
        return AudioContent::make($content['data'], $content['mimeType']);
    }

    private function formatResourceContent(array $content, string $indexStr): EmbeddedResource
    {
        if (! isset($content['resource']) || ! is_array($content['resource'])) {
            throw new \RuntimeException("Invalid 'resource' content{$indexStr}: Missing or invalid 'resource' object.");
        }

        $resource = $content['resource'];
        if (! isset($resource['uri']) || ! is_string($resource['uri'])) {
            throw new \RuntimeException("Invalid resource{$indexStr}: Missing or invalid 'uri'.");
        }

        if (isset($resource['text']) && is_string($resource['text'])) {
            $resourceObj = TextResourceContents::make($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']);
        } elseif (isset($resource['blob']) && is_string($resource['blob'])) {
            $resourceObj = BlobResourceContents::make(
                $resource['uri'],
                $resource['mimeType'] ?? 'application/octet-stream',
                $resource['blob']
            );
        } else {
            throw new \RuntimeException("Invalid resource{$indexStr}: Must contain 'text' or 'blob'.");
        }

        return new EmbeddedResource($resourceObj);
    }

    public function toArray(): array
    {
        $completionProviders = [];
        foreach ($this->completionProviders as $argument => $provider) {
            $completionProviders[$argument] = serialize($provider);
        }

        return [
            'schema' => $this->schema->toArray(),
            'completionProviders' => $completionProviders,
            ...parent::toArray(),
        ];
    }

    public static function fromArray(array $data): self|false
    {
        try {
            if (! isset($data['schema']) || ! isset($data['handler'])) {
                return false;
            }

            $completionProviders = [];
            foreach ($data['completionProviders'] ?? [] as $argument => $provider) {
                $completionProviders[$argument] = unserialize($provider);
            }

            return new self(
                Prompt::fromArray($data['schema']),
                $data['handler'],
                $data['isManual'] ?? false,
                $completionProviders,
            );
        } catch (Throwable $e) {
            return false;
        }
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Contracts;

interface SessionHandlerInterface
{
    /**
     * Read session data
     *
     * Returns an encoded string of the read data.
     * If nothing was read, it must return false.
     * @param string $id The session id to read data for.
     */
    public function read(string $id): string|false;

    /**
     * Write session data
     * @param string $id The session id.
     * @param string $data The encoded session data.
     */
    public function write(string $id, string $data): bool;

    /**
     * Destroy a session
     * @param string $id The session ID being destroyed.
     * The return value (usually TRUE on success, FALSE on failure).
     */
    public function destroy(string $id): bool;

    /**
     * Cleanup old sessions
     * Sessions that have not updated for
     * the last maxlifetime seconds will be removed.
     */
    public function gc(int $maxLifetime): array;
}

<?php

namespace PhpMcp\Server\Utils;

use InvalidArgumentException;
use JsonException;
use Opis\JsonSchema\Errors\ValidationError;
use Opis\JsonSchema\Validator;
use Psr\Log\LoggerInterface;
use Throwable;

/**
 * Validates data against JSON Schema definitions using opis/json-schema.
 */
class SchemaValidator
{
    private ?Validator $jsonSchemaValidator = null;

    private LoggerInterface $logger;

    public function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    /**
     * Validates data against a JSON schema.
     *
     * @param  mixed  $data  The data to validate (should generally be decoded JSON).
     * @param  array|object  $schema  The JSON Schema definition (as PHP array or object).
     * @return list<array{pointer: string, keyword: string, message: string}> Array of validation errors, empty if valid.
     */
    public function validateAgainstJsonSchema(mixed $data, array|object $schema): array
    {
        if (is_array($data) && empty($data)) {
            $data = new \stdClass();
        }

        try {
            // --- Schema Preparation ---
            if (is_array($schema)) {
                $schemaJson = json_encode($schema, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
                $schemaObject = json_decode($schemaJson, false, 512, JSON_THROW_ON_ERROR);
            } elseif (is_object($schema)) {
                // This might be overly cautious but safer against varied inputs.
                $schemaJson = json_encode($schema, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
                $schemaObject = json_decode($schemaJson, false, 512, JSON_THROW_ON_ERROR);
            } else {
                throw new InvalidArgumentException('Schema must be an array or object.');
            }

            // --- Data Preparation ---
            // Opis Validator generally prefers objects for object validation
            $dataToValidate = $this->convertDataForValidator($data);
        } catch (JsonException $e) {
            $this->logger->error('MCP SDK: Invalid schema structure provided for validation (JSON conversion failed).', ['exception' => $e]);

            return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Invalid schema definition provided (JSON error).']];
        } catch (InvalidArgumentException $e) {
            $this->logger->error('MCP SDK: Invalid schema structure provided for validation.', ['exception' => $e]);

            return [['pointer' => '', 'keyword' => 'internal', 'message' => $e->getMessage()]];
        } catch (Throwable $e) {
            $this->logger->error('MCP SDK: Error preparing data/schema for validation.', ['exception' => $e]);

            return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Internal validation preparation error.']];
        }

        $validator = $this->getJsonSchemaValidator();

        try {
            $result = $validator->validate($dataToValidate, $schemaObject);
        } catch (Throwable $e) {
            $this->logger->error('MCP SDK: JSON Schema validation failed internally.', [
                'exception_message' => $e->getMessage(),
                'exception_trace' => $e->getTraceAsString(),
                'data' => json_encode($dataToValidate),
                'schema' => json_encode($schemaObject),
            ]);

            return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: ' . $e->getMessage()]];
        }

        if ($result->isValid()) {
            return [];
        }

        $formattedErrors = [];
        $topError = $result->error();

        if ($topError) {
            $this->collectSubErrors($topError, $formattedErrors);
        }

        if (empty($formattedErrors) && $topError) { // Fallback
            $formattedErrors[] = [
                'pointer' => $this->formatJsonPointerPath($topError->data()?->path()),
                'keyword' => $topError->keyword(),
                'message' => $this->formatValidationError($topError),
            ];
        }

        return $formattedErrors;
    }

    /**
     * Get or create the JSON Schema validator instance.
     */
    private function getJsonSchemaValidator(): Validator
    {
        if ($this->jsonSchemaValidator === null) {
            $this->jsonSchemaValidator = new Validator();
            // Potentially configure resolver here if needed later
        }

        return $this->jsonSchemaValidator;
    }

    /**
     * Recursively converts associative arrays to stdClass objects for validator compatibility.
     */
    private function convertDataForValidator(mixed $data): mixed
    {
        if (is_array($data)) {
            // Check if it's an associative array (keys are not sequential numbers 0..N-1)
            if (! empty($data) && array_keys($data) !== range(0, count($data) - 1)) {
                $obj = new \stdClass();
                foreach ($data as $key => $value) {
                    $obj->{$key} = $this->convertDataForValidator($value);
                }

                return $obj;
            } else {
                // It's a list (sequential array), convert items recursively
                return array_map([$this, 'convertDataForValidator'], $data);
            }
        } elseif (is_object($data) && $data instanceof \stdClass) {
            // Deep copy/convert stdClass objects as well
            $obj = new \stdClass();
            foreach (get_object_vars($data) as $key => $value) {
                $obj->{$key} = $this->convertDataForValidator($value);
            }

            return $obj;
        }

        // Leave other objects and scalar types as they are
        return $data;
    }

    /**
     * Recursively collects leaf validation errors.
     */
    private function collectSubErrors(ValidationError $error, array &$collectedErrors): void
    {
        $subErrors = $error->subErrors();
        if (empty($subErrors)) {
            $collectedErrors[] = [
                'pointer' => $this->formatJsonPointerPath($error->data()?->path()),
                'keyword' => $error->keyword(),
                'message' => $this->formatValidationError($error),
            ];
        } else {
            foreach ($subErrors as $subError) {
                $this->collectSubErrors($subError, $collectedErrors);
            }
        }
    }

    /**
     * Formats the path array into a JSON Pointer string.
     */
    private function formatJsonPointerPath(?array $pathComponents): string
    {
        if ($pathComponents === null || empty($pathComponents)) {
            return '/';
        }
        $escapedComponents = array_map(function ($component) {
            $componentStr = (string) $component;

            return str_replace(['~', '/'], ['~0', '~1'], $componentStr);
        }, $pathComponents);

        return '/' . implode('/', $escapedComponents);
    }

    /**
     * Formats an Opis SchemaValidationError into a user-friendly message.
     */
    private function formatValidationError(ValidationError $error): string
    {
        $keyword = $error->keyword();
        $args = $error->args();
        $message = "Constraint `{$keyword}` failed.";

        switch (strtolower($keyword)) {
            case 'required':
                $missing = $args['missing'] ?? [];
                $formattedMissing = implode(', ', array_map(fn($p) => "`{$p}`", $missing));
                $message = "Missing required properties: {$formattedMissing}.";
                break;
            case 'type':
                $expected = implode('|', (array) ($args['expected'] ?? []));
                $used = $args['used'] ?? 'unknown';
                $message = "Invalid type. Expected `{$expected}`, but received `{$used}`.";
                break;
            case 'enum':
                $schemaData = $error->schema()?->info()?->data();
                $allowedValues = [];
                if (is_object($schemaData) && property_exists($schemaData, 'enum') && is_array($schemaData->enum)) {
                    $allowedValues = $schemaData->enum;
                } elseif (is_array($schemaData) && isset($schemaData['enum']) && is_array($schemaData['enum'])) {
                    $allowedValues = $schemaData['enum'];
                } else {
                    $this->logger->warning("MCP SDK: Could not retrieve 'enum' values from schema info for error.", ['error_args' => $args]);
                }
                if (empty($allowedValues)) {
                    $message = 'Value does not match the allowed enumeration.';
                } else {
                    $formattedAllowed = array_map(function ($v) { /* ... formatting logic ... */
                        if (is_string($v)) {
                            return '"' . $v . '"';
                        }
                        if (is_bool($v)) {
                            return $v ? 'true' : 'false';
                        }
                        if ($v === null) {
                            return 'null';
                        }

                        return (string) $v;
                    }, $allowedValues);
                    $message = 'Value must be one of the allowed values: ' . implode(', ', $formattedAllowed) . '.';
                }
                break;
            case 'const':
                $expected = json_encode($args['expected'] ?? 'null', JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
                $message = "Value must be equal to the constant value: {$expected}.";
                break;
            case 'minLength': // Corrected casing
                $min = $args['min'] ?? '?';
                $message = "String must be at least {$min} characters long.";
                break;
            case 'maxLength': // Corrected casing
                $max = $args['max'] ?? '?';
                $message = "String must not be longer than {$max} characters.";
                break;
            case 'pattern':
                $pattern = $args['pattern'] ?? '?';
                $message = "String does not match the required pattern: `{$pattern}`.";
                break;
            case 'minimum':
                $min = $args['min'] ?? '?';
                $message = "Number must be greater than or equal to {$min}.";
                break;
            case 'maximum':
                $max = $args['max'] ?? '?';
                $message = "Number must be less than or equal to {$max}.";
                break;
            case 'exclusiveMinimum': // Corrected casing
                $min = $args['min'] ?? '?';
                $message = "Number must be strictly greater than {$min}.";
                break;
            case 'exclusiveMaximum': // Corrected casing
                $max = $args['max'] ?? '?';
                $message = "Number must be strictly less than {$max}.";
                break;
            case 'multipleOf': // Corrected casing
                $value = $args['value'] ?? '?';
                $message = "Number must be a multiple of {$value}.";
                break;
            case 'minItems': // Corrected casing
                $min = $args['min'] ?? '?';
                $message = "Array must contain at least {$min} items.";
                break;
            case 'maxItems': // Corrected casing
                $max = $args['max'] ?? '?';
                $message = "Array must contain no more than {$max} items.";
                break;
            case 'uniqueItems': // Corrected casing
                $message = 'Array items must be unique.';
                break;
            case 'minProperties': // Corrected casing
                $min = $args['min'] ?? '?';
                $message = "Object must have at least {$min} properties.";
                break;
            case 'maxProperties': // Corrected casing
                $max = $args['max'] ?? '?';
                $message = "Object must have no more than {$max} properties.";
                break;
            case 'additionalProperties': // Corrected casing
                $unexpected = $args['properties'] ?? [];
                $formattedUnexpected = implode(', ', array_map(fn($p) => "`{$p}`", $unexpected));
                $message = "Object contains unexpected additional properties: {$formattedUnexpected}.";
                break;
            case 'format':
                $format = $args['format'] ?? 'unknown';
                $message = "Value does not match the required format: `{$format}`.";
                break;
            default:
                $builtInMessage = $error->message();
                if ($builtInMessage && $builtInMessage !== 'The data must match the schema') {
                    $placeholders = $args ?? [];
                    $builtInMessage = preg_replace_callback('/\{(\w+)\}/', function ($match) use ($placeholders) {
                        $key = $match[1];
                        $value = $placeholders[$key] ?? '{' . $key . '}';

                        return is_array($value) ? json_encode($value) : (string) $value;
                    }, $builtInMessage);
                    $message = $builtInMessage;
                }
                break;
        }

        return $message;
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Exception;

use PhpMcp\Schema\JsonRpc\Error as JsonRpcError;

/**
 * Exception related to violations of the JSON-RPC 2.0 or MCP structure
 * in incoming messages or outgoing responses (e.g., missing required fields,
 * invalid types within the protocol itself).
 */
class ProtocolException extends McpServerException
{
    public function toJsonRpcError(string|int $id): JsonRpcError
    {
        $code = ($this->code >= -32700 && $this->code <= -32600) ? $this->code : self::CODE_INVALID_REQUEST;

        return new JsonRpcError(
            jsonrpc: '2.0',
            id: $id,
            code: $code,
            message: $this->getMessage(),
            data: $this->getData()
        );
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Elements;

use InvalidArgumentException;
use JsonSerializable;
use PhpMcp\Server\Context;
use PhpMcp\Server\Exception\McpServerException;
use Psr\Container\ContainerInterface;
use ReflectionException;
use ReflectionFunctionAbstract;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
use Throwable;
use TypeError;

class RegisteredElement implements JsonSerializable
{
    /** @var callable|array|string */
    public readonly mixed $handler;
    public readonly bool $isManual;

    public function __construct(
        callable|array|string $handler,
        bool $isManual = false,
    ) {
        $this->handler = $handler;
        $this->isManual = $isManual;
    }

    public function handle(ContainerInterface $container, array $arguments, Context $context): mixed
    {
        if (is_string($this->handler)) {
            if (class_exists($this->handler) && method_exists($this->handler, '__invoke')) {
                $reflection = new \ReflectionMethod($this->handler, '__invoke');
                $arguments = $this->prepareArguments($reflection, $arguments, $context);
                $instance = $container->get($this->handler);
                return call_user_func($instance, ...$arguments);
            }

            if (function_exists($this->handler)) {
                $reflection = new \ReflectionFunction($this->handler);
                $arguments = $this->prepareArguments($reflection, $arguments, $context);
                return call_user_func($this->handler, ...$arguments);
            }
        }

        if (is_callable($this->handler)) {
            $reflection = $this->getReflectionForCallable($this->handler);
            $arguments = $this->prepareArguments($reflection, $arguments, $context);
            return call_user_func($this->handler, ...$arguments);
        }

        if (is_array($this->handler)) {
            [$className, $methodName] = $this->handler;
            $reflection = new \ReflectionMethod($className, $methodName);
            $arguments = $this->prepareArguments($reflection, $arguments, $context);

            $instance = $container->get($className);
            return call_user_func([$instance, $methodName], ...$arguments);
        }

        throw new \InvalidArgumentException('Invalid handler type');
    }

    protected function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments, Context $context): array
    {
        $finalArgs = [];

        foreach ($reflection->getParameters() as $parameter) {
            // TODO: Handle variadic parameters.
            $paramName = $parameter->getName();
            $paramType = $parameter->getType();
            $paramPosition = $parameter->getPosition();

            if ($paramType instanceof ReflectionNamedType && $paramType->getName() === Context::class) {
                $finalArgs[$paramPosition] = $context;

                continue;
            }

            if (isset($arguments[$paramName])) {
                $argument = $arguments[$paramName];
                try {
                    $finalArgs[$paramPosition] = $this->castArgumentType($argument, $parameter);
                } catch (InvalidArgumentException $e) {
                    throw McpServerException::invalidParams($e->getMessage(), $e);
                } catch (Throwable $e) {
                    throw McpServerException::internalError(
                        "Error processing parameter `{$paramName}`: {$e->getMessage()}",
                        $e
                    );
                }
            } elseif ($parameter->isDefaultValueAvailable()) {
                $finalArgs[$paramPosition] = $parameter->getDefaultValue();
            } elseif ($parameter->allowsNull()) {
                $finalArgs[$paramPosition] = null;
            } elseif ($parameter->isOptional()) {
                continue;
            } else {
                $reflectionName = $reflection instanceof \ReflectionMethod
                    ? $reflection->class . '::' . $reflection->name
                    : 'Closure';
                throw McpServerException::internalError(
                    "Missing required argument `{$paramName}` for {$reflectionName}."
                );
            }
        }

        return array_values($finalArgs);
    }

    /**
     * Gets a ReflectionMethod or ReflectionFunction for a callable.
     */
    private function getReflectionForCallable(callable $handler): \ReflectionMethod|\ReflectionFunction
    {
        if (is_string($handler)) {
            return new \ReflectionFunction($handler);
        }

        if ($handler instanceof \Closure) {
            return new \ReflectionFunction($handler);
        }

        if (is_array($handler) && count($handler) === 2) {
            [$class, $method] = $handler;
            return new \ReflectionMethod($class, $method);
        }

        throw new \InvalidArgumentException('Cannot create reflection for this callable type');
    }

    /**
     * Attempts type casting based on ReflectionParameter type hints.
     *
     * @throws InvalidArgumentException If casting is impossible for the required type.
     * @throws TypeError If internal PHP casting fails unexpectedly.
     */
    private function castArgumentType(mixed $argument, ReflectionParameter $parameter): mixed
    {
        $type = $parameter->getType();

        if ($argument === null) {
            if ($type && $type->allowsNull()) {
                return null;
            }
        }

        if (! $type instanceof ReflectionNamedType) {
            return $argument;
        }

        $typeName = $type->getName();

        if (enum_exists($typeName)) {
            if (is_object($argument) && $argument instanceof $typeName) {
                return $argument;
            }

            if (is_subclass_of($typeName, \BackedEnum::class)) {
                $value = $typeName::tryFrom($argument);
                if ($value === null) {
                    throw new InvalidArgumentException(
                        "Invalid value '{$argument}' for backed enum {$typeName}. Expected one of its backing values.",
                    );
                }
                return $value;
            } else {
                if (is_string($argument)) {
                    foreach ($typeName::cases() as $case) {
                        if ($case->name === $argument) {
                            return $case;
                        }
                    }
                    $validNames = array_map(fn($c) => $c->name, $typeName::cases());
                    throw new InvalidArgumentException(
                        "Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: " . implode(', ', $validNames) . "."
                    );
                } else {
                    throw new InvalidArgumentException(
                        "Invalid value type '{$argument}' for unit enum {$typeName}. Expected a string matching a case name."
                    );
                }
            }
        }

        try {
            return match (strtolower($typeName)) {
                'int', 'integer' => $this->castToInt($argument),
                'string' => (string) $argument,
                'bool', 'boolean' => $this->castToBoolean($argument),
                'float', 'double' => $this->castToFloat($argument),
                'array' => $this->castToArray($argument),
                default => $argument,
            };
        } catch (TypeError $e) {
            throw new InvalidArgumentException(
                "Value cannot be cast to required type `{$typeName}`.",
                0,
                $e
            );
        }
    }

    /** Helper to cast strictly to boolean */
    private function castToBoolean(mixed $argument): bool
    {
        if (is_bool($argument)) {
            return $argument;
        }
        if ($argument === 1 || $argument === '1' || strtolower((string) $argument) === 'true') {
            return true;
        }
        if ($argument === 0 || $argument === '0' || strtolower((string) $argument) === 'false') {
            return false;
        }
        throw new InvalidArgumentException('Cannot cast value to boolean. Use true/false/1/0.');
    }

    /** Helper to cast strictly to integer */
    private function castToInt(mixed $argument): int
    {
        if (is_int($argument)) {
            return $argument;
        }
        if (is_numeric($argument) && floor((float) $argument) == $argument && ! is_string($argument)) {
            return (int) $argument;
        }
        if (is_string($argument) && ctype_digit(ltrim($argument, '-'))) {
            return (int) $argument;
        }
        throw new InvalidArgumentException('Cannot cast value to integer. Expected integer representation.');
    }

    /** Helper to cast strictly to float */
    private function castToFloat(mixed $argument): float
    {
        if (is_float($argument)) {
            return $argument;
        }
        if (is_int($argument)) {
            return (float) $argument;
        }
        if (is_numeric($argument)) {
            return (float) $argument;
        }
        throw new InvalidArgumentException('Cannot cast value to float. Expected numeric representation.');
    }

    /** Helper to cast strictly to array */
    private function castToArray(mixed $argument): array
    {
        if (is_array($argument)) {
            return $argument;
        }
        throw new InvalidArgumentException('Cannot cast value to array. Expected array.');
    }

    public function toArray(): array
    {
        return [
            'handler' => $this->handler,
            'isManual' => $this->isManual,
        ];
    }

    public function jsonSerialize(): array
    {
        return $this->toArray();
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Elements;

use PhpMcp\Schema\Content\Content;
use PhpMcp\Schema\Content\TextContent;
use PhpMcp\Server\Context;
use Psr\Container\ContainerInterface;
use PhpMcp\Schema\Tool;
use Throwable;

class RegisteredTool extends RegisteredElement
{
    public function __construct(
        public readonly Tool $schema,
        callable|array|string $handler,
        bool $isManual = false,
    ) {
        parent::__construct($handler, $isManual);
    }

    public static function make(Tool $schema, callable|array|string $handler, bool $isManual = false): self
    {
        return new self($schema, $handler, $isManual);
    }

    /**
     * Calls the underlying handler for this tool.
     *
     * @return Content[] The content items for CallToolResult.
     */
    public function call(ContainerInterface $container, array $arguments, Context $context): array
    {
        $result = $this->handle($container, $arguments, $context);

        return $this->formatResult($result);
    }

    /**
     * Formats the result of a tool execution into an array of MCP Content items.
     *
     * - If the result is already a Content object, it's wrapped in an array.
     * - If the result is an array:
     *   - If all elements are Content objects, the array is returned as is.
     *   - If it's a mixed array (Content and non-Content items), non-Content items are
     *     individually formatted (scalars to TextContent, others to JSON TextContent).
     *   - If it's an array with no Content items, the entire array is JSON-encoded into a single TextContent.
     * - Scalars (string, int, float, bool) are wrapped in TextContent.
     * - null is represented as TextContent('(null)').
     * - Other objects are JSON-encoded and wrapped in TextContent.
     *
     * @param  mixed  $toolExecutionResult  The raw value returned by the tool's PHP method.
     * @return Content[] The content items for CallToolResult.
     * @throws JsonException if JSON encoding fails for non-Content array/object results.
     */
    protected function formatResult(mixed $toolExecutionResult): array
    {
        if ($toolExecutionResult instanceof Content) {
            return [$toolExecutionResult];
        }

        if (is_array($toolExecutionResult)) {
            if (empty($toolExecutionResult)) {
                return [TextContent::make('[]')];
            }

            $allAreContent = true;
            $hasContent = false;

            foreach ($toolExecutionResult as $item) {
                if ($item instanceof Content) {
                    $hasContent = true;
                } else {
                    $allAreContent = false;
                }
            }

            if ($allAreContent && $hasContent) {
                return $toolExecutionResult;
            }

            if ($hasContent) {
                $result = [];
                foreach ($toolExecutionResult as $item) {
                    if ($item instanceof Content) {
                        $result[] = $item;
                    } else {
                        $result = array_merge($result, $this->formatResult($item));
                    }
                }
                return $result;
            }
        }

        if ($toolExecutionResult === null) {
            return [TextContent::make('(null)')];
        }

        if (is_bool($toolExecutionResult)) {
            return [TextContent::make($toolExecutionResult ? 'true' : 'false')];
        }

        if (is_scalar($toolExecutionResult)) {
            return [TextContent::make($toolExecutionResult)];
        }

        $jsonResult = json_encode(
            $toolExecutionResult,
            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE
        );

        return [TextContent::make($jsonResult)];
    }

    public function toArray(): array
    {
        return [
            'schema' => $this->schema->toArray(),
            ...parent::toArray(),
        ];
    }

    public static function fromArray(array $data): self|false
    {
        try {
            if (! isset($data['schema']) || ! isset($data['handler'])) {
                return false;
            }

            return new self(
                Tool::fromArray($data['schema']),
                $data['handler'],
                $data['isManual'] ?? false,
            );
        } catch (Throwable $e) {
            return false;
        }
    }
}

<?php declare(strict_types = 1);
namespace PhpMcp\Server;

use PhpMcp\Server\Contracts\SessionInterface;
use Psr\Http\Message\ServerRequestInterface;

final class Context
{
    public function __construct(
        public readonly SessionInterface $session,
        public readonly ?ServerRequestInterface $request = null,
    )
    {
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Elements;

use PhpMcp\Schema\Content\BlobResourceContents;
use PhpMcp\Schema\Content\EmbeddedResource;
use PhpMcp\Schema\Content\ResourceContents;
use PhpMcp\Schema\Content\TextResourceContents;
use PhpMcp\Schema\Resource;
use PhpMcp\Server\Context;
use Psr\Container\ContainerInterface;
use Throwable;

class RegisteredResource extends RegisteredElement
{
    public function __construct(
        public readonly Resource $schema,
        callable|array|string $handler,
        bool $isManual = false,
    ) {
        parent::__construct($handler, $isManual);
    }

    public static function make(Resource $schema, callable|array|string $handler, bool $isManual = false): self
    {
        return new self($schema, $handler, $isManual);
    }

    /**
     * Reads the resource content.
     *
     * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
     */
    public function read(ContainerInterface $container, string $uri, Context $context): array
    {
        $result = $this->handle($container, ['uri' => $uri], $context);

        return $this->formatResult($result, $uri, $this->schema->mimeType);
    }

    /**
     * Formats the raw result of a resource read operation into MCP ResourceContent items.
     *
     * @param  mixed  $readResult  The raw result from the resource handler method.
     * @param  string  $uri  The URI of the resource that was read.
     * @param  ?string  $mimeType  The MIME type from the ResourceDefinition.
     * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
     *
     * @throws \RuntimeException If the result cannot be formatted.
     *
     * Supported result types:
     * - ResourceContent: Used as-is
     * - EmbeddedResource: Resource is extracted from the EmbeddedResource
     * - string: Converted to text content with guessed or provided MIME type
     * - stream resource: Read and converted to blob with provided MIME type
     * - array with 'blob' key: Used as blob content
     * - array with 'text' key: Used as text content
     * - SplFileInfo: Read and converted to blob
     * - array: Converted to JSON if MIME type is application/json or contains 'json'
     *          For other MIME types, will try to convert to JSON with a warning
     */
    protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array
    {
        if ($readResult instanceof ResourceContents) {
            return [$readResult];
        }

        if ($readResult instanceof EmbeddedResource) {
            return [$readResult->resource];
        }

        if (is_array($readResult)) {
            if (empty($readResult)) {
                return [TextResourceContents::make($uri, 'application/json', '[]')];
            }

            $allAreResourceContents = true;
            $hasResourceContents = false;
            $allAreEmbeddedResource = true;
            $hasEmbeddedResource = false;

            foreach ($readResult as $item) {
                if ($item instanceof ResourceContents) {
                    $hasResourceContents = true;
                    $allAreEmbeddedResource = false;
                } elseif ($item instanceof EmbeddedResource) {
                    $hasEmbeddedResource = true;
                    $allAreResourceContents = false;
                } else {
                    $allAreResourceContents = false;
                    $allAreEmbeddedResource = false;
                }
            }

            if ($allAreResourceContents && $hasResourceContents) {
                return $readResult;
            }

            if ($allAreEmbeddedResource && $hasEmbeddedResource) {
                return array_map(fn($item) => $item->resource, $readResult);
            }

            if ($hasResourceContents || $hasEmbeddedResource) {
                $result = [];
                foreach ($readResult as $item) {
                    if ($item instanceof ResourceContents) {
                        $result[] = $item;
                    } elseif ($item instanceof EmbeddedResource) {
                        $result[] = $item->resource;
                    } else {
                        $result = array_merge($result, $this->formatResult($item, $uri, $mimeType));
                    }
                }
                return $result;
            }
        }

        if (is_string($readResult)) {
            $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult);

            return [TextResourceContents::make($uri, $mimeType, $readResult)];
        }

        if (is_resource($readResult) && get_resource_type($readResult) === 'stream') {
            $result = BlobResourceContents::fromStream(
                $uri,
                $readResult,
                $mimeType ?? 'application/octet-stream'
            );

            @fclose($readResult);

            return [$result];
        }

        if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) {
            $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream';

            return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])];
        }

        if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) {
            $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain';

            return [TextResourceContents::make($uri, $mimeType, $readResult['text'])];
        }

        if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) {
            if ($mimeType && str_contains(strtolower($mimeType), 'text')) {
                return [TextResourceContents::make($uri, $mimeType, file_get_contents($readResult->getPathname()))];
            }

            return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)];
        }

        if (is_array($readResult)) {
            if ($mimeType && (str_contains(strtolower($mimeType), 'json') ||
                $mimeType === 'application/json')) {
                try {
                    $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);

                    return [TextResourceContents::make($uri, $mimeType, $jsonString)];
                } catch (\JsonException $e) {
                    throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
                }
            }

            try {
                $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
                $mimeType = $mimeType ?? 'application/json';

                return [TextResourceContents::make($uri, $mimeType, $jsonString)];
            } catch (\JsonException $e) {
                throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
            }
        }

        throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult));
    }

    /** Guesses MIME type from string content (very basic) */
    private function guessMimeTypeFromString(string $content): string
    {
        $trimmed = ltrim($content);

        if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) {
            if (str_contains($trimmed, '<html')) {
                return 'text/html';
            }
            if (str_contains($trimmed, '<?xml')) {
                return 'application/xml';
            }

            return 'text/plain';
        }

        if (str_starts_with($trimmed, '{') && str_ends_with(rtrim($content), '}')) {
            return 'application/json';
        }

        if (str_starts_with($trimmed, '[') && str_ends_with(rtrim($content), ']')) {
            return 'application/json';
        }

        return 'text/plain';
    }

    public function toArray(): array
    {
        return [
            'schema' => $this->schema->toArray(),
            ...parent::toArray(),
        ];
    }

    public static function fromArray(array $data): self|false
    {
        try {
            if (! isset($data['schema']) || ! isset($data['handler'])) {
                return false;
            }

            return new self(
                Resource::fromArray($data['schema']),
                $data['handler'],
                $data['isManual'] ?? false,
            );
        } catch (Throwable $e) {
            return false;
        }
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Exception;

use PhpMcp\Schema\JsonRpc\Error as JsonRpcError;

/**
 * Exception related to errors in the underlying transport layer
 * (e.g., socket errors, process management issues, SSE stream errors).
 */
class TransportException extends McpServerException
{
    public function toJsonRpcError(string|int $id): JsonRpcError
    {
        return new JsonRpcError(
            jsonrpc: '2.0',
            id: $id,
            code: JsonRpcError::CODE_INTERNAL_ERROR,
            message: 'Transport layer error: ' . $this->getMessage(),
            data: null
        );
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Contracts;

use React\EventLoop\LoopInterface;

/**
 * Interface for components that require a ReactPHP event loop instance.
 *
 * Primarily used for injecting the configured loop into transport implementations.
 */
interface LoopAwareInterface
{
    public function setLoop(LoopInterface $loop): void;
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Contracts;

interface CompletionProviderInterface
{
    /**
     * Get completions for a given current value.
     *
     * @param  string  $currentValue  The current value to get completions for.
     * @param  SessionInterface  $session  The session to get completions for.
     * @return array  The completions.
     */
    public function getCompletions(string $currentValue, SessionInterface $session): array;
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Attributes;

use Attribute;
use PhpMcp\Server\Contracts\CompletionProviderInterface;

#[Attribute(Attribute::TARGET_PARAMETER)]
class CompletionProvider
{
    /**
     * @param class-string<CompletionProviderInterface>|null $providerClass
     * @param class-string<CompletionProviderInterface>|CompletionProviderInterface|null $provider If a class-string, it will be resolved from the container at the point of use.
     */
    public function __construct(
        public ?string $providerClass = null,
        public string|CompletionProviderInterface|null $provider = null,
        public ?array $values = null,
        public ?string $enum = null,
    ) {
        if (count(array_filter([$provider, $values, $enum])) !== 1) {
            throw new \InvalidArgumentException('Only one of provider, values, or enum can be set');
        }
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Utils;

use InvalidArgumentException;
use ReflectionMethod;
use ReflectionException;

/**
 * Utility class to validate and resolve MCP element handlers.
 */
class HandlerResolver
{
    /**
     * Validates and resolves a handler to a ReflectionMethod or ReflectionFunction instance.
     *
     * A handler can be:
     * - A Closure: function() { ... }
     * - An array: [ClassName::class, 'methodName'] (instance method)
     * - An array: [ClassName::class, 'staticMethod'] (static method, if callable)
     * - A string: InvokableClassName::class (which will resolve to its '__invoke' method)
     *
     * @param \Closure|array|string $handler The handler to resolve.
     * @return \ReflectionMethod|\ReflectionFunction
     *
     * @throws InvalidArgumentException If the handler format is invalid, the class/method doesn't exist,
     *                                  or the method is unsuitable (e.g., private, abstract).
     */
    public static function resolve(\Closure|array|string $handler): \ReflectionMethod|\ReflectionFunction
    {
        // Handle Closures
        if ($handler instanceof \Closure) {
            return new \ReflectionFunction($handler);
        }

        $className = null;
        $methodName = null;

        if (is_array($handler)) {
            if (count($handler) !== 2 || !isset($handler[0]) || !isset($handler[1]) || !is_string($handler[0]) || !is_string($handler[1])) {
                throw new InvalidArgumentException('Invalid array handler format. Expected [ClassName::class, \'methodName\'].');
            }
            [$className, $methodName] = $handler;
            if (!class_exists($className)) {
                throw new InvalidArgumentException("Handler class '{$className}' not found for array handler.");
            }
            if (!method_exists($className, $methodName)) {
                throw new InvalidArgumentException("Handler method '{$methodName}' not found in class '{$className}' for array handler.");
            }
        } elseif (is_string($handler) && class_exists($handler)) {
            $className = $handler;
            $methodName = '__invoke';
            if (!method_exists($className, $methodName)) {
                throw new InvalidArgumentException("Invokable handler class '{$className}' must have a public '__invoke' method.");
            }
        } else {
            throw new InvalidArgumentException('Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.');
        }

        try {
            $reflectionMethod = new ReflectionMethod($className, $methodName);

            // For discovered elements (non-manual), still reject static methods
            // For manual elements, we'll allow static methods since they're callable
            if (!$reflectionMethod->isPublic()) {
                throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' must be public.");
            }
            if ($reflectionMethod->isAbstract()) {
                throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be abstract.");
            }
            if ($reflectionMethod->isConstructor() || $reflectionMethod->isDestructor()) {
                throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be a constructor or destructor.");
            }

            return $reflectionMethod;
        } catch (ReflectionException $e) {
            // This typically occurs if class_exists passed but ReflectionMethod still fails (rare)
            throw new InvalidArgumentException("Reflection error for handler '{$className}::{$methodName}': {$e->getMessage()}", 0, $e);
        }
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server;

use PhpMcp\Schema\Implementation;
use PhpMcp\Schema\ServerCapabilities;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
use React\EventLoop\LoopInterface;

/**
 * Value Object holding core configuration and shared dependencies for the MCP Server instance.
 *
 * This object is typically assembled by the ServerBuilder and passed to the Server constructor.
 */
class Configuration
{
    /**
     * @param  Implementation  $serverInfo  Info about this MCP server application.
     * @param  ServerCapabilities  $capabilities  Capabilities of this MCP server application.
     * @param  LoggerInterface  $logger  PSR-3 Logger instance.
     * @param  LoopInterface  $loop  ReactPHP Event Loop instance.
     * @param  CacheInterface|null  $cache  Optional PSR-16 Cache instance for registry/state.
     * @param  ContainerInterface  $container  PSR-11 DI Container for resolving handlers/dependencies.
     * @param  int  $paginationLimit  Maximum number of items to return for list methods.
     * @param  string|null  $instructions  Instructions describing how to use the server and its features.
     */
    public function __construct(
        public readonly Implementation $serverInfo,
        public readonly ServerCapabilities $capabilities,
        public readonly LoggerInterface $logger,
        public readonly LoopInterface $loop,
        public readonly ?CacheInterface $cache,
        public readonly ContainerInterface $container,
        public readonly int $paginationLimit = 50,
        public readonly ?string $instructions = null,
    ) {}
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Attributes;

use Attribute;

/**
 * Defines a JSON Schema for a method's input or an individual parameter.
 *
 * When used at the method level, it describes an object schema where properties
 * correspond to the method's parameters.
 *
 * When used at the parameter level, it describes the schema for that specific parameter.
 * If 'type' is omitted at the parameter level, it will be inferred.
 */
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PARAMETER)]
class Schema
{
    /**
     * The complete JSON schema array.
     * If provided, it takes precedence over individual properties like $type, $properties, etc.
     */
    public ?array $definition = null;

    /**
     * Alternatively, provide individual top-level schema keywords.
     * These are used if $definition is null.
     */
    public ?string $type = null;
    public ?string $description = null;
    public mixed $default = null;
    public ?array $enum = null; // list of allowed values
    public ?string $format = null; // e.g., 'email', 'date-time'

    // Constraints for string
    public ?int $minLength = null;
    public ?int $maxLength = null;
    public ?string $pattern = null;

    // Constraints for number/integer
    public int|float|null $minimum = null;
    public int|float|null $maximum = null;
    public ?bool $exclusiveMinimum = null;
    public ?bool $exclusiveMaximum = null;
    public int|float|null $multipleOf = null;

    // Constraints for array
    public ?array $items = null; // JSON schema for array items
    public ?int $minItems = null;
    public ?int $maxItems = null;
    public ?bool $uniqueItems = null;

    // Constraints for object (primarily used when Schema is on a method or an object-typed parameter)
    public ?array $properties = null; // [propertyName => [schema array], ...]
    public ?array $required = null;   // [propertyName, ...]
    public bool|array|null $additionalProperties = null; // true, false, or a schema array

    /**
     * @param array|null $definition A complete JSON schema array. If provided, other parameters are ignored.
     * @param Type|null $type The JSON schema type.
     * @param string|null $description Description of the element.
     * @param array|null $enum Allowed enum values.
     * @param string|null $format String format (e.g., 'date-time', 'email').
     * @param int|null $minLength Minimum length for strings.
     * @param int|null $maxLength Maximum length for strings.
     * @param string|null $pattern Regex pattern for strings.
     * @param int|float|null $minimum Minimum value for numbers/integers.
     * @param int|float|null $maximum Maximum value for numbers/integers.
     * @param bool|null $exclusiveMinimum Exclusive minimum.
     * @param bool|null $exclusiveMaximum Exclusive maximum.
     * @param int|float|null $multipleOf Must be a multiple of this value.
     * @param array|null $items JSON Schema for items if type is 'array'.
     * @param int|null $minItems Minimum items for an array.
     * @param int|null $maxItems Maximum items for an array.
     * @param bool|null $uniqueItems Whether array items must be unique.
     * @param array|null $properties Property definitions if type is 'object'. [name => schema_array].
     * @param array|null $required List of required properties for an object.
     * @param bool|array|null $additionalProperties Policy for additional properties in an object.
     */
    public function __construct(
        ?array $definition = null,
        ?string $type = null,
        ?string $description = null,
        ?array $enum = null,
        ?string $format = null,
        ?int $minLength = null,
        ?int $maxLength = null,
        ?string $pattern = null,
        int|float|null $minimum = null,
        int|float|null $maximum = null,
        ?bool $exclusiveMinimum = null,
        ?bool $exclusiveMaximum = null,
        int|float|null $multipleOf = null,
        ?array $items = null,
        ?int $minItems = null,
        ?int $maxItems = null,
        ?bool $uniqueItems = null,
        ?array $properties = null,
        ?array $required = null,
        bool|array|null $additionalProperties = null
    ) {
        if ($definition !== null) {
            $this->definition = $definition;
        } else {
            $this->type = $type;
            $this->description = $description;
            $this->enum = $enum;
            $this->format = $format;
            $this->minLength = $minLength;
            $this->maxLength = $maxLength;
            $this->pattern = $pattern;
            $this->minimum = $minimum;
            $this->maximum = $maximum;
            $this->exclusiveMinimum = $exclusiveMinimum;
            $this->exclusiveMaximum = $exclusiveMaximum;
            $this->multipleOf = $multipleOf;
            $this->items = $items;
            $this->minItems = $minItems;
            $this->maxItems = $maxItems;
            $this->uniqueItems = $uniqueItems;
            $this->properties = $properties;
            $this->required = $required;
            $this->additionalProperties = $additionalProperties;
        }
    }

    /**
     * Converts the attribute's definition to a JSON schema array.
     */
    public function toArray(): array
    {
        if ($this->definition !== null) {
            return [
                'definition' => $this->definition,
            ];
        }

        $schema = [];
        if ($this->type !== null) {
            $schema['type'] = $this->type;
        }
        if ($this->description !== null) {
            $schema['description'] = $this->description;
        }
        if ($this->enum !== null) {
            $schema['enum'] = $this->enum;
        }
        if ($this->format !== null) {
            $schema['format'] = $this->format;
        }

        // String
        if ($this->minLength !== null) {
            $schema['minLength'] = $this->minLength;
        }
        if ($this->maxLength !== null) {
            $schema['maxLength'] = $this->maxLength;
        }
        if ($this->pattern !== null) {
            $schema['pattern'] = $this->pattern;
        }

        // Numeric
        if ($this->minimum !== null) {
            $schema['minimum'] = $this->minimum;
        }
        if ($this->maximum !== null) {
            $schema['maximum'] = $this->maximum;
        }
        if ($this->exclusiveMinimum !== null) {
            $schema['exclusiveMinimum'] = $this->exclusiveMinimum;
        }
        if ($this->exclusiveMaximum !== null) {
            $schema['exclusiveMaximum'] = $this->exclusiveMaximum;
        }
        if ($this->multipleOf !== null) {
            $schema['multipleOf'] = $this->multipleOf;
        }

        // Array
        if ($this->items !== null) {
            $schema['items'] = $this->items;
        }
        if ($this->minItems !== null) {
            $schema['minItems'] = $this->minItems;
        }
        if ($this->maxItems !== null) {
            $schema['maxItems'] = $this->maxItems;
        }
        if ($this->uniqueItems !== null) {
            $schema['uniqueItems'] = $this->uniqueItems;
        }

        // Object
        if ($this->properties !== null) {
            $schema['properties'] = $this->properties;
        }
        if ($this->required !== null) {
            $schema['required'] = $this->required;
        }
        if ($this->additionalProperties !== null) {
            $schema['additionalProperties'] = $this->additionalProperties;
        }

        return $schema;
    }
}

<?php

namespace PhpMcp\Server\Utils;

use phpDocumentor\Reflection\DocBlock;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
use phpDocumentor\Reflection\DocBlock\Tags\Return_;
use phpDocumentor\Reflection\DocBlockFactory;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Throwable;

/**
 * Parses DocBlocks using phpdocumentor/reflection-docblock.
 */
class DocBlockParser
{
    private DocBlockFactory $docBlockFactory;
    private LoggerInterface $logger;

    public function __construct(?LoggerInterface $logger = null)
    {
        $this->docBlockFactory = DocBlockFactory::createInstance();
        $this->logger = $logger ?? new NullLogger();
    }

    /**
     * Safely parses a DocComment string into a DocBlock object.
     */
    public function parseDocBlock(string|null|false $docComment): ?DocBlock
    {
        if ($docComment === false || $docComment === null || empty($docComment)) {
            return null;
        }
        try {
            return $this->docBlockFactory->create($docComment);
        } catch (Throwable $e) {
            // Log error or handle gracefully if invalid DocBlock syntax is encountered
            $this->logger->warning('Failed to parse DocBlock', [
                'error' => $e->getMessage(),
                'exception_trace' => $e->getTraceAsString(),
            ]);

            return null;
        }
    }

    /**
     * Gets the summary line from a DocBlock.
     */
    public function getSummary(?DocBlock $docBlock): ?string
    {
        if (! $docBlock) {
            return null;
        }
        $summary = trim($docBlock->getSummary());

        return $summary ?: null; // Return null if empty after trimming
    }

    /**
     * Gets the description from a DocBlock (summary + description body).
     */
    public function getDescription(?DocBlock $docBlock): ?string
    {
        if (! $docBlock) {
            return null;
        }
        $summary = trim($docBlock->getSummary());
        $descriptionBody = trim((string) $docBlock->getDescription());

        if ($summary && $descriptionBody) {
            return $summary . "\n\n" . $descriptionBody;
        }
        if ($summary) {
            return $summary;
        }
        if ($descriptionBody) {
            return $descriptionBody;
        }

        return null;
    }

    /**
     * Extracts @param tag information from a DocBlock, keyed by variable name (e.g., '$paramName').
     *
     * @return array<string, Param>
     */
    public function getParamTags(?DocBlock $docBlock): array
    {
        if (! $docBlock) {
            return [];
        }

        /** @var array<string, Param> $paramTags */
        $paramTags = [];
        foreach ($docBlock->getTagsByName('param') as $tag) {
            if ($tag instanceof Param && $tag->getVariableName()) {
                $paramTags['$' . $tag->getVariableName()] = $tag;
            }
        }

        return $paramTags;
    }

    /**
     * Gets the @return tag information from a DocBlock.
     */
    public function getReturnTag(?DocBlock $docBlock): ?Return_
    {
        if (! $docBlock) {
            return null;
        }
        /** @var Return_|null $returnTag */
        $returnTag = $docBlock->getTagsByName('return')[0] ?? null;

        return $returnTag;
    }

    /**
     * Gets the description string from a Param tag.
     */
    public function getParamDescription(?Param $paramTag): ?string
    {
        return $paramTag ? (trim((string) $paramTag->getDescription()) ?: null) : null;
    }

    /**
     * Gets the type string from a Param tag.
     */
    public function getParamTypeString(?Param $paramTag): ?string
    {
        if ($paramTag && $paramTag->getType()) {
            $typeFromTag = trim((string) $paramTag->getType());
            if (! empty($typeFromTag)) {
                return ltrim($typeFromTag, '\\');
            }
        }

        return null;
    }

    /**
     * Gets the description string from a Return_ tag.
     */
    public function getReturnDescription(?Return_ $returnTag): ?string
    {
        return $returnTag ? (trim((string) $returnTag->getDescription()) ?: null) : null;
    }

    /**
     * Gets the type string from a Return_ tag.
     */
    public function getReturnTypeString(?Return_ $returnTag): ?string
    {
        if ($returnTag && $returnTag->getType()) {
            $typeFromTag = trim((string) $returnTag->getType());
            if (! empty($typeFromTag)) {
                return ltrim($typeFromTag, '\\');
            }
        }

        return null;
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Contracts;

use Psr\Log\LoggerInterface;

/**
 * Interface for components that can accept a PSR-3 Logger instance.
 *
 * Primarily used for injecting the configured logger into transport implementations.
 */
interface LoggerAwareInterface
{
    public function setLogger(LoggerInterface $logger): void;
}

<?php

namespace PhpMcp\Server\Attributes;

use Attribute;
use PhpMcp\Schema\Annotations;

/**
 * Marks a PHP class definition as representing an MCP Resource Template.
 * This is informational, used for 'resources/templates/list'.
 */
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
final class McpResourceTemplate
{
    /**
     * @param  string  $uriTemplate  The URI template string (RFC 6570).
     * @param  ?string  $name  A human-readable name for the template type.  If null, a default might be generated from the method name.
     * @param  ?string  $description  Optional description. Defaults to class DocBlock summary.
     * @param  ?string  $mimeType  Optional default MIME type for matching resources.
     * @param  ?Annotations  $annotations  Optional annotations describing the resource template.
     */
    public function __construct(
        public string $uriTemplate,
        public ?string $name = null,
        public ?string $description = null,
        public ?string $mimeType = null,
        public ?Annotations $annotations = null,
    ) {
    }
}

<?php

namespace PhpMcp\Server\Attributes;

use Attribute;
use PhpMcp\Schema\ToolAnnotations;

#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
class McpTool
{
    /**
     * @param  string|null  $name  The name of the tool (defaults to the method name)
     * @param  string|null  $description  The description of the tool (defaults to the DocBlock/inferred)
     * @param  ToolAnnotations|null  $annotations  Optional annotations describing tool behavior
     */
    public function __construct(
        public ?string $name = null,
        public ?string $description = null,
        public ?ToolAnnotations $annotations = null,
    ) {
    }
}

<?php

namespace PhpMcp\Server\Attributes;

use Attribute;
use PhpMcp\Schema\Annotations;

/**
 * Marks a PHP class as representing or handling a specific MCP Resource instance.
 * Used primarily for the 'resources/list' discovery.
 */
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
final class McpResource
{
    /**
     * @param  string  $uri  The specific URI identifying this resource instance. Must be unique within the server.
     * @param  ?string  $name  A human-readable name for this resource. If null, a default might be generated from the method name.
     * @param  ?string  $description  An optional description of the resource. Defaults to class DocBlock summary.
     * @param  ?string  $mimeType  The MIME type, if known and constant for this resource.
     * @param  ?int  $size  The size in bytes, if known and constant.
     * @param  Annotations|null  $annotations  Optional annotations describing the resource.
     */
    public function __construct(
        public string $uri,
        public ?string $name = null,
        public ?string $description = null,
        public ?string $mimeType = null,
        public ?int $size = null,
        public ?Annotations $annotations = null,
    ) {
    }
}

<?php

namespace PhpMcp\Server\Attributes;

use Attribute;

/**
 * Marks a PHP method as an MCP Prompt generator.
 * The method should return the prompt messages, potentially using arguments for templating.
 */
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
final class McpPrompt
{
    /**
     * @param  ?string  $name  Overrides the prompt name (defaults to method name).
     * @param  ?string  $description  Optional description of the prompt. Defaults to method DocBlock summary.
     */
    public function __construct(
        public ?string $name = null,
        public ?string $description = null,
    ) {
    }
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Exception;

/**
 * Exception related to errors during the attribute discovery process.
 */
class DiscoveryException extends McpServerException
{
    // No specific JSON-RPC code, internal server issue.
}

<?php

declare(strict_types=1);

namespace PhpMcp\Server\Exception;

/**
 * Exception related to invalid server configuration.
 *
 * Typically thrown during ServerBuilder::build().
 */
class ConfigurationException extends McpServerException
{
    // No specific JSON-RPC code, usually an internal setup issue.
    // Code 0 is appropriate.
}

class
DefaultUuidSessionIdGenerator
implements SessionIdGeneratorInterface
{
public function generateId () : string {}
public function onSessionInitialized (string $sessionId) : void {}
}

This code segment functions to: Generate a unique hexadecimal identifier using cryptographically secure random bytes and implement an interface method for handling session initialization events.

```php
[TASK_BEGIN]
class DefaultUuidSessionIdGenerator implements SessionIdGeneratorInterface
{
    public function generateId(): string
    {
        $bytes = random_bytes(16);
        $bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40); // Set version to 0100
        $bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80); // Set bits 6-7 to 10

        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
    }

    public function onSessionInitialized(string $sessionId): void
    {
        // No default action needed for session initialization
    }
}
[TASK_END]
```

class DefaultUuidSessionIdGenerator implements SessionIdGeneratorInterface
{
    public function generateId(): string
    {
        $bytes = random_bytes(16);
        $bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
        $bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
    }
    public function onSessionInitialized(string $sessionId): void
    {
    }
}