<?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);
    }
    public function call(ContainerInterface $container, array $arguments, Context $context): array
    {
        $result = $this->handle($container, $arguments, $context);
        return $this->formatResult($result);
    }
    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\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\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;

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

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

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

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;

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\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

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\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\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\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 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\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\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\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\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 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\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);

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

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

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\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

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\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\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

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 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\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\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\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\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

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;
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;

/**
 * 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;

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\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;

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\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

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
    {
        // no-op
    }
}

<?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\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;

/**
 * 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

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.
}

<?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\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\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\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;
}

class
RegisteredTool
extends RegisteredElement
{
public function __construct (
        public readonly Tool $schema,
        callable|array|string $handler,
        bool $isManual = false,
    ) {}
public static function make (Tool $schema, callable|array|string $handler, bool $isManual = false) : self {}
public function call (ContainerInterface $container, array $arguments, Context $context) : array {}
protected function formatResult (mixed $toolExecutionResult) : array {}
public function toArray () : array {}
public static function fromArray (array $data) : self|false {}
}

This code segment functions to: Process and standardize the output of executed operations by converting various data types into formatted content structures, ensuring consistent serialization handling for different result formats while maintaining compatibility with parent class behaviors.

```php
[TASK_BEGIN]
class RegisteredTool extends RegisteredElement
{
    public function __construct(
        public readonly Tool $schema,
        public readonly callable|array|string $handler,
        public readonly bool $isManual = false,
    ) {}

    public static function make(Tool $schema, callable|array|string $handler, bool $isManual = false): self
    {
        return new self($schema, $handler, $isManual);
    }

    public function call(ContainerInterface $container, array $arguments, Context $context): array
    {
        $handler = $this->handler;

        if (is_string($handler)) {
            $handler = $container->get($handler);
        }

        if (is_array($handler) && is_string($handler[0])) {
            $handler[0] = $container->get($handler[0]);
        }

        return $this->formatResult($handler($container, $arguments, $context));
    }

    protected function formatResult(mixed $toolExecutionResult): array
    {
        return ['result' => $toolExecutionResult];
    }

    public function toArray(): array
    {
        return [
            'schema' => $this->schema->toArray(),
            'handler' => $this->handler,
            'isManual' => $this->isManual,
        ];
    }

    public static function fromArray(array $data): self|false
    {
        $schema = Tool::fromArray($data['schema']);
        return new self(
            $schema,
            $data['handler'],
            $data['isManual'] ?? false
        );
    }
}
[TASK_END]
```