#
tokens: 47623/50000 14/154 files (page 3/5)
lines: off (toggle) GitHub
raw markdown copy
This is page 3 of 5. Use http://codebase.md/php-mcp/server?lines=false&page={x} to view the full context.

# Directory Structure

```
├── .github
│   └── workflows
│       ├── changelog.yml
│       └── tests.yml
├── .gitignore
├── .php-cs-fixer.php
├── CHANGELOG.md
├── composer.json
├── CONTRIBUTING.md
├── examples
│   ├── 01-discovery-stdio-calculator
│   │   ├── McpElements.php
│   │   └── server.php
│   ├── 02-discovery-http-userprofile
│   │   ├── McpElements.php
│   │   ├── server.php
│   │   └── UserIdCompletionProvider.php
│   ├── 03-manual-registration-stdio
│   │   ├── server.php
│   │   └── SimpleHandlers.php
│   ├── 04-combined-registration-http
│   │   ├── DiscoveredElements.php
│   │   ├── ManualHandlers.php
│   │   └── server.php
│   ├── 05-stdio-env-variables
│   │   ├── EnvToolHandler.php
│   │   └── server.php
│   ├── 06-custom-dependencies-stdio
│   │   ├── McpTaskHandlers.php
│   │   ├── server.php
│   │   └── Services.php
│   ├── 07-complex-tool-schema-http
│   │   ├── EventTypes.php
│   │   ├── McpEventScheduler.php
│   │   └── server.php
│   └── 08-schema-showcase-streamable
│       ├── SchemaShowcaseElements.php
│       └── server.php
├── LICENSE
├── phpunit.xml
├── README.md
├── src
│   ├── Attributes
│   │   ├── CompletionProvider.php
│   │   ├── McpPrompt.php
│   │   ├── McpResource.php
│   │   ├── McpResourceTemplate.php
│   │   ├── McpTool.php
│   │   └── Schema.php
│   ├── Configuration.php
│   ├── Context.php
│   ├── Contracts
│   │   ├── CompletionProviderInterface.php
│   │   ├── EventStoreInterface.php
│   │   ├── LoggerAwareInterface.php
│   │   ├── LoopAwareInterface.php
│   │   ├── ServerTransportInterface.php
│   │   ├── SessionHandlerInterface.php
│   │   └── SessionInterface.php
│   ├── Defaults
│   │   ├── ArrayCache.php
│   │   ├── BasicContainer.php
│   │   ├── DefaultUuidSessionIdGenerator.php
│   │   ├── EnumCompletionProvider.php
│   │   ├── FileCache.php
│   │   ├── InMemoryEventStore.php
│   │   ├── ListCompletionProvider.php
│   │   └── SystemClock.php
│   ├── Dispatcher.php
│   ├── Elements
│   │   ├── RegisteredElement.php
│   │   ├── RegisteredPrompt.php
│   │   ├── RegisteredResource.php
│   │   ├── RegisteredResourceTemplate.php
│   │   └── RegisteredTool.php
│   ├── Exception
│   │   ├── ConfigurationException.php
│   │   ├── DiscoveryException.php
│   │   ├── McpServerException.php
│   │   ├── ProtocolException.php
│   │   └── TransportException.php
│   ├── Protocol.php
│   ├── Registry.php
│   ├── Server.php
│   ├── ServerBuilder.php
│   ├── Session
│   │   ├── ArraySessionHandler.php
│   │   ├── CacheSessionHandler.php
│   │   ├── Session.php
│   │   ├── SessionManager.php
│   │   └── SubscriptionManager.php
│   ├── Transports
│   │   ├── HttpServerTransport.php
│   │   ├── StdioServerTransport.php
│   │   └── StreamableHttpServerTransport.php
│   └── Utils
│       ├── Discoverer.php
│       ├── DocBlockParser.php
│       ├── HandlerResolver.php
│       ├── SchemaGenerator.php
│       └── SchemaValidator.php
└── tests
    ├── Fixtures
    │   ├── Discovery
    │   │   ├── DiscoverablePromptHandler.php
    │   │   ├── DiscoverableResourceHandler.php
    │   │   ├── DiscoverableTemplateHandler.php
    │   │   ├── DiscoverableToolHandler.php
    │   │   ├── EnhancedCompletionHandler.php
    │   │   ├── InvocablePromptFixture.php
    │   │   ├── InvocableResourceFixture.php
    │   │   ├── InvocableResourceTemplateFixture.php
    │   │   ├── InvocableToolFixture.php
    │   │   ├── NonDiscoverableClass.php
    │   │   └── SubDir
    │   │       └── HiddenTool.php
    │   ├── Enums
    │   │   ├── BackedIntEnum.php
    │   │   ├── BackedStringEnum.php
    │   │   ├── PriorityEnum.php
    │   │   ├── StatusEnum.php
    │   │   └── UnitEnum.php
    │   ├── General
    │   │   ├── CompletionProviderFixture.php
    │   │   ├── DocBlockTestFixture.php
    │   │   ├── InvokableHandlerFixture.php
    │   │   ├── PromptHandlerFixture.php
    │   │   ├── RequestAttributeChecker.php
    │   │   ├── ResourceHandlerFixture.php
    │   │   ├── ToolHandlerFixture.php
    │   │   └── VariousTypesHandler.php
    │   ├── Middlewares
    │   │   ├── ErrorMiddleware.php
    │   │   ├── FirstMiddleware.php
    │   │   ├── HeaderMiddleware.php
    │   │   ├── RequestAttributeMiddleware.php
    │   │   ├── SecondMiddleware.php
    │   │   ├── ShortCircuitMiddleware.php
    │   │   └── ThirdMiddleware.php
    │   ├── Schema
    │   │   └── SchemaGenerationTarget.php
    │   ├── ServerScripts
    │   │   ├── HttpTestServer.php
    │   │   ├── StdioTestServer.php
    │   │   └── StreamableHttpTestServer.php
    │   └── Utils
    │       ├── AttributeFixtures.php
    │       ├── DockBlockParserFixture.php
    │       └── SchemaGeneratorFixture.php
    ├── Integration
    │   ├── DiscoveryTest.php
    │   ├── HttpServerTransportTest.php
    │   ├── SchemaGenerationTest.php
    │   ├── StdioServerTransportTest.php
    │   └── StreamableHttpServerTransportTest.php
    ├── Mocks
    │   ├── Clients
    │   │   ├── MockJsonHttpClient.php
    │   │   ├── MockSseClient.php
    │   │   └── MockStreamHttpClient.php
    │   └── Clock
    │       └── FixedClock.php
    ├── Pest.php
    ├── TestCase.php
    └── Unit
        ├── Attributes
        │   ├── CompletionProviderTest.php
        │   ├── McpPromptTest.php
        │   ├── McpResourceTemplateTest.php
        │   ├── McpResourceTest.php
        │   └── McpToolTest.php
        ├── ConfigurationTest.php
        ├── Defaults
        │   ├── EnumCompletionProviderTest.php
        │   └── ListCompletionProviderTest.php
        ├── DispatcherTest.php
        ├── Elements
        │   ├── RegisteredElementTest.php
        │   ├── RegisteredPromptTest.php
        │   ├── RegisteredResourceTemplateTest.php
        │   ├── RegisteredResourceTest.php
        │   └── RegisteredToolTest.php
        ├── ProtocolTest.php
        ├── RegistryTest.php
        ├── ServerBuilderTest.php
        ├── ServerTest.php
        ├── Session
        │   ├── ArraySessionHandlerTest.php
        │   ├── CacheSessionHandlerTest.php
        │   ├── SessionManagerTest.php
        │   └── SessionTest.php
        └── Utils
            ├── DocBlockParserTest.php
            ├── HandlerResolverTest.php
            └── SchemaValidatorTest.php
```

# Files

--------------------------------------------------------------------------------
/src/Elements/RegisteredResourceTemplate.php:
--------------------------------------------------------------------------------

```php
<?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;
        }
    }
}

```

--------------------------------------------------------------------------------
/tests/Unit/Elements/RegisteredResourceTest.php:
--------------------------------------------------------------------------------

```php
<?php

namespace PhpMcp\Server\Tests\Unit\Elements;

use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PhpMcp\Schema\Resource as ResourceSchema;
use PhpMcp\Server\Context;
use PhpMcp\Server\Contracts\SessionInterface;
use PhpMcp\Server\Elements\RegisteredResource;
use PhpMcp\Schema\Content\TextResourceContents;
use PhpMcp\Schema\Content\BlobResourceContents;
use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture;
use Psr\Container\ContainerInterface;
use PhpMcp\Server\Exception\McpServerException;

uses(MockeryPHPUnitIntegration::class);

beforeEach(function () {
    $this->container = Mockery::mock(ContainerInterface::class);
    $this->handlerInstance = new ResourceHandlerFixture();
    $this->container->shouldReceive('get')
        ->with(ResourceHandlerFixture::class)
        ->andReturn($this->handlerInstance)
        ->byDefault();

    $this->testUri = 'test://resource/item.txt';
    $this->resourceSchema = ResourceSchema::make($this->testUri, 'test-resource', mimeType: 'text/plain');
    $this->registeredResource = RegisteredResource::make(
        $this->resourceSchema,
        [ResourceHandlerFixture::class, 'returnStringText']
    );
    $this->context = new Context(Mockery::mock(SessionInterface::class));
});

afterEach(function () {
    if (ResourceHandlerFixture::$unlinkableSplFile && file_exists(ResourceHandlerFixture::$unlinkableSplFile)) {
        @unlink(ResourceHandlerFixture::$unlinkableSplFile);
        ResourceHandlerFixture::$unlinkableSplFile = null;
    }
});

it('constructs correctly and exposes schema', function () {
    expect($this->registeredResource->schema)->toBe($this->resourceSchema);
    expect($this->registeredResource->handler)->toBe([ResourceHandlerFixture::class, 'returnStringText']);
    expect($this->registeredResource->isManual)->toBeFalse();
});

it('can be made as a manual registration', function () {
    $manualResource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnStringText'], true);
    expect($manualResource->isManual)->toBeTrue();
});

it('passes URI to handler if handler method expects it', function () {
    $resource = RegisteredResource::make(
        ResourceSchema::make($this->testUri, 'needs-uri'),
        [ResourceHandlerFixture::class, 'resourceHandlerNeedsUri']
    );

    $handlerMock = Mockery::mock(ResourceHandlerFixture::class);
    $handlerMock->shouldReceive('resourceHandlerNeedsUri')
        ->with($this->testUri)
        ->once()
        ->andReturn("Confirmed URI: {$this->testUri}");
    $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock);

    $result = $resource->read($this->container, $this->testUri, $this->context);
    expect($result[0]->text)->toBe("Confirmed URI: {$this->testUri}");
});

it('does not require handler method to accept URI', function () {
    $resource = RegisteredResource::make(
        ResourceSchema::make($this->testUri, 'no-uri-param'),
        [ResourceHandlerFixture::class, 'resourceHandlerDoesNotNeedUri']
    );
    $handlerMock = Mockery::mock(ResourceHandlerFixture::class);
    $handlerMock->shouldReceive('resourceHandlerDoesNotNeedUri')->once()->andReturn("Success no URI");
    $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock);

    $result = $resource->read($this->container, $this->testUri, $this->context);
    expect($result[0]->text)->toBe("Success no URI");
});


dataset('resource_handler_return_types', [
    'string_text'        => ['returnStringText', 'text/plain', fn($text, $uri) => expect($text)->toBe("Plain string content for {$uri}"), null],
    'string_json_guess'  => ['returnStringJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_json'])->toBe($uri), null],
    'string_html_guess'  => ['returnStringHtml', 'text/html', fn($text, $uri) => expect($text)->toContain("<title>{$uri}</title>"), null],
    'array_json_schema_mime' => ['returnArrayJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_array'])->toBe($uri), null], // schema has text/plain, overridden by array + JSON content
    'empty_array'        => ['returnEmptyArray', 'application/json', fn($text) => expect($text)->toBe('[]'), null],
    'stream_octet'       => ['returnStream', 'application/octet-stream', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Streamed content for {$uri}")],
    'array_for_blob'     => ['returnArrayForBlobSchema', 'application/x-custom-blob-array', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Blob for {$uri} via array")],
    'array_for_text'     => ['returnArrayForTextSchema', 'text/vnd.custom-array-text', fn($text, $uri) => expect($text)->toBe("Text from array for {$uri} via array"), null],
    'direct_TextResourceContents' => ['returnTextResourceContents', 'text/special-contents', fn($text) => expect($text)->toBe('Direct TextResourceContents'), null],
    'direct_BlobResourceContents' => ['returnBlobResourceContents', 'application/custom-blob-contents', null, fn($blob) => expect(base64_decode($blob ?? ''))->toBe('blobbycontents')],
    'direct_EmbeddedResource' => ['returnEmbeddedResource', 'application/vnd.custom-embedded', fn($text) => expect($text)->toBe('Direct EmbeddedResource content'), null],
]);

it('formats various handler return types correctly', function (string $handlerMethod, string $expectedMime, ?callable $textAssertion, ?callable $blobAssertion) {
    $schema = ResourceSchema::make($this->testUri, 'format-test');
    $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, $handlerMethod]);

    $resultContents = $resource->read($this->container, $this->testUri, $this->context);

    expect($resultContents)->toBeArray()->toHaveCount(1);
    $content = $resultContents[0];

    expect($content->uri)->toBe($this->testUri);
    expect($content->mimeType)->toBe($expectedMime);

    if ($textAssertion) {
        expect($content)->toBeInstanceOf(TextResourceContents::class);
        $textAssertion($content->text, $this->testUri);
    }
    if ($blobAssertion) {
        expect($content)->toBeInstanceOf(BlobResourceContents::class);
        $blobAssertion($content->blob, $this->testUri);
    }
})->with('resource_handler_return_types');

it('formats SplFileInfo based on schema MIME type (text)', function () {
    $schema = ResourceSchema::make($this->testUri, 'spl-text', mimeType: 'text/markdown');
    $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, 'returnSplFileInfo']);
    $result = $resource->read($this->container, $this->testUri, $this->context);

    expect($result[0])->toBeInstanceOf(TextResourceContents::class);
    expect($result[0]->mimeType)->toBe('text/markdown');
    expect($result[0]->text)->toBe("Content from SplFileInfo for {$this->testUri}");
});

it('formats SplFileInfo based on schema MIME type (blob if not text like)', function () {
    $schema = ResourceSchema::make($this->testUri, 'spl-blob', mimeType: 'image/png');
    $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, 'returnSplFileInfo']);
    $result = $resource->read($this->container, $this->testUri, $this->context);

    expect($result[0])->toBeInstanceOf(BlobResourceContents::class);
    expect($result[0]->mimeType)->toBe('image/png');
    expect(base64_decode($result[0]->blob ?? ''))->toBe("Content from SplFileInfo for {$this->testUri}");
});

it('formats array of ResourceContents as is', function () {
    $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnArrayOfResourceContents']);
    $results = $resource->read($this->container, $this->testUri, $this->context);
    expect($results)->toHaveCount(2);
    expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe('Part 1 of many RC');
    expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('pngdata'));
});

it('formats array of EmbeddedResources by extracting their inner resource', function () {
    $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnArrayOfEmbeddedResources']);
    $results = $resource->read($this->container, $this->testUri, $this->context);
    expect($results)->toHaveCount(2);
    expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe('<doc1/>');
    expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('fontdata'));
});

it('formats mixed array with ResourceContent/EmbeddedResource by processing each item', function () {
    $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnMixedArrayWithResourceTypes']);
    $results = $resource->read($this->container, $this->testUri, $this->context);

    expect($results)->toBeArray()->toHaveCount(4);
    expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe("A raw string piece");
    expect($results[1])->toBeInstanceOf(TextResourceContents::class)->text->toBe("**Markdown!**");
    expect($results[2])->toBeInstanceOf(TextResourceContents::class);
    expect(json_decode($results[2]->text, true))->toEqual(['nested_array_data' => 'value', 'for_uri' => $this->testUri]);
    expect($results[3])->toBeInstanceOf(TextResourceContents::class)->text->toBe("col1,col2");
});


it('propagates McpServerException from handler during read', function () {
    $resource = RegisteredResource::make(
        $this->resourceSchema,
        [ResourceHandlerFixture::class, 'resourceHandlerNeedsUri']
    );
    $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn(
        Mockery::mock(ResourceHandlerFixture::class, function (Mockery\MockInterface $mock) {
            $mock->shouldReceive('resourceHandlerNeedsUri')->andThrow(McpServerException::invalidParams("Test error"));
        })
    );
    $resource->read($this->container, $this->testUri, $this->context);
})->throws(McpServerException::class, "Test error");

it('propagates other exceptions from handler during read', function () {
    $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'handlerThrowsException']);
    $resource->read($this->container, $this->testUri, $this->context);
})->throws(\DomainException::class, "Cannot read resource");

it('throws RuntimeException for unformattable handler result', function () {
    $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnUnformattableType']);
    $resource->read($this->container, $this->testUri, $this->context);
})->throws(\RuntimeException::class, "Cannot format resource read result for URI");


it('can be serialized to array and deserialized', function () {
    $original = RegisteredResource::make(
        ResourceSchema::make(
            'uri://test',
            'my-resource',
            'desc',
            'app/foo',
        ),
        [ResourceHandlerFixture::class, 'getStaticText'],
        true
    );

    $array = $original->toArray();

    expect($array['schema']['uri'])->toBe('uri://test');
    expect($array['schema']['name'])->toBe('my-resource');
    expect($array['schema']['description'])->toBe('desc');
    expect($array['schema']['mimeType'])->toBe('app/foo');
    expect($array['handler'])->toBe([ResourceHandlerFixture::class, 'getStaticText']);
    expect($array['isManual'])->toBeTrue();

    $rehydrated = RegisteredResource::fromArray($array);
    expect($rehydrated)->toBeInstanceOf(RegisteredResource::class);
    expect($rehydrated->schema->uri)->toEqual($original->schema->uri);
    expect($rehydrated->schema->name)->toEqual($original->schema->name);
    expect($rehydrated->isManual)->toBeTrue();
});

it('fromArray returns false on failure', function () {
    $badData = ['schema' => ['uri' => 'fail']];
    expect(RegisteredResource::fromArray($badData))->toBeFalse();
});

```

--------------------------------------------------------------------------------
/tests/Unit/Session/CacheSessionHandlerTest.php:
--------------------------------------------------------------------------------

```php
<?php

namespace PhpMcp\Server\Tests\Unit\Session;

use Mockery;
use Mockery\MockInterface;
use PhpMcp\Server\Session\CacheSessionHandler;
use PhpMcp\Server\Contracts\SessionHandlerInterface;
use Psr\SimpleCache\CacheInterface;
use PhpMcp\Server\Tests\Mocks\Clock\FixedClock;

const SESSION_ID_CACHE_1 = 'cache-session-id-1';
const SESSION_ID_CACHE_2 = 'cache-session-id-2';
const SESSION_ID_CACHE_3 = 'cache-session-id-3';
const SESSION_DATA_CACHE_1 = '{"id":"cs1","data":{"a":1,"b":"foo"}}';
const SESSION_DATA_CACHE_2 = '{"id":"cs2","data":{"x":true,"y":null}}';
const SESSION_DATA_CACHE_3 = '{"id":"cs3","data":"simple string data"}';
const DEFAULT_TTL_CACHE = 3600;
const SESSION_INDEX_KEY_CACHE = 'mcp_session_index';

beforeEach(function () {
    $this->fixedClock = new FixedClock();
    /** @var MockInterface&CacheInterface $cache */
    $this->cache = Mockery::mock(CacheInterface::class);

    $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault();
    $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true)->byDefault();

    $this->handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
});

it('implements SessionHandlerInterface', function () {
    expect($this->handler)->toBeInstanceOf(SessionHandlerInterface::class);
});

it('constructs with default TTL and SystemClock if no clock provided', function () {
    $cacheMock = Mockery::mock(CacheInterface::class);
    $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault();
    $handler = new CacheSessionHandler($cacheMock);

    expect($handler->ttl)->toBe(DEFAULT_TTL_CACHE);
    $reflection = new \ReflectionClass($handler);
    $clockProp = $reflection->getProperty('clock');
    $clockProp->setAccessible(true);
    expect($clockProp->getValue($handler))->toBeInstanceOf(\PhpMcp\Server\Defaults\SystemClock::class);
});

it('constructs with a custom TTL and injected clock', function () {
    $customTtl = 7200;
    $clock = new FixedClock();
    $cacheMock = Mockery::mock(CacheInterface::class);
    $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault();
    $handler = new CacheSessionHandler($cacheMock, $customTtl, $clock);
    expect($handler->ttl)->toBe($customTtl);

    $reflection = new \ReflectionClass($handler);
    $clockProp = $reflection->getProperty('clock');
    $clockProp->setAccessible(true);
    expect($clockProp->getValue($handler))->toBe($clock);
});

it('loads session index from cache on construction', function () {
    $initialTimestamp = $this->fixedClock->now()->modify('-100 seconds')->getTimestamp();
    $initialIndex = [SESSION_ID_CACHE_1 => $initialTimestamp];

    $cacheMock = Mockery::mock(CacheInterface::class);
    $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn($initialIndex);

    new CacheSessionHandler($cacheMock, DEFAULT_TTL_CACHE, $this->fixedClock);
});

it('reads session data from cache', function () {
    $sessionIndex = [SESSION_ID_CACHE_1 => $this->fixedClock->now()->modify('-100 seconds')->getTimestamp()];
    $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn($sessionIndex);
    $this->cache->shouldReceive('get')->with(SESSION_ID_CACHE_1, false)->once()->andReturn(SESSION_DATA_CACHE_1);

    $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
    $readData = $handler->read(SESSION_ID_CACHE_1);
    expect($readData)->toBe(SESSION_DATA_CACHE_1);
});

it('returns false when reading non-existent session (cache get returns default)', function () {
    $this->cache->shouldReceive('get')->with('non-existent-id', false)->once()->andReturn(false);
    $readData = $this->handler->read('non-existent-id');
    expect($readData)->toBeFalse();
});

it('writes session data to cache with correct key and TTL, and updates session index', function () {
    $expectedTimestamp = $this->fixedClock->now()->getTimestamp(); // 15:00:00

    $this->cache->shouldReceive('set')
        ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_1 => $expectedTimestamp])
        ->once()->andReturn(true);
    $this->cache->shouldReceive('set')
        ->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1)
        ->once()->andReturn(true);

    $writeResult = $this->handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1);
    expect($writeResult)->toBeTrue();
});

it('updates timestamp in session index for existing session on write', function () {
    $initialWriteTime = $this->fixedClock->now()->modify('-60 seconds')->getTimestamp(); // 14:59:00
    $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([SESSION_ID_CACHE_1 => $initialWriteTime]);
    $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);

    $this->fixedClock->addSeconds(90);
    $expectedNewTimestamp = $this->fixedClock->now()->getTimestamp();

    $this->cache->shouldReceive('set')
        ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_1 => $expectedNewTimestamp])
        ->once()->andReturn(true);
    $this->cache->shouldReceive('set')
        ->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1)
        ->once()->andReturn(true);

    $handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1);
});

it('returns false if cache set for session data fails', function () {
    $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true);
    $this->cache->shouldReceive('set')->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1)
        ->once()->andReturn(false);

    $writeResult = $this->handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1);
    expect($writeResult)->toBeFalse();
});

it('destroys session by removing from cache and updating index', function () {
    $initialTimestamp = $this->fixedClock->now()->getTimestamp();
    $initialIndex = [SESSION_ID_CACHE_1 => $initialTimestamp, SESSION_ID_CACHE_2 => $initialTimestamp];
    $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
    $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);

    $this->cache->shouldReceive('set')
        ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_2 => $initialTimestamp])
        ->once()->andReturn(true);
    $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(true);

    $handler->destroy(SESSION_ID_CACHE_1);
});

it('destroy returns true if session ID not in index (cache delete still called)', function () {
    $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([]); // Empty index
    $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE);

    $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true); // Index remains empty
    $this->cache->shouldReceive('delete')->with('non-existent-id')->once()->andReturn(true); // Cache delete for data

    $destroyResult = $handler->destroy('non-existent-id');
    expect($destroyResult)->toBeTrue();
});

it('destroy returns false if cache delete for session data fails', function () {
    $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([SESSION_ID_CACHE_1 => time()]);
    $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE);

    $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true); // Index update
    $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(false); // Data delete fails

    $destroyResult = $handler->destroy(SESSION_ID_CACHE_1);
    expect($destroyResult)->toBeFalse();
});

it('garbage collects only sessions older than maxLifetime from cache and index', function () {
    $maxLifetime = 120;

    $initialIndex = [
        SESSION_ID_CACHE_1 => $this->fixedClock->now()->modify('-60 seconds')->getTimestamp(),
        SESSION_ID_CACHE_2 => $this->fixedClock->now()->modify("-{$maxLifetime} seconds -10 seconds")->getTimestamp(),
        SESSION_ID_CACHE_3 => $this->fixedClock->now()->modify('-1000 seconds')->getTimestamp(),
    ];
    $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
    $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);

    $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_2)->once()->andReturn(true);
    $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_3)->once()->andReturn(true);
    $this->cache->shouldNotReceive('delete')->with(SESSION_ID_CACHE_1);

    $expectedFinalIndex = [SESSION_ID_CACHE_1 => $initialIndex[SESSION_ID_CACHE_1]];
    $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $expectedFinalIndex)->once()->andReturn(true);

    $deletedSessionIds = $handler->gc($maxLifetime);

    expect($deletedSessionIds)->toBeArray()->toHaveCount(2)
        ->and($deletedSessionIds)->toContain(SESSION_ID_CACHE_2)
        ->and($deletedSessionIds)->toContain(SESSION_ID_CACHE_3);
});

it('garbage collection respects maxLifetime precisely for cache handler', function () {
    $maxLifetime = 60;

    $sessionTimestamp = $this->fixedClock->now()->modify("-{$maxLifetime} seconds")->getTimestamp();
    $initialIndex = [SESSION_ID_CACHE_1 => $sessionTimestamp];
    $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
    $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);

    $this->cache->shouldNotReceive('delete');
    $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $initialIndex)->once()->andReturn(true);
    $deleted = $handler->gc($maxLifetime);
    expect($deleted)->toBeEmpty();

    $this->fixedClock->addSeconds(1);
    $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
    $handlerAfterTimeAdvance = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);

    $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(true);
    $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true);
    $deleted2 = $handlerAfterTimeAdvance->gc($maxLifetime);
    expect($deleted2)->toEqual([SESSION_ID_CACHE_1]);
});


it('garbage collection handles an empty session index', function () {
    $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([]);
    $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE);

    $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true);
    $this->cache->shouldNotReceive('delete');

    $deletedSessions = $handler->gc(DEFAULT_TTL_CACHE);
    expect($deletedSessions)->toBeArray()->toBeEmpty();
});

it('garbage collection continues updating index even if a cache delete fails', function () {
    $maxLifetime = 60;

    $initialIndex = [
        'expired_deleted_ok' => $this->fixedClock->now()->modify("-70 seconds")->getTimestamp(),
        'expired_delete_fails' => $this->fixedClock->now()->modify("-80 seconds")->getTimestamp(),
        'survivor' => $this->fixedClock->now()->modify('-30 seconds')->getTimestamp(),
    ];
    $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
    $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);

    $this->cache->shouldReceive('delete')->with('expired_deleted_ok')->once()->andReturn(true);
    $this->cache->shouldReceive('delete')->with('expired_delete_fails')->once()->andReturn(false);

    $expectedFinalIndex = ['survivor' => $initialIndex['survivor']];
    $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $expectedFinalIndex)->once()->andReturn(true);

    $deletedSessionIds = $handler->gc($maxLifetime);
    expect($deletedSessionIds)->toHaveCount(2)->toContain('expired_deleted_ok')->toContain('expired_delete_fails');
});

```

--------------------------------------------------------------------------------
/tests/Fixtures/Utils/SchemaGeneratorFixture.php:
--------------------------------------------------------------------------------

```php
<?php

namespace PhpMcp\Server\Tests\Fixtures\Utils;

use PhpMcp\Server\Attributes\Schema;
use PhpMcp\Server\Context;
use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum;
use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum;
use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum;
use stdClass;

/**
 * Comprehensive fixture for testing SchemaGenerator with various scenarios.
 */
class SchemaGeneratorFixture
{
    // ===== BASIC SCENARIOS =====

    public function noParams(): void
    {
    }

    /**
     * Type hints only - no Schema attributes.
     */
    public function typeHintsOnly(string $name, int $age, bool $active, array $tags, ?stdClass $config = null): void
    {
    }

    /**
     * DocBlock types only - no PHP type hints, no Schema attributes.
     * @param string $username The username
     * @param int $count Number of items
     * @param bool $enabled Whether enabled
     * @param array $data Some data
     */
    public function docBlockOnly($username, $count, $enabled, $data): void
    {
    }

    /**
     * Type hints with DocBlock descriptions.
     * @param string $email User email address
     * @param int $score User score
     * @param bool $verified Whether user is verified
     */
    public function typeHintsWithDocBlock(string $email, int $score, bool $verified): void
    {
    }

    public function contextParameter(Context $context): void
    {
    }

    // ===== METHOD-LEVEL SCHEMA SCENARIOS =====

    /**
     * Method-level Schema with complete definition.
     */
    #[Schema(definition: [
        'type' => 'object',
        'description' => 'Creates a custom filter with complete definition',
        'properties' => [
            'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']],
            'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']],
            'value' => ['description' => 'Value to filter by, type depends on field and operator']
        ],
        'required' => ['field', 'operator', 'value'],
        'if' => [
            'properties' => ['field' => ['const' => 'date']]
        ],
        'then' => [
            'properties' => ['value' => ['type' => 'string', 'format' => 'date']]
        ]
    ])]
    public function methodLevelCompleteDefinition(string $field, string $operator, mixed $value): array
    {
        return compact('field', 'operator', 'value');
    }

    /**
     * Method-level Schema defining properties.
     */
    #[Schema(
        description: "Creates a new user with detailed information.",
        properties: [
            'username' => ['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'],
            'email' => ['type' => 'string', 'format' => 'email'],
            'age' => ['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'],
            'isActive' => ['type' => 'boolean', 'default' => true]
        ],
        required: ['username', 'email']
    )]
    public function methodLevelWithProperties(string $username, string $email, int $age, bool $isActive = true): array
    {
        return compact('username', 'email', 'age', 'isActive');
    }

    /**
     * Method-level Schema for complex array argument.
     */
    #[Schema(
        properties: [
            'profiles' => [
                'type' => 'array',
                'description' => 'An array of user profiles to update.',
                'minItems' => 1,
                'items' => [
                    'type' => 'object',
                    'properties' => [
                        'id' => ['type' => 'integer'],
                        'data' => ['type' => 'object', 'additionalProperties' => true]
                    ],
                    'required' => ['id', 'data']
                ]
            ]
        ],
        required: ['profiles']
    )]
    public function methodLevelArrayArgument(array $profiles): array
    {
        return ['updated_count' => count($profiles)];
    }

    // ===== PARAMETER-LEVEL SCHEMA SCENARIOS =====

    /**
     * Parameter-level Schema attributes only.
     */
    public function parameterLevelOnly(
        #[Schema(description: "Recipient ID", pattern: "^user_")]
        string $recipientId,
        #[Schema(maxLength: 1024)]
        string $messageBody,
        #[Schema(type: 'integer', enum: [1, 2, 5])]
        int $priority = 1,
        #[Schema(
            type: 'object',
            properties: [
                'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']],
                'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push']
            ],
            required: ['type']
        )]
        ?array $notificationConfig = null
    ): array {
        return compact('recipientId', 'messageBody', 'priority', 'notificationConfig');
    }

    /**
     * Parameter-level Schema with string constraints.
     */
    public function parameterStringConstraints(
        #[Schema(format: 'email')]
        string $email,
        #[Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$')]
        string $password,
        string $regularString
    ): void {
    }

    /**
     * Parameter-level Schema with numeric constraints.
     */
    public function parameterNumericConstraints(
        #[Schema(minimum: 18, maximum: 120)]
        int $age,
        #[Schema(minimum: 0, maximum: 5, exclusiveMaximum: true)]
        float $rating,
        #[Schema(multipleOf: 10)]
        int $count
    ): void {
    }

    /**
     * Parameter-level Schema with array constraints.
     */
    public function parameterArrayConstraints(
        #[Schema(type: 'array', items: ['type' => 'string'], minItems: 1, uniqueItems: true)]
        array $tags,
        #[Schema(type: 'array', items: ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], minItems: 1, maxItems: 5)]
        array $scores
    ): void {
    }

    // ===== COMBINED SCENARIOS =====

    /**
     * Method-level + Parameter-level Schema combination.
     * @param string $settingKey The key of the setting
     * @param mixed $newValue The new value for the setting
     */
    #[Schema(
        properties: [
            'settingKey' => ['type' => 'string', 'description' => 'The key of the setting.'],
            'newValue' => ['description' => 'The new value for the setting (any type).']
        ],
        required: ['settingKey', 'newValue']
    )]
    public function methodAndParameterLevel(
        string $settingKey,
        #[Schema(description: "The specific new boolean value.", type: 'boolean')]
        mixed $newValue
    ): array {
        return compact('settingKey', 'newValue');
    }

    /**
     * Type hints + DocBlock + Parameter-level Schema.
     * @param string $username The user's name
     * @param int $priority Task priority level
     */
    public function typeHintDocBlockAndParameterSchema(
        #[Schema(minLength: 3, pattern: '^[a-zA-Z0-9_]+$')]
        string $username,
        #[Schema(minimum: 1, maximum: 10)]
        int $priority
    ): void {
    }

    // ===== ENUM SCENARIOS =====

    /**
     * Various enum parameter types.
     * @param BackedStringEnum $stringEnum Backed string enum
     * @param BackedIntEnum $intEnum Backed int enum
     * @param UnitEnum $unitEnum Unit enum
     */
    public function enumParameters(
        BackedStringEnum $stringEnum,
        BackedIntEnum $intEnum,
        UnitEnum $unitEnum,
        ?BackedStringEnum $nullableEnum = null,
        BackedIntEnum $enumWithDefault = BackedIntEnum::First
    ): void {
    }

    // ===== ARRAY TYPE SCENARIOS =====

    /**
     * Various array type scenarios.
     * @param array $genericArray Generic array
     * @param string[] $stringArray Array of strings
     * @param int[] $intArray Array of integers
     * @param array<string, mixed> $mixedMap Mixed array map
     * @param array{name: string, age: int} $objectLikeArray Object-like array
     * @param array{user: array{id: int, name: string}, items: int[]} $nestedObjectArray Nested object array
     */
    public function arrayTypeScenarios(
        array $genericArray,
        array $stringArray,
        array $intArray,
        array $mixedMap,
        array $objectLikeArray,
        array $nestedObjectArray
    ): void {
    }

    // ===== NULLABLE AND OPTIONAL SCENARIOS =====

    /**
     * Nullable and optional parameter scenarios.
     * @param string|null $nullableString Nullable string
     * @param int|null $nullableInt Nullable integer
     */
    public function nullableAndOptional(
        ?string $nullableString,
        ?int $nullableInt = null,
        string $optionalString = 'default',
        bool $optionalBool = true,
        array $optionalArray = []
    ): void {
    }

    // ===== UNION TYPE SCENARIOS =====

    /**
     * Union type parameters.
     * @param string|int $stringOrInt String or integer
     * @param bool|string|null $multiUnion Bool, string or null
     */
    public function unionTypes(
        string|int $stringOrInt,
        bool|string|null $multiUnion
    ): void {
    }

    // ===== VARIADIC SCENARIOS =====

    /**
     * Variadic parameter scenarios.
     * @param string ...$items Variadic strings
     */
    public function variadicStrings(string ...$items): void
    {
    }

    /**
     * Variadic with Schema constraints.
     * @param int ...$numbers Variadic integers
     */
    public function variadicWithConstraints(
        #[Schema(items: ['type' => 'integer', 'minimum' => 0])]
        int ...$numbers
    ): void {
    }

    // ===== MIXED TYPE SCENARIOS =====

    /**
     * Mixed type scenarios.
     * @param mixed $anyValue Any value
     * @param mixed $optionalAny Optional any value
     */
    public function mixedTypes(
        mixed $anyValue,
        mixed $optionalAny = 'default'
    ): void {
    }

    // ===== COMPLEX NESTED SCENARIOS =====

    /**
     * Complex nested Schema constraints.
     */
    public function complexNestedSchema(
        #[Schema(
            type: 'object',
            properties: [
                'customer' => [
                    'type' => 'object',
                    'properties' => [
                        'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'],
                        'name' => ['type' => 'string', 'minLength' => 2],
                        'email' => ['type' => 'string', 'format' => 'email']
                    ],
                    'required' => ['id', 'name']
                ],
                'items' => [
                    'type' => 'array',
                    'minItems' => 1,
                    'items' => [
                        'type' => 'object',
                        'properties' => [
                            'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'],
                            'quantity' => ['type' => 'integer', 'minimum' => 1],
                            'price' => ['type' => 'number', 'minimum' => 0]
                        ],
                        'required' => ['product_id', 'quantity', 'price']
                    ]
                ],
                'metadata' => [
                    'type' => 'object',
                    'additionalProperties' => true
                ]
            ],
            required: ['customer', 'items']
        )]
        array $order
    ): array {
        return ['order_id' => uniqid()];
    }

    // ===== TYPE PRECEDENCE SCENARIOS =====

    /**
     * Testing type precedence between PHP, DocBlock, and Schema.
     * @param integer $numericString DocBlock says integer despite string type hint
     * @param string $stringWithConstraints String with Schema constraints
     * @param array<string> $arrayWithItems Array with Schema item overrides
     */
    public function typePrecedenceTest(
        string $numericString,
        #[Schema(format: 'email', minLength: 5)]
        string $stringWithConstraints,
        #[Schema(items: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])]
        array $arrayWithItems
    ): void {
    }

    // ===== ERROR EDGE CASES =====

    /**
     * Method with no parameters but Schema description.
     */
    #[Schema(description: "Gets server status. Takes no arguments.", properties: [])]
    public function noParamsWithSchema(): array
    {
        return ['status' => 'OK'];
    }

    /**
     * Parameter with Schema but inferred type.
     */
    public function parameterSchemaInferredType(
        #[Schema(description: "Some parameter", minLength: 3)]
        $inferredParam
    ): void {
    }

    /**
     * Parameter with complete custom definition via #[Schema(definition: ...)]
     */
    public function parameterWithRawDefinition(
        #[Schema(definition: [
            'description' => 'Custom-defined schema',
            'type' => 'string',
            'format' => 'uuid'
        ])]
        string $custom
    ): void {
    }
}

```

--------------------------------------------------------------------------------
/src/Elements/RegisteredPrompt.php:
--------------------------------------------------------------------------------

```php
<?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;
        }
    }
}

```

--------------------------------------------------------------------------------
/src/Utils/SchemaValidator.php:
--------------------------------------------------------------------------------

```php
<?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;
    }
}

```

--------------------------------------------------------------------------------
/src/Transports/HttpServerTransport.php:
--------------------------------------------------------------------------------

```php
<?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).
     * @param array<callable(\Psr\Http\Message\ServerRequestInterface, callable): (\Psr\Http\Message\ResponseInterface|\React\Promise\PromiseInterface)> $middlewares Middlewares to be applied to the HTTP server.
     */
    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,
        private array $middlewares = []
    ) {
        $this->logger = new NullLogger();
        $this->loop = Loop::get();
        $this->ssePath = '/' . trim($mcpPathPrefix, '/') . '/sse';
        $this->messagePath = '/' . trim($mcpPathPrefix, '/') . '/message';

        foreach ($this->middlewares as $mw) {
            if (!is_callable($mw)) {
                throw new \InvalidArgumentException('All provided middlewares must be callable.');
            }
        }
    }

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

            $handlers = array_merge($this->middlewares, [$this->createRequestHandler()]);
            $this->http = new HttpServer($this->loop, ...$handlers);
            $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));
        }

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

        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();
    }
}

```

--------------------------------------------------------------------------------
/tests/Unit/Elements/RegisteredElementTest.php:
--------------------------------------------------------------------------------

```php
<?php

namespace PhpMcp\Server\Tests\Unit\Elements;

use Mockery;
use PhpMcp\Server\Context;
use PhpMcp\Server\Contracts\SessionInterface;
use PhpMcp\Server\Elements\RegisteredElement;
use PhpMcp\Server\Exception\McpServerException;
use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum;
use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum;
use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum;
use PhpMcp\Server\Tests\Fixtures\General\VariousTypesHandler;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ServerRequestInterface;
use stdClass;

// --- Test Fixtures for Handler Types ---

class MyInvokableTestHandler
{
    public function __invoke(string $name): string
    {
        return "Hello, {$name}!";
    }
}

class MyStaticMethodTestHandler
{
    public static function myStaticMethod(int $a, int $b): int
    {
        return $a + $b;
    }
}

function my_global_test_function(bool $flag): string
{
    return $flag ? 'on' : 'off';
}


beforeEach(function () {
    $this->container = Mockery::mock(ContainerInterface::class);
    $this->container->shouldReceive('get')->with(VariousTypesHandler::class)->andReturn(new VariousTypesHandler());
    $this->context = new Context(Mockery::mock(SessionInterface::class));
});

it('can be constructed as manual or discovered', function () {
    $handler = [VariousTypesHandler::class, 'noArgsMethod'];
    $elManual = new RegisteredElement($handler, true);
    $elDiscovered = new RegisteredElement($handler, false);
    expect($elManual->isManual)->toBeTrue();
    expect($elDiscovered->isManual)->toBeFalse();
    expect($elDiscovered->handler)->toBe($handler);
});

it('prepares arguments in correct order for simple required types', function () {
    $element = new RegisteredElement([VariousTypesHandler::class, 'simpleRequiredArgs']);
    $args = ['pString' => 'hello', 'pBool' => true, 'pInt' => 123];
    $result = $element->handle($this->container, $args, $this->context);

    $expectedResult = ['pString' => 'hello', 'pInt' => 123, 'pBool' => true];

    expect($result)->toBe($expectedResult);
});

it('uses default values for missing optional arguments', function () {
    $element = new RegisteredElement([VariousTypesHandler::class, 'optionalArgsWithDefaults']);

    $result1 = $element->handle($this->container, ['pString' => 'override'], $this->context);
    expect($result1['pString'])->toBe('override');
    expect($result1['pInt'])->toBe(100);
    expect($result1['pNullableBool'])->toBeTrue();
    expect($result1['pFloat'])->toBe(3.14);

    $result2 = $element->handle($this->container, [], $this->context);
    expect($result2['pString'])->toBe('default_string');
    expect($result2['pInt'])->toBe(100);
    expect($result2['pNullableBool'])->toBeTrue();
    expect($result2['pFloat'])->toBe(3.14);
});

it('passes null for nullable arguments if not provided', function () {
    $elementNoDefaults = new RegisteredElement([VariousTypesHandler::class, 'nullableArgsWithoutDefaults']);
    $result2 = $elementNoDefaults->handle($this->container, [], $this->context);
    expect($result2['pString'])->toBeNull();
    expect($result2['pInt'])->toBeNull();
    expect($result2['pArray'])->toBeNull();
});

it('passes null explicitly for nullable arguments', function () {
    $element = new RegisteredElement([VariousTypesHandler::class, 'nullableArgsWithoutDefaults']);
    $result = $element->handle($this->container, ['pString' => null, 'pInt' => null, 'pArray' => null], $this->context);
    expect($result['pString'])->toBeNull();
    expect($result['pInt'])->toBeNull();
    expect($result['pArray'])->toBeNull();
});

it('handles mixed type arguments', function () {
    $element = new RegisteredElement([VariousTypesHandler::class, 'mixedTypeArg']);
    $obj = new stdClass();
    $testValues = [
        'a string',
        123,
        true,
        null,
        ['an', 'array'],
        $obj
    ];
    foreach ($testValues as $value) {
        $result = $element->handle($this->container, ['pMixed' => $value], $this->context);
        expect($result['pMixed'])->toBe($value);
    }
});

it('throws McpServerException for missing required argument', function () {
    $element = new RegisteredElement([VariousTypesHandler::class, 'simpleRequiredArgs']);
    $element->handle($this->container, ['pString' => 'hello', 'pInt' => 123], $this->context);
})->throws(McpServerException::class, 'Missing required argument `pBool`');

dataset('valid_type_casts', [
    'string_from_int'       => ['strParam', 123, '123'],
    'int_from_valid_string' => ['intParam', '456', 456],
    'int_from_neg_string'   => ['intParam', '-10', -10],
    'int_from_float_whole'  => ['intParam', 77.0, 77],
    'bool_from_int_1'       => ['boolProp', 1, true],
    'bool_from_string_true' => ['boolProp', 'true', true],
    'bool_from_string_TRUE' => ['boolProp', 'TRUE', true],
    'bool_from_int_0'       => ['boolProp', 0, false],
    'bool_from_string_false' => ['boolProp', 'false', false],
    'bool_from_string_FALSE' => ['boolProp', 'FALSE', false],
    'float_from_valid_string' => ['floatParam', '7.89', 7.89],
    'float_from_int'        => ['floatParam', 10, 10.0],
    'array_passthrough'     => ['arrayParam', ['x', 'y'], ['x', 'y']],
    'object_passthrough'    => ['objectParam', (object)['a' => 1], (object)['a' => 1]],
    'string_for_int_cast_specific' => ['stringForIntCast', '999', 999],
    'string_for_float_cast_specific' => ['stringForFloatCast', '123.45', 123.45],
    'string_for_bool_true_cast_specific' => ['stringForBoolTrueCast', '1', true],
    'string_for_bool_false_cast_specific' => ['stringForBoolFalseCast', '0', false],
    'int_for_string_cast_specific' => ['intForStringCast', 55, '55'],
    'int_for_float_cast_specific' => ['intForFloatCast', 66, 66.0],
    'bool_for_string_cast_specific' => ['boolForStringCast', true, '1'],
    'backed_string_enum_valid_val' => ['backedStringEnumParam', 'A', BackedStringEnum::OptionA],
    'backed_int_enum_valid_val' => ['backedIntEnumParam', 1, BackedIntEnum::First],
    'unit_enum_valid_val' => ['unitEnumParam', 'Yes', UnitEnum::Yes],
]);

it('casts argument types correctly for valid inputs (comprehensive)', function (string $paramName, mixed $inputValue, mixed $expectedValue) {
    $element = new RegisteredElement([VariousTypesHandler::class, 'comprehensiveArgumentTest']);

    $allArgs = [
        'strParam' => 'default string',
        'intParam' => 0,
        'boolProp' => false,
        'floatParam' => 0.0,
        'arrayParam' => [],
        'backedStringEnumParam' => BackedStringEnum::OptionA,
        'backedIntEnumParam' => BackedIntEnum::First,
        'unitEnumParam' => 'Yes',
        'nullableStringParam' => null,
        'mixedParam' => 'default mixed',
        'objectParam' => new stdClass(),
        'stringForIntCast' => '0',
        'stringForFloatCast' => '0.0',
        'stringForBoolTrueCast' => 'false',
        'stringForBoolFalseCast' => 'true',
        'intForStringCast' => 0,
        'intForFloatCast' => 0,
        'boolForStringCast' => false,
        'valueForBackedStringEnum' => 'A',
        'valueForBackedIntEnum' => 1,
    ];
    $testArgs = array_merge($allArgs, [$paramName => $inputValue]);

    $result = $element->handle($this->container, $testArgs, $this->context);
    expect($result[$paramName])->toEqual($expectedValue);
})->with('valid_type_casts');


dataset('invalid_type_casts', [
    'int_from_alpha_string' => ['intParam', 'abc', '/Cannot cast value to integer/i'],
    'int_from_float_non_whole' => ['intParam', 12.3, '/Cannot cast value to integer/i'],
    'bool_from_string_random' => ['boolProp', 'random', '/Cannot cast value to boolean/i'],
    'bool_from_int_invalid' => ['boolProp', 2, '/Cannot cast value to boolean/i'],
    'float_from_alpha_string' => ['floatParam', 'xyz', '/Cannot cast value to float/i'],
    'array_from_string' => ['arrayParam', 'not_an_array', '/Cannot cast value to array/i'],
    'backed_string_enum_invalid_val' => ['backedStringEnumParam', 'Z', "/Invalid value 'Z' for backed enum .*BackedStringEnum/i"],
    'backed_int_enum_invalid_val' => ['backedIntEnumParam', 99, "/Invalid value '99' for backed enum .*BackedIntEnum/i"],
    'unit_enum_invalid_string_val' => ['unitEnumParam', 'Maybe', "/Invalid value 'Maybe' for unit enum .*UnitEnum/i"],
]);

it('throws McpServerException for invalid type casting', function (string $paramName, mixed $invalidValue, string $expectedMsgRegex) {
    $element = new RegisteredElement([VariousTypesHandler::class, 'comprehensiveArgumentTest']);
    $allArgs = [ /* fill with defaults as in valid_type_casts */
        'strParam' => 's',
        'intParam' => 1,
        'boolProp' => true,
        'floatParam' => 1.1,
        'arrayParam' => [],
        'backedStringEnumParam' => BackedStringEnum::OptionA,
        'backedIntEnumParam' => BackedIntEnum::First,
        'unitEnumParam' => UnitEnum::Yes,
        'nullableStringParam' => null,
        'mixedParam' => 'mix',
        'objectParam' => new stdClass(),
        'stringForIntCast' => '0',
        'stringForFloatCast' => '0.0',
        'stringForBoolTrueCast' => 'false',
        'stringForBoolFalseCast' => 'true',
        'intForStringCast' => 0,
        'intForFloatCast' => 0,
        'boolForStringCast' => false,
        'valueForBackedStringEnum' => 'A',
        'valueForBackedIntEnum' => 1,
    ];
    $testArgs = array_merge($allArgs, [$paramName => $invalidValue]);

    try {
        $element->handle($this->container, $testArgs, $this->context);
    } catch (McpServerException $e) {
        expect($e->getMessage())->toMatch($expectedMsgRegex);
    }
})->with('invalid_type_casts');

it('casts to BackedStringEnum correctly', function () {
    $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']);
    $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 1], $this->context);
    expect($result['pBackedString'])->toBe(BackedStringEnum::OptionA);
});

it('throws for invalid BackedStringEnum value', function () {
    $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']);
    $element->handle($this->container, ['pBackedString' => 'Invalid', 'pBackedInt' => 1], $this->context);
})->throws(McpServerException::class, "Invalid value 'Invalid' for backed enum");

it('casts to BackedIntEnum correctly', function () {
    $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']);
    $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 2], $this->context);
    expect($result['pBackedInt'])->toBe(BackedIntEnum::Second);
});

it('throws for invalid BackedIntEnum value', function () {
    $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']);
    $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 999], $this->context);
})->throws(McpServerException::class, "Invalid value '999' for backed enum");

it('casts to UnitEnum correctly', function () {
    $element = new RegisteredElement([VariousTypesHandler::class, 'unitEnumArg']);
    $result = $element->handle($this->container, ['pUnitEnum' => 'Yes'], $this->context);
    expect($result['pUnitEnum'])->toBe(UnitEnum::Yes);
});

it('throws for invalid UnitEnum value', function () {
    $element = new RegisteredElement([VariousTypesHandler::class, 'unitEnumArg']);
    $element->handle($this->container, ['pUnitEnum' => 'Invalid'], $this->context);
})->throws(McpServerException::class, "Invalid value 'Invalid' for unit enum");


it('throws ReflectionException if handler method does not exist', function () {
    $element = new RegisteredElement([VariousTypesHandler::class, 'nonExistentMethod']);
    $element->handle($this->container, [], $this->context);
})->throws(\ReflectionException::class, "VariousTypesHandler::nonExistentMethod() does not exist");

it('passes Context object', function() {
    $sessionMock = Mockery::mock(SessionInterface::class);
    $sessionMock->expects('get')->with('testKey')->andReturn('testValue');
    $requestMock = Mockery::mock(ServerRequestInterface::class);
    $requestMock->expects('getHeaderLine')->with('testHeader')->andReturn('testHeaderValue');

    $context = new Context($sessionMock, $requestMock);
    $element = new RegisteredElement([VariousTypesHandler::class, 'contextArg']);
    $result = $element->handle($this->container, [], $context);
    expect($result)->toBe([
        'session' => 'testValue',
        'request' => 'testHeaderValue'
    ]);
});

describe('Handler Types', function () {
    it('handles invokable class handler', function () {
        $this->container->shouldReceive('get')
            ->with(MyInvokableTestHandler::class)
            ->andReturn(new MyInvokableTestHandler());

        $element = new RegisteredElement(MyInvokableTestHandler::class);
        $result = $element->handle($this->container, ['name' => 'World'], $this->context);

        expect($result)->toBe('Hello, World!');
    });

    it('handles closure handler', function () {
        $closure = function (string $a, string $b) {
            return $a . $b;
        };
        $element = new RegisteredElement($closure);
        $result = $element->handle($this->container, ['a' => 'foo', 'b' => 'bar'], $this->context);
        expect($result)->toBe('foobar');
    });

    it('handles static method handler', function () {
        $handler = [MyStaticMethodTestHandler::class, 'myStaticMethod'];
        $element = new RegisteredElement($handler);
        $result = $element->handle($this->container, ['a' => 5, 'b' => 10], $this->context);
        expect($result)->toBe(15);
    });

    it('handles global function name handler', function () {
        $handler = 'PhpMcp\Server\Tests\Unit\Elements\my_global_test_function';
        $element = new RegisteredElement($handler);
        $result = $element->handle($this->container, ['flag' => true], $this->context);
        expect($result)->toBe('on');
    });
});

```

--------------------------------------------------------------------------------
/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php:
--------------------------------------------------------------------------------

```php
<?php

namespace Mcp\SchemaShowcaseExample;

use DateTime;
use DateInterval;
use PhpMcp\Server\Attributes\McpTool;
use PhpMcp\Server\Attributes\Schema;

class SchemaShowcaseElements
{
    /**
     * Validates and formats text with string constraints.
     * Demonstrates: minLength, maxLength, pattern validation.
     */
    #[McpTool(
        name: 'format_text',
        description: 'Formats text with validation constraints. Text must be 5-100 characters and contain only letters, numbers, spaces, and basic punctuation.'
    )]
    public function formatText(
        #[Schema(
            type: 'string',
            description: 'The text to format',
            minLength: 5,
            maxLength: 100,
            pattern: '^[a-zA-Z0-9\s\.,!?\-]+$'
        )]
        string $text,

        #[Schema(
            type: 'string',
            description: 'Format style',
            enum: ['uppercase', 'lowercase', 'title', 'sentence']
        )]
        string $format = 'sentence'
    ): array {
        fwrite(STDERR, "Format text tool called: text='$text', format='$format'\n");

        $formatted = match ($format) {
            'uppercase' => strtoupper($text),
            'lowercase' => strtolower($text),
            'title' => ucwords(strtolower($text)),
            'sentence' => ucfirst(strtolower($text)),
            default => $text
        };

        return [
            'original' => $text,
            'formatted' => $formatted,
            'length' => strlen($text),
            'format_applied' => $format
        ];
    }

    /**
     * Performs mathematical operations with numeric constraints.
     * 
     * Demonstrates: METHOD-LEVEL Schema
     */
    #[McpTool(name: 'calculate_range')]
    #[Schema(
        type: 'object',
        properties: [
            'first' => [
                'type' => 'number',
                'description' => 'First number (must be between 0 and 1000)',
                'minimum' => 0,
                'maximum' => 1000
            ],
            'second' => [
                'type' => 'number',
                'description' => 'Second number (must be between 0 and 1000)',
                'minimum' => 0,
                'maximum' => 1000
            ],
            'operation' => [
                'type' => 'string',
                'description' => 'Operation to perform',
                'enum' => ['add', 'subtract', 'multiply', 'divide', 'power']
            ],
            'precision' => [
                'type' => 'integer',
                'description' => 'Decimal precision (must be multiple of 2, between 0-10)',
                'minimum' => 0,
                'maximum' => 10,
                'multipleOf' => 2
            ]
        ],
        required: ['first', 'second', 'operation'],
    )]
    public function calculateRange(float $first, float $second, string $operation, int $precision = 2): array
    {
        fwrite(STDERR, "Calculate range tool called: $first $operation $second (precision: $precision)\n");

        $result = match ($operation) {
            'add' => $first + $second,
            'subtract' => $first - $second,
            'multiply' => $first * $second,
            'divide' => $second != 0 ? $first / $second : null,
            'power' => pow($first, $second),
            default => null
        };

        if ($result === null) {
            return [
                'error' => $operation === 'divide' ? 'Division by zero' : 'Invalid operation',
                'inputs' => compact('first', 'second', 'operation', 'precision')
            ];
        }

        return [
            'result' => round($result, $precision),
            'operation' => "$first $operation $second",
            'precision' => $precision,
            'within_bounds' => $result >= 0 && $result <= 1000000
        ];
    }

    /**
     * Processes user profile data with object schema validation.
     * Demonstrates: object properties, required fields, additionalProperties.
     */
    #[McpTool(
        name: 'validate_profile',
        description: 'Validates and processes user profile data with strict schema requirements.'
    )]
    public function validateProfile(
        #[Schema(
            type: 'object',
            description: 'User profile information',
            properties: [
                'name' => [
                    'type' => 'string',
                    'minLength' => 2,
                    'maxLength' => 50,
                    'description' => 'Full name'
                ],
                'email' => [
                    'type' => 'string',
                    'format' => 'email',
                    'description' => 'Valid email address'
                ],
                'age' => [
                    'type' => 'integer',
                    'minimum' => 13,
                    'maximum' => 120,
                    'description' => 'Age in years'
                ],
                'role' => [
                    'type' => 'string',
                    'enum' => ['user', 'admin', 'moderator', 'guest'],
                    'description' => 'User role'
                ],
                'preferences' => [
                    'type' => 'object',
                    'properties' => [
                        'notifications' => ['type' => 'boolean'],
                        'theme' => ['type' => 'string', 'enum' => ['light', 'dark', 'auto']]
                    ],
                    'additionalProperties' => false
                ]
            ],
            required: ['name', 'email', 'age'],
            additionalProperties: true
        )]
        array $profile
    ): array {
        fwrite(STDERR, "Validate profile tool called with: " . json_encode($profile) . "\n");

        $errors = [];
        $warnings = [];

        // Additional business logic validation
        if (isset($profile['age']) && $profile['age'] < 18 && ($profile['role'] ?? 'user') === 'admin') {
            $errors[] = 'Admin role requires age 18 or older';
        }

        if (isset($profile['email']) && !filter_var($profile['email'], FILTER_VALIDATE_EMAIL)) {
            $errors[] = 'Invalid email format';
        }

        if (!isset($profile['role'])) {
            $warnings[] = 'No role specified, defaulting to "user"';
            $profile['role'] = 'user';
        }

        return [
            'valid' => empty($errors),
            'profile' => $profile,
            'errors' => $errors,
            'warnings' => $warnings,
            'processed_at' => date('Y-m-d H:i:s')
        ];
    }

    /**
     * Manages a list of items with array constraints.
     * Demonstrates: array items, minItems, maxItems, uniqueItems.
     */
    #[McpTool(
        name: 'manage_list',
        description: 'Manages a list of items with size and uniqueness constraints.'
    )]
    public function manageList(
        #[Schema(
            type: 'array',
            description: 'List of items to manage (2-10 unique strings)',
            items: [
                'type' => 'string',
                'minLength' => 1,
                'maxLength' => 30
            ],
            minItems: 2,
            maxItems: 10,
            uniqueItems: true
        )]
        array $items,

        #[Schema(
            type: 'string',
            description: 'Action to perform on the list',
            enum: ['sort', 'reverse', 'shuffle', 'deduplicate', 'filter_short', 'filter_long']
        )]
        string $action = 'sort'
    ): array {
        fwrite(STDERR, "Manage list tool called with " . count($items) . " items, action: $action\n");

        $original = $items;
        $processed = $items;

        switch ($action) {
            case 'sort':
                sort($processed);
                break;
            case 'reverse':
                $processed = array_reverse($processed);
                break;
            case 'shuffle':
                shuffle($processed);
                break;
            case 'deduplicate':
                $processed = array_unique($processed);
                break;
            case 'filter_short':
                $processed = array_filter($processed, fn($item) => strlen($item) <= 10);
                break;
            case 'filter_long':
                $processed = array_filter($processed, fn($item) => strlen($item) > 10);
                break;
        }

        return [
            'original_count' => count($original),
            'processed_count' => count($processed),
            'action' => $action,
            'original' => $original,
            'processed' => array_values($processed), // Re-index array
            'stats' => [
                'average_length' => count($processed) > 0 ? round(array_sum(array_map('strlen', $processed)) / count($processed), 2) : 0,
                'shortest' => count($processed) > 0 ? min(array_map('strlen', $processed)) : 0,
                'longest' => count($processed) > 0 ? max(array_map('strlen', $processed)) : 0,
            ]
        ];
    }

    /**
     * Generates configuration with format validation.
     * Demonstrates: format constraints (date-time, uri, etc).
     */
    #[McpTool(
        name: 'generate_config',
        description: 'Generates configuration with format-validated inputs.'
    )]
    public function generateConfig(
        #[Schema(
            type: 'string',
            description: 'Application name (alphanumeric with hyphens)',
            pattern: '^[a-zA-Z0-9\-]+$',
            minLength: 3,
            maxLength: 20
        )]
        string $appName,

        #[Schema(
            type: 'string',
            description: 'Valid URL for the application',
            format: 'uri'
        )]
        string $baseUrl,

        #[Schema(
            type: 'string',
            description: 'Environment type',
            enum: ['development', 'staging', 'production']
        )]
        string $environment = 'development',

        #[Schema(
            type: 'boolean',
            description: 'Enable debug mode'
        )]
        bool $debug = true,

        #[Schema(
            type: 'integer',
            description: 'Port number (1024-65535)',
            minimum: 1024,
            maximum: 65535
        )]
        int $port = 8080
    ): array {
        fwrite(STDERR, "Generate config tool called for app: $appName\n");

        $config = [
            'app' => [
                'name' => $appName,
                'env' => $environment,
                'debug' => $debug,
                'url' => $baseUrl,
                'port' => $port,
            ],
            'generated_at' => date('c'), // ISO 8601 format
            'version' => '1.0.0',
            'features' => [
                'logging' => $environment !== 'production' || $debug,
                'caching' => $environment === 'production',
                'analytics' => $environment === 'production',
                'rate_limiting' => $environment !== 'development',
            ]
        ];

        return [
            'success' => true,
            'config' => $config,
            'validation' => [
                'app_name_valid' => preg_match('/^[a-zA-Z0-9\-]+$/', $appName) === 1,
                'url_valid' => filter_var($baseUrl, FILTER_VALIDATE_URL) !== false,
                'port_in_range' => $port >= 1024 && $port <= 65535,
            ]
        ];
    }

    /**
     * Processes time-based data with date-time format validation.
     * Demonstrates: date-time format, exclusiveMinimum, exclusiveMaximum.
     */
    #[McpTool(
        name: 'schedule_event',
        description: 'Schedules an event with time validation and constraints.'
    )]
    public function scheduleEvent(
        #[Schema(
            type: 'string',
            description: 'Event title (3-50 characters)',
            minLength: 3,
            maxLength: 50
        )]
        string $title,

        #[Schema(
            type: 'string',
            description: 'Event start time in ISO 8601 format',
            format: 'date-time'
        )]
        string $startTime,

        #[Schema(
            type: 'number',
            description: 'Duration in hours (minimum 0.5, maximum 24)',
            minimum: 0.5,
            maximum: 24,
            multipleOf: 0.5
        )]
        float $durationHours,

        #[Schema(
            type: 'string',
            description: 'Event priority level',
            enum: ['low', 'medium', 'high', 'urgent']
        )]
        string $priority = 'medium',

        #[Schema(
            type: 'array',
            description: 'List of attendee email addresses',
            items: [
                'type' => 'string',
                'format' => 'email'
            ],
            maxItems: 20
        )]
        array $attendees = []
    ): array {
        fwrite(STDERR, "Schedule event tool called: $title at $startTime\n");

        $start = DateTime::createFromFormat(DateTime::ISO8601, $startTime);
        if (!$start) {
            $start = DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $startTime);
        }

        if (!$start) {
            return [
                'success' => false,
                'error' => 'Invalid date-time format. Use ISO 8601 format.',
                'example' => '2024-01-15T14:30:00Z'
            ];
        }

        $end = clone $start;
        $end->add(new DateInterval('PT' . ($durationHours * 60) . 'M'));

        $event = [
            'id' => uniqid('event_'),
            'title' => $title,
            'start_time' => $start->format('c'),
            'end_time' => $end->format('c'),
            'duration_hours' => $durationHours,
            'priority' => $priority,
            'attendees' => $attendees,
            'created_at' => date('c')
        ];

        return [
            'success' => true,
            'event' => $event,
            'info' => [
                'attendee_count' => count($attendees),
                'is_all_day' => $durationHours >= 24,
                'is_future' => $start > new DateTime(),
                'timezone_note' => 'Times are in UTC'
            ]
        ];
    }
}

```

--------------------------------------------------------------------------------
/tests/Unit/Elements/RegisteredPromptTest.php:
--------------------------------------------------------------------------------

```php
<?php

namespace PhpMcp\Server\Tests\Unit\Elements;

use Mockery;
use PhpMcp\Schema\Prompt as PromptSchema;
use PhpMcp\Schema\PromptArgument;
use PhpMcp\Server\Context;
use PhpMcp\Server\Contracts\SessionInterface;
use PhpMcp\Server\Elements\RegisteredPrompt;
use PhpMcp\Schema\Content\PromptMessage;
use PhpMcp\Schema\Enum\Role;
use PhpMcp\Schema\Content\TextContent;
use PhpMcp\Schema\Content\ImageContent;
use PhpMcp\Schema\Content\AudioContent;
use PhpMcp\Schema\Content\EmbeddedResource;
use PhpMcp\Server\Tests\Fixtures\Enums\StatusEnum;
use PhpMcp\Server\Tests\Fixtures\General\PromptHandlerFixture;
use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture;
use PhpMcp\Server\Tests\Unit\Attributes\TestEnum;
use Psr\Container\ContainerInterface;

beforeEach(function () {
    $this->container = Mockery::mock(ContainerInterface::class);
    $this->container->shouldReceive('get')
        ->with(PromptHandlerFixture::class)
        ->andReturn(new PromptHandlerFixture())
        ->byDefault();

    $this->promptSchema = PromptSchema::make(
        'test-greeting-prompt',
        'Generates a greeting.',
        [PromptArgument::make('name', 'The name to greet.', true)]
    );
    
    $this->context = new Context(Mockery::mock(SessionInterface::class));
});

it('constructs correctly with schema, handler, and completion providers', function () {
    $providers = ['name' => CompletionProviderFixture::class];
    $prompt = RegisteredPrompt::make(
        $this->promptSchema,
        [PromptHandlerFixture::class, 'promptWithArgumentCompletion'],
        false,
        $providers
    );

    expect($prompt->schema)->toBe($this->promptSchema);
    expect($prompt->handler)->toBe([PromptHandlerFixture::class, 'promptWithArgumentCompletion']);
    expect($prompt->isManual)->toBeFalse();
    expect($prompt->completionProviders)->toEqual($providers);
    expect($prompt->completionProviders['name'])->toBe(CompletionProviderFixture::class);
    expect($prompt->completionProviders)->not->toHaveKey('nonExistentArg');
});

it('can be made as a manual registration', function () {
    $manualPrompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'generateSimpleGreeting'], true);
    expect($manualPrompt->isManual)->toBeTrue();
});

it('calls handler with prepared arguments via get()', function () {
    $handlerMock = Mockery::mock(PromptHandlerFixture::class);
    $handlerMock->shouldReceive('generateSimpleGreeting')
        ->with('Alice', 'warm')
        ->once()
        ->andReturn([['role' => 'user', 'content' => 'Warm greeting for Alice.']]);
    $this->container->shouldReceive('get')->with(PromptHandlerFixture::class)->andReturn($handlerMock);

    $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'generateSimpleGreeting']);
    $messages = $prompt->get($this->container, ['name' => 'Alice', 'style' => 'warm'], $this->context);

    expect($messages[0]->content->text)->toBe('Warm greeting for Alice.');
});

it('formats single PromptMessage object from handler', function () {
    $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnSinglePromptMessageObject']);
    $messages = $prompt->get($this->container, [], $this->context);
    expect($messages)->toBeArray()->toHaveCount(1);
    expect($messages[0])->toBeInstanceOf(PromptMessage::class);
    expect($messages[0]->content->text)->toBe("Single PromptMessage object.");
});

it('formats array of PromptMessage objects from handler as is', function () {
    $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnArrayOfPromptMessageObjects']);
    $messages = $prompt->get($this->container, [], $this->context);
    expect($messages)->toBeArray()->toHaveCount(2);
    expect($messages[0]->content->text)->toBe("First message object.");
    expect($messages[1]->content)->toBeInstanceOf(ImageContent::class);
});

it('formats empty array from handler as empty array', function () {
    $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnEmptyArrayForPrompt']);
    $messages = $prompt->get($this->container, [], $this->context);
    expect($messages)->toBeArray()->toBeEmpty();
});

it('formats simple user/assistant map from handler', function () {
    $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnSimpleUserAssistantMap']);
    $messages = $prompt->get($this->container, [], $this->context);
    expect($messages)->toHaveCount(2);
    expect($messages[0]->role)->toBe(Role::User);
    expect($messages[0]->content->text)->toBe("This is the user's turn.");
    expect($messages[1]->role)->toBe(Role::Assistant);
    expect($messages[1]->content->text)->toBe("And this is the assistant's reply.");
});

it('formats user/assistant map with Content objects', function () {
    $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithContentObjects']);
    $messages = $prompt->get($this->container, [], $this->context);
    expect($messages[0]->role)->toBe(Role::User);
    expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User text content object.");
    expect($messages[1]->role)->toBe(Role::Assistant);
    expect($messages[1]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("asst_img_data");
});

it('formats user/assistant map with mixed content (string and Content object)', function () {
    $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithMixedContent']);
    $messages = $prompt->get($this->container, [], $this->context);
    expect($messages[0]->role)->toBe(Role::User);
    expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("Plain user string.");
    expect($messages[1]->role)->toBe(Role::Assistant);
    expect($messages[1]->content)->toBeInstanceOf(AudioContent::class)->data->toBe("aud_data");
});

it('formats user/assistant map with array content', function () {
    $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithArrayContent']);
    $messages = $prompt->get($this->container, [], $this->context);
    expect($messages[0]->role)->toBe(Role::User);
    expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User array content");
    expect($messages[1]->role)->toBe(Role::Assistant);
    expect($messages[1]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("asst_arr_img_data");
});

it('formats list of raw message arrays with various content types', function () {
    $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnListOfRawMessageArrays']);
    $messages = $prompt->get($this->container, [], $this->context);
    expect($messages)->toHaveCount(6);
    expect($messages[0]->content->text)->toBe("First raw message string.");
    expect($messages[1]->content)->toBeInstanceOf(TextContent::class);
    expect($messages[2]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("raw_img_data");
    expect($messages[3]->content)->toBeInstanceOf(AudioContent::class)->data->toBe("raw_aud_data");
    expect($messages[4]->content)->toBeInstanceOf(EmbeddedResource::class);
    expect($messages[4]->content->resource->blob)->toBe(base64_encode('pdf-data'));
    expect($messages[5]->content)->toBeInstanceOf(EmbeddedResource::class);
    expect($messages[5]->content->resource->text)->toBe('{"theme":"dark"}');
});

it('formats list of raw message arrays with scalar or array content (becoming JSON TextContent)', function () {
    $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnListOfRawMessageArraysWithScalars']);
    $messages = $prompt->get($this->container, [], $this->context);
    expect($messages)->toHaveCount(5);
    expect($messages[0]->content->text)->toBe("123");
    expect($messages[1]->content->text)->toBe("true");
    expect($messages[2]->content->text)->toBe("(null)");
    expect($messages[3]->content->text)->toBe("3.14");
    expect($messages[4]->content->text)->toBe(json_encode(['key' => 'value'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
});

it('formats mixed array of PromptMessage objects and raw message arrays', function () {
    $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnMixedArrayOfPromptMessagesAndRaw']);
    $messages = $prompt->get($this->container, [], $this->context);
    expect($messages)->toHaveCount(4);
    expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("This is a PromptMessage object.");
    expect($messages[1]->content)->toBeInstanceOf(TextContent::class)->text->toBe("This is a raw message array.");
    expect($messages[2]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("pm_img");
    expect($messages[3]->content)->toBeInstanceOf(TextContent::class)->text->toBe("Raw message with typed content.");
});


dataset('prompt_format_errors', [
    'non_array_return' => ['promptReturnsNonArray', '/Prompt generator method must return an array/'],
    'invalid_role_in_array' => ['promptReturnsInvalidRole', "/Invalid role 'system'/"],
    'invalid_content_structure_in_array' => ['promptReturnsArrayWithInvalidContentStructure', "/Invalid message format at index 0. Expected an array with 'role' and 'content' keys./"], // More specific from formatMessage
    'invalid_typed_content_in_array' => ['promptReturnsArrayWithInvalidTypedContent', "/Invalid 'image' content at index 0: Missing or invalid 'data' string/"],
    'invalid_resource_content_in_array' => ['promptReturnsArrayWithInvalidResourceContent', "/Invalid resource at index 0: Must contain 'text' or 'blob'./"],
]);

it('throws RuntimeException for invalid prompt result formats', function (string|callable $handlerMethodOrCallable, string $expectedErrorPattern) {
    $methodName = is_string($handlerMethodOrCallable) ? $handlerMethodOrCallable : 'customReturn';
    $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, $methodName]);

    if (is_callable($handlerMethodOrCallable)) {
        $this->container->shouldReceive('get')->with(PromptHandlerFixture::class)->andReturn(
            Mockery::mock(PromptHandlerFixture::class, [$methodName => $handlerMethodOrCallable()])
        );
    }

    try {
        $prompt->get($this->container, [], $this->context);
    } catch (\RuntimeException $e) {
        expect($e->getMessage())->toMatch($expectedErrorPattern);
    }

    expect($prompt->toArray())->toBeArray();
})->with('prompt_format_errors');


it('propagates exceptions from handler during get()', function () {
    $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'promptHandlerThrows']);
    $prompt->get($this->container, [], $this->context);
})->throws(\LogicException::class, "Prompt generation failed inside handler.");


it('can be serialized to array and deserialized with completion providers', function () {
    $schema = PromptSchema::make(
        'serialize-prompt',
        'Test SerDe',
        [PromptArgument::make('arg1', required: true), PromptArgument::make('arg2', 'description for arg2')]
    );
    $providers = ['arg1' => CompletionProviderFixture::class];
    $serializedProviders = ['arg1' => serialize(CompletionProviderFixture::class)];
    $original = RegisteredPrompt::make(
        $schema,
        [PromptHandlerFixture::class, 'generateSimpleGreeting'],
        true,
        $providers
    );

    $array = $original->toArray();

    expect($array['schema']['name'])->toBe('serialize-prompt');
    expect($array['schema']['arguments'])->toHaveCount(2);
    expect($array['handler'])->toBe([PromptHandlerFixture::class, 'generateSimpleGreeting']);
    expect($array['isManual'])->toBeTrue();
    expect($array['completionProviders'])->toEqual($serializedProviders);

    $rehydrated = RegisteredPrompt::fromArray($array);
    expect($rehydrated)->toBeInstanceOf(RegisteredPrompt::class);
    expect($rehydrated->schema->name)->toEqual($original->schema->name);
    expect($rehydrated->isManual)->toBeTrue();
    expect($rehydrated->completionProviders)->toEqual($providers);
});

it('fromArray returns false on failure for prompt', function () {
    $badData = ['schema' => ['name' => 'fail']];
    expect(RegisteredPrompt::fromArray($badData))->toBeFalse();
});

it('can be serialized with ListCompletionProvider instances', function () {
    $schema = PromptSchema::make(
        'list-prompt',
        'Test list completion',
        [PromptArgument::make('status')]
    );
    $listProvider = new \PhpMcp\Server\Defaults\ListCompletionProvider(['draft', 'published', 'archived']);
    $providers = ['status' => $listProvider];

    $original = RegisteredPrompt::make(
        $schema,
        [PromptHandlerFixture::class, 'generateSimpleGreeting'],
        true,
        $providers
    );

    $array = $original->toArray();
    expect($array['completionProviders']['status'])->toBeString(); // Serialized instance

    $rehydrated = RegisteredPrompt::fromArray($array);
    expect($rehydrated->completionProviders['status'])->toBeInstanceOf(\PhpMcp\Server\Defaults\ListCompletionProvider::class);
});

it('can be serialized with EnumCompletionProvider instances', function () {
    $schema = PromptSchema::make(
        'enum-prompt',
        'Test enum completion',
        [PromptArgument::make('priority')]
    );

    $enumProvider = new \PhpMcp\Server\Defaults\EnumCompletionProvider(StatusEnum::class);
    $providers = ['priority' => $enumProvider];

    $original = RegisteredPrompt::make(
        $schema,
        [PromptHandlerFixture::class, 'generateSimpleGreeting'],
        true,
        $providers
    );

    $array = $original->toArray();
    expect($array['completionProviders']['priority'])->toBeString(); // Serialized instance

    $rehydrated = RegisteredPrompt::fromArray($array);
    expect($rehydrated->completionProviders['priority'])->toBeInstanceOf(\PhpMcp\Server\Defaults\EnumCompletionProvider::class);
});

```

--------------------------------------------------------------------------------
/tests/Integration/StdioServerTransportTest.php:
--------------------------------------------------------------------------------

```php
<?php

use PhpMcp\Server\Protocol;
use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture;
use React\ChildProcess\Process;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;

use function React\Async\await;

const STDIO_SERVER_SCRIPT_PATH = __DIR__ . '/../Fixtures/ServerScripts/StdioTestServer.php';
const PROCESS_TIMEOUT_SECONDS = 5;

function sendRequestToServer(Process $process, string $requestId, string $method, array $params = []): void
{
    $request = json_encode([
        'jsonrpc' => '2.0',
        'id' => $requestId,
        'method' => $method,
        'params' => $params,
    ]);
    $process->stdin->write($request . "\n");
}

function sendNotificationToServer(Process $process, string $method, array $params = []): void
{
    $notification = json_encode([
        'jsonrpc' => '2.0',
        'method' => $method,
        'params' => $params,
    ]);

    $process->stdin->write($notification . "\n");
}

function readResponseFromServer(Process $process, string $expectedRequestId, LoopInterface $loop): PromiseInterface
{
    $deferred = new Deferred();
    $buffer = '';

    $dataListener = function ($chunk) use (&$buffer, $deferred, $expectedRequestId, $process, &$dataListener) {
        $buffer .= $chunk;
        if (str_contains($buffer, "\n")) {
            $lines = explode("\n", $buffer);
            $buffer = array_pop($lines);

            foreach ($lines as $line) {
                if (empty(trim($line))) {
                    continue;
                }
                try {
                    $response = json_decode(trim($line), true);
                    if (array_key_exists('id', $response) && $response['id'] == $expectedRequestId) {
                        $process->stdout->removeListener('data', $dataListener);
                        $deferred->resolve($response);
                        return;
                    } elseif (isset($response['method']) && str_starts_with($response['method'], 'notifications/')) {
                        // It's a notification, log it or handle if necessary for a specific test, but don't resolve
                    }
                } catch (\JsonException $e) {
                    $process->stdout->removeListener('data', $dataListener);
                    $deferred->reject(new \RuntimeException("Failed to decode JSON response: " . $line, 0, $e));
                    return;
                }
            }
        }
    };

    $process->stdout->on('data', $dataListener);

    return timeout($deferred->promise(), PROCESS_TIMEOUT_SECONDS, $loop)
        ->catch(function ($reason) use ($expectedRequestId) {
            if ($reason instanceof \RuntimeException && str_contains($reason->getMessage(), 'Timed out after')) {
                throw new \RuntimeException("Timeout waiting for response to request ID '{$expectedRequestId}'");
            }
            throw $reason;
        })
        ->finally(function () use ($process, $dataListener) {
            $process->stdout->removeListener('data', $dataListener);
        });
}

beforeEach(function () {
    $this->loop = Loop::get();

    if (!is_executable(STDIO_SERVER_SCRIPT_PATH)) {
        chmod(STDIO_SERVER_SCRIPT_PATH, 0755);
    }

    $phpPath = PHP_BINARY ?: 'php';
    $command = escapeshellarg($phpPath) . ' ' . escapeshellarg(STDIO_SERVER_SCRIPT_PATH);
    $this->process = new Process($command);
    $this->process->start($this->loop);

    $this->processErrorOutput = '';
    $this->process->stderr->on('data', function ($chunk) {
        $this->processErrorOutput .= $chunk;
    });
});

afterEach(function () {
    if ($this->process instanceof Process && $this->process->isRunning()) {
        if ($this->process->stdin->isWritable()) {
            $this->process->stdin->end();
        }
        $this->process->stdout->close();
        $this->process->stdin->close();
        $this->process->stderr->close();
        $this->process->terminate(SIGTERM);
        await(delay(0.05, $this->loop));
        if ($this->process->isRunning()) {
            $this->process->terminate(SIGKILL);
        }
    }
    $this->process = null;
});

it('starts the stdio server, initializes, calls a tool, and closes', function () {
    // 1. Initialize Request
    sendRequestToServer($this->process, 'init-1', 'initialize', [
        'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION,
        'clientInfo' => ['name' => 'PestTestClient', 'version' => '1.0'],
        'capabilities' => []
    ]);
    $initResponse = await(readResponseFromServer($this->process, 'init-1', $this->loop));

    expect($initResponse['id'])->toBe('init-1');
    expect($initResponse)->not->toHaveKey('error');
    expect($initResponse['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION);
    expect($initResponse['result']['serverInfo']['name'])->toBe('StdioIntegrationTestServer');

    // 2. Initialized Notification
    sendNotificationToServer($this->process, 'notifications/initialized');

    await(delay(0.05, $this->loop));

    // 3. Call a tool
    sendRequestToServer($this->process, 'tool-call-1', 'tools/call', [
        'name' => 'greet_stdio_tool',
        'arguments' => ['name' => 'Integration Tester']
    ]);
    $toolResponse = await(readResponseFromServer($this->process, 'tool-call-1', $this->loop));

    expect($toolResponse['id'])->toBe('tool-call-1');
    expect($toolResponse)->not->toHaveKey('error');
    expect($toolResponse['result']['content'][0]['text'])->toBe('Hello, Integration Tester!');
    expect($toolResponse['result']['isError'])->toBeFalse();

    $this->process->stdin->end();
})->group('integration', 'stdio_transport');

it('can handle invalid JSON request from client', function () {
    $this->process->stdin->write("this is not json\n");

    $response = await(readResponseFromServer($this->process, '', $this->loop));

    expect($response['id'])->toBe('');
    expect($response['error']['code'])->toBe(-32700);
    expect($response['error']['message'])->toContain('Invalid JSON');

    $this->process->stdin->end();
})->group('integration', 'stdio_transport');

it('handles request for non-existent method', function () {
    sendRequestToServer($this->process, 'init-err', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]);
    await(readResponseFromServer($this->process, 'init-err', $this->loop));

    sendNotificationToServer($this->process, 'notifications/initialized');
    await(delay(0.05, $this->loop));

    sendRequestToServer($this->process, 'err-meth-1', 'non/existentMethod', []);
    $response = await(readResponseFromServer($this->process, 'err-meth-1', $this->loop));

    expect($response['id'])->toBe('err-meth-1');
    expect($response['error']['code'])->toBe(-32601);
    expect($response['error']['message'])->toContain("Method 'non/existentMethod' not found");

    $this->process->stdin->end();
})->group('integration', 'stdio_transport');

it('can handle batch requests correctly', function () {
    // 1. Initialize
    sendRequestToServer($this->process, 'init-batch', 'initialize', [
        'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION,
        'clientInfo' => ['name' => 'BatchClient', 'version' => '1.0'],
        'capabilities' => []
    ]);
    await(readResponseFromServer($this->process, 'init-batch', $this->loop));
    sendNotificationToServer($this->process, 'notifications/initialized');
    await(delay(0.05, $this->loop));

    // 2. Send Batch Request
    $batchRequests = [
        ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_stdio_tool', 'arguments' => ['name' => 'Batch Item 1']]],
        ['jsonrpc' => '2.0', 'method' => 'notifications/something'],
        ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'greet_stdio_tool', 'arguments' => ['name' => 'Batch Item 2']]],
        ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method']
    ];

    $rawBatchRequest = json_encode($batchRequests);
    $this->process->stdin->write($rawBatchRequest . "\n");

    // 3. Read Batch Response
    $batchResponsePromise = new Deferred();
    $fullBuffer = '';
    $batchDataListener = function ($chunk) use (&$fullBuffer, $batchResponsePromise, &$batchDataListener) {
        $fullBuffer .= $chunk;
        if (str_contains($fullBuffer, "\n")) {
            $line = trim($fullBuffer);
            $fullBuffer = '';
            try {
                $decoded = json_decode($line, true);
                if (is_array($decoded)) { // Batch response is an array
                    $this->process->stdout->removeListener('data', $batchDataListener);
                    $batchResponsePromise->resolve($decoded);
                }
            } catch (\JsonException $e) {
                $this->process->stdout->removeListener('data', $batchDataListener);
                $batchResponsePromise->reject(new \RuntimeException("Batch JSON decode failed: " . $line, 0, $e));
            }
        }
    };
    $this->process->stdout->on('data', $batchDataListener);

    $batchResponseArray = await(timeout($batchResponsePromise->promise(), PROCESS_TIMEOUT_SECONDS, $this->loop));

    expect($batchResponseArray)->toBeArray()->toHaveCount(3); // greet1, greet2, error

    $response1 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-1'))[0] ?? null;
    $response2 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-2'))[0] ?? null;
    $response3 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-3'))[0] ?? null;

    expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!');
    expect($response2['result']['content'][0]['text'])->toBe('Hello, Batch Item 2!');
    expect($response3['error']['code'])->toBe(-32601);
    expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found");


    $this->process->stdin->end();
})->group('integration', 'stdio_transport');

it('can passes an empty context', function () {
    sendRequestToServer($this->process, 'init-context', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]);
    await(readResponseFromServer($this->process, 'init-context', $this->loop));
    sendNotificationToServer($this->process, 'notifications/initialized');
    await(delay(0.05, $this->loop));

    sendRequestToServer($this->process, 'tool-context-1', 'tools/call', [
        'name' => 'tool_reads_context',
        'arguments' => []
    ]);
    $toolResponse = await(readResponseFromServer($this->process, 'tool-context-1', $this->loop));

    expect($toolResponse['id'])->toBe('tool-context-1');
    expect($toolResponse)->not->toHaveKey('error');
    expect($toolResponse['result']['content'][0]['text'])->toBe('No request instance present');
    expect($toolResponse['result']['isError'])->toBeFalse();

    $this->process->stdin->end();
})->group('integration', 'stdio_transport');

it('can handle tool list request', function () {
    sendRequestToServer($this->process, 'init-tool-list', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]);
    await(readResponseFromServer($this->process, 'init-tool-list', $this->loop));
    sendNotificationToServer($this->process, 'notifications/initialized');
    await(delay(0.05, $this->loop));

    sendRequestToServer($this->process, 'tool-list-1', 'tools/list', []);
    $toolListResponse = await(readResponseFromServer($this->process, 'tool-list-1', $this->loop));

    expect($toolListResponse['id'])->toBe('tool-list-1');
    expect($toolListResponse)->not->toHaveKey('error');
    expect($toolListResponse['result']['tools'])->toBeArray()->toHaveCount(2);
    expect($toolListResponse['result']['tools'][0]['name'])->toBe('greet_stdio_tool');
    expect($toolListResponse['result']['tools'][1]['name'])->toBe('tool_reads_context');

    $this->process->stdin->end();
})->group('integration', 'stdio_transport');

it('can read a registered resource', function () {
    sendRequestToServer($this->process, 'init-res', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]);
    await(readResponseFromServer($this->process, 'init-res', $this->loop));
    sendNotificationToServer($this->process, 'notifications/initialized');
    await(delay(0.05, $this->loop));

    sendRequestToServer($this->process, 'res-read-1', 'resources/read', ['uri' => 'test://stdio/static']);
    $resourceResponse = await(readResponseFromServer($this->process, 'res-read-1', $this->loop));

    expect($resourceResponse['id'])->toBe('res-read-1');
    expect($resourceResponse)->not->toHaveKey('error');
    expect($resourceResponse['result']['contents'])->toBeArray()->toHaveCount(1);
    expect($resourceResponse['result']['contents'][0]['uri'])->toBe('test://stdio/static');
    expect($resourceResponse['result']['contents'][0]['text'])->toBe(ResourceHandlerFixture::$staticTextContent);
    expect($resourceResponse['result']['contents'][0]['mimeType'])->toBe('text/plain');

    $this->process->stdin->end();
})->group('integration', 'stdio_transport');

it('can get a registered prompt', function () {
    sendRequestToServer($this->process, 'init-prompt', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]);
    await(readResponseFromServer($this->process, 'init-prompt', $this->loop));
    sendNotificationToServer($this->process, 'notifications/initialized');
    await(delay(0.05, $this->loop));

    sendRequestToServer($this->process, 'prompt-get-1', 'prompts/get', [
        'name' => 'simple_stdio_prompt',
        'arguments' => ['name' => 'StdioPromptUser']
    ]);
    $promptResponse = await(readResponseFromServer($this->process, 'prompt-get-1', $this->loop));

    expect($promptResponse['id'])->toBe('prompt-get-1');
    expect($promptResponse)->not->toHaveKey('error');
    expect($promptResponse['result']['messages'])->toBeArray()->toHaveCount(1);
    expect($promptResponse['result']['messages'][0]['role'])->toBe('user');
    expect($promptResponse['result']['messages'][0]['content']['text'])->toBe('Craft a friendly greeting for StdioPromptUser.');

    $this->process->stdin->end();
})->group('integration', 'stdio_transport');

it('handles client not sending initialized notification before other requests', function () {
    sendRequestToServer($this->process, 'init-no-ack', 'initialize', [
        'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION,
        'clientInfo' => ['name' => 'ForgetfulClient', 'version' => '1.0'],
        'capabilities' => []
    ]);
    await(readResponseFromServer($this->process, 'init-no-ack', $this->loop));
    // Client "forgets" to send notifications/initialized


    sendRequestToServer($this->process, 'tool-call-no-ack', 'tools/call', [
        'name' => 'greet_stdio_tool',
        'arguments' => ['name' => 'NoAckUser']
    ]);
    $toolResponse = await(readResponseFromServer($this->process, 'tool-call-no-ack', $this->loop));

    expect($toolResponse['id'])->toBe('tool-call-no-ack');
    expect($toolResponse['error']['code'])->toBe(-32600);
    expect($toolResponse['error']['message'])->toContain('Client session not initialized');

    $this->process->stdin->end();
})->group('integration', 'stdio_transport');

```

--------------------------------------------------------------------------------
/tests/Unit/Utils/SchemaValidatorTest.php:
--------------------------------------------------------------------------------

```php
<?php

namespace PhpMcp\Server\Tests\Unit\Utils;

use Mockery;
use PhpMcp\Server\Utils\SchemaValidator;
use Psr\Log\LoggerInterface;
use PhpMcp\Server\Attributes\Schema;
use PhpMcp\Server\Attributes\Schema\ArrayItems;
use PhpMcp\Server\Attributes\Schema\Format;
use PhpMcp\Server\Attributes\Schema\Property;

// --- Setup ---
beforeEach(function () {
    /** @var \Mockery\MockInterface&\Psr\Log\LoggerInterface */
    $this->loggerMock = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing();
    $this->validator = new SchemaValidator($this->loggerMock);
});

// --- Helper Data & Schemas ---
function getSimpleSchema(): array
{
    return [
        'type' => 'object',
        'properties' => [
            'name' => ['type' => 'string', 'description' => 'The name'],
            'age' => ['type' => 'integer', 'minimum' => 0],
            'active' => ['type' => 'boolean'],
            'score' => ['type' => 'number'],
            'items' => ['type' => 'array', 'items' => ['type' => 'string']],
            'nullableValue' => ['type' => ['string', 'null']],
            'optionalValue' => ['type' => 'string'], // Not required
        ],
        'required' => ['name', 'age', 'active', 'score', 'items', 'nullableValue'],
        'additionalProperties' => false,
    ];
}

function getValidData(): array
{
    return [
        'name' => 'Tester',
        'age' => 30,
        'active' => true,
        'score' => 99.5,
        'items' => ['a', 'b'],
        'nullableValue' => null,
        'optionalValue' => 'present',
    ];
}

// --- Basic Validation Tests ---

test('valid data passes validation', function () {
    $schema = getSimpleSchema();
    $data = getValidData();

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toBeEmpty();
});

test('invalid type generates type error', function () {
    $schema = getSimpleSchema();
    $data = getValidData();
    $data['age'] = 'thirty'; // Invalid type

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toHaveCount(1)
        ->and($errors[0]['pointer'])->toBe('/age')
        ->and($errors[0]['keyword'])->toBe('type')
        ->and($errors[0]['message'])->toContain('Expected `integer`');
});

test('missing required property generates required error', function () {
    $schema = getSimpleSchema();
    $data = getValidData();
    unset($data['name']); // Missing required

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toHaveCount(1)
        ->and($errors[0]['keyword'])->toBe('required')
        ->and($errors[0]['message'])->toContain('Missing required properties: `name`');
});

test('additional property generates additionalProperties error', function () {
    $schema = getSimpleSchema();
    $data = getValidData();
    $data['extra'] = 'not allowed'; // Additional property

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toHaveCount(1)
        ->and($errors[0]['pointer'])->toBe('/') // Error reported at the object root
        ->and($errors[0]['keyword'])->toBe('additionalProperties')
        ->and($errors[0]['message'])->toContain('Additional object properties are not allowed: ["extra"]');
});

// --- Keyword Constraint Tests ---

test('enum constraint violation', function () {
    $schema = ['type' => 'string', 'enum' => ['A', 'B']];
    $data = 'C';

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toHaveCount(1)
        ->and($errors[0]['keyword'])->toBe('enum')
        ->and($errors[0]['message'])->toContain('must be one of the allowed values: "A", "B"');
});

test('minimum constraint violation', function () {
    $schema = ['type' => 'integer', 'minimum' => 10];
    $data = 5;

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toHaveCount(1)
        ->and($errors[0]['keyword'])->toBe('minimum')
        ->and($errors[0]['message'])->toContain('must be greater than or equal to 10');
});

test('maxLength constraint violation', function () {
    $schema = ['type' => 'string', 'maxLength' => 5];
    $data = 'toolong';

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toHaveCount(1)
        ->and($errors[0]['keyword'])->toBe('maxLength')
        ->and($errors[0]['message'])->toContain('Maximum string length is 5, found 7');
});

test('pattern constraint violation', function () {
    $schema = ['type' => 'string', 'pattern' => '^[a-z]+$'];
    $data = '123';

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toHaveCount(1)
        ->and($errors[0]['keyword'])->toBe('pattern')
        ->and($errors[0]['message'])->toContain('does not match the required pattern: `^[a-z]+$`');
});

test('minItems constraint violation', function () {
    $schema = ['type' => 'array', 'minItems' => 2];
    $data = ['one'];

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toHaveCount(1)
        ->and($errors[0]['keyword'])->toBe('minItems')
        ->and($errors[0]['message'])->toContain('Array should have at least 2 items, 1 found');
});

test('uniqueItems constraint violation', function () {
    $schema = ['type' => 'array', 'uniqueItems' => true];
    $data = ['a', 'b', 'a'];

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toHaveCount(1)
        ->and($errors[0]['keyword'])->toBe('uniqueItems')
        ->and($errors[0]['message'])->toContain('Array must have unique items');
});

// --- Nested Structures and Pointers ---
test('nested object validation error pointer', function () {
    $schema = [
        'type' => 'object',
        'properties' => [
            'user' => [
                'type' => 'object',
                'properties' => ['id' => ['type' => 'integer']],
                'required' => ['id'],
            ],
        ],
        'required' => ['user'],
    ];
    $data = ['user' => ['id' => 'abc']]; // Invalid nested type

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toHaveCount(1)
        ->and($errors[0]['pointer'])->toBe('/user/id');
});

test('array item validation error pointer', function () {
    $schema = [
        'type' => 'array',
        'items' => ['type' => 'integer'],
    ];
    $data = [1, 2, 'three', 4]; // Invalid item type

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toHaveCount(1)
        ->and($errors[0]['pointer'])->toBe('/2'); // Pointer to the index of the invalid item
});

// --- Data Conversion Tests ---
test('validates data passed as stdClass object', function () {
    $schema = getSimpleSchema();
    $dataObj = json_decode(json_encode(getValidData())); // Convert to stdClass

    $errors = $this->validator->validateAgainstJsonSchema($dataObj, $schema);
    expect($errors)->toBeEmpty();
});

test('validates data with nested associative arrays correctly', function () {
    $schema = [
        'type' => 'object',
        'properties' => [
            'nested' => [
                'type' => 'object',
                'properties' => ['key' => ['type' => 'string']],
                'required' => ['key'],
            ],
        ],
        'required' => ['nested'],
    ];
    $data = ['nested' => ['key' => 'value']]; // Nested assoc array

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toBeEmpty();
});

// --- Edge Cases ---
test('handles invalid schema structure gracefully', function () {
    $schema = ['type' => 'object', 'properties' => ['name' => ['type' => 123]]]; // Invalid type value
    $data = ['name' => 'test'];

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);
    expect($errors)->toHaveCount(1)
        ->and($errors[0]['keyword'])->toBe('internal')
        ->and($errors[0]['message'])->toContain('Schema validation process failed');
});

test('handles empty data object against schema requiring properties', function () {
    $schema = getSimpleSchema(); // Requires name, age etc.
    $data = []; // Empty data

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);

    expect($errors)->not->toBeEmpty()
        ->and($errors[0]['keyword'])->toBe('required');
});

test('handles empty schema (allows anything)', function () {
    $schema = []; // Empty schema object/array implies no constraints
    $data = ['anything' => [1, 2], 'goes' => true];

    $errors = $this->validator->validateAgainstJsonSchema($data, $schema);

    expect($errors)->not->toBeEmpty()
        ->and($errors[0]['keyword'])->toBe('internal')
        ->and($errors[0]['message'])->toContain('Invalid schema');
});

test('validates schema with string format constraints from Schema attribute', function () {
    $emailSchema = (new Schema(format: 'email'))->toArray();

    // Valid email
    $validErrors = $this->validator->validateAgainstJsonSchema('[email protected]', $emailSchema);
    expect($validErrors)->toBeEmpty();

    // Invalid email
    $invalidErrors = $this->validator->validateAgainstJsonSchema('not-an-email', $emailSchema);
    expect($invalidErrors)->not->toBeEmpty()
        ->and($invalidErrors[0]['keyword'])->toBe('format')
        ->and($invalidErrors[0]['message'])->toContain('email');
});

test('validates schema with string length constraints from Schema attribute', function () {
    $passwordSchema = (new Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$'))->toArray();

    // Valid password (meets length and pattern)
    $validErrors = $this->validator->validateAgainstJsonSchema('Password123', $passwordSchema);
    expect($validErrors)->toBeEmpty();

    // Invalid - too short
    $shortErrors = $this->validator->validateAgainstJsonSchema('Pass1', $passwordSchema);
    expect($shortErrors)->not->toBeEmpty()
        ->and($shortErrors[0]['keyword'])->toBe('minLength');

    // Invalid - no digit
    $noDigitErrors = $this->validator->validateAgainstJsonSchema('PasswordXYZ', $passwordSchema);
    expect($noDigitErrors)->not->toBeEmpty()
        ->and($noDigitErrors[0]['keyword'])->toBe('pattern');
});

test('validates schema with numeric constraints from Schema attribute', function () {
    $ageSchema = (new Schema(minimum: 18, maximum: 120))->toArray();

    // Valid age
    $validErrors = $this->validator->validateAgainstJsonSchema(25, $ageSchema);
    expect($validErrors)->toBeEmpty();

    // Invalid - too low
    $tooLowErrors = $this->validator->validateAgainstJsonSchema(15, $ageSchema);
    expect($tooLowErrors)->not->toBeEmpty()
        ->and($tooLowErrors[0]['keyword'])->toBe('minimum');

    // Invalid - too high
    $tooHighErrors = $this->validator->validateAgainstJsonSchema(150, $ageSchema);
    expect($tooHighErrors)->not->toBeEmpty()
        ->and($tooHighErrors[0]['keyword'])->toBe('maximum');
});

test('validates schema with array constraints from Schema attribute', function () {
    $tagsSchema = (new Schema(uniqueItems: true, minItems: 2))->toArray();

    // Valid tags array
    $validErrors = $this->validator->validateAgainstJsonSchema(['php', 'javascript', 'python'], $tagsSchema);
    expect($validErrors)->toBeEmpty();

    // Invalid - duplicate items
    $duplicateErrors = $this->validator->validateAgainstJsonSchema(['php', 'php', 'javascript'], $tagsSchema);
    expect($duplicateErrors)->not->toBeEmpty()
        ->and($duplicateErrors[0]['keyword'])->toBe('uniqueItems');

    // Invalid - too few items
    $tooFewErrors = $this->validator->validateAgainstJsonSchema(['php'], $tagsSchema);
    expect($tooFewErrors)->not->toBeEmpty()
        ->and($tooFewErrors[0]['keyword'])->toBe('minItems');
});

test('validates schema with object constraints from Schema attribute', function () {
    $userSchema = (new Schema(
        properties: [
            'name' => ['type' => 'string', 'minLength' => 2],
            'email' => ['type' => 'string', 'format' => 'email'],
            'age' => ['type' => 'integer', 'minimum' => 18]
        ],
        required: ['name', 'email']
    ))->toArray();

    // Valid user object
    $validUser = [
        'name' => 'John',
        'email' => '[email protected]',
        'age' => 25
    ];
    $validErrors = $this->validator->validateAgainstJsonSchema($validUser, $userSchema);
    expect($validErrors)->toBeEmpty();

    // Invalid - missing required email
    $missingEmailUser = [
        'name' => 'John',
        'age' => 25
    ];
    $missingErrors = $this->validator->validateAgainstJsonSchema($missingEmailUser, $userSchema);
    expect($missingErrors)->not->toBeEmpty()
        ->and($missingErrors[0]['keyword'])->toBe('required');

    // Invalid - name too short
    $shortNameUser = [
        'name' => 'J',
        'email' => '[email protected]',
        'age' => 25
    ];
    $nameErrors = $this->validator->validateAgainstJsonSchema($shortNameUser, $userSchema);
    expect($nameErrors)->not->toBeEmpty()
        ->and($nameErrors[0]['keyword'])->toBe('minLength');

    // Invalid - age too low
    $youngUser = [
        'name' => 'John',
        'email' => '[email protected]',
        'age' => 15
    ];
    $ageErrors = $this->validator->validateAgainstJsonSchema($youngUser, $userSchema);
    expect($ageErrors)->not->toBeEmpty()
        ->and($ageErrors[0]['keyword'])->toBe('minimum');
});

test('validates schema with nested constraints from Schema attribute', function () {
    $orderSchema = (new Schema(
        properties: [
            'customer' => [
                'type' => 'object',
                'properties' => [
                    'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'],
                    'name' => ['type' => 'string', 'minLength' => 2]
                ],
            ],
            'items' => [
                'type' => 'array',
                'minItems' => 1,
                'items' => [
                    'type' => 'object',
                    'properties' => [
                        'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'],
                        'quantity' => ['type' => 'integer', 'minimum' => 1]
                    ],
                    'required' => ['product_id', 'quantity']
                ]
            ]
        ],
        required: ['customer', 'items']
    ))->toArray();

    // Valid order
    $validOrder = [
        'customer' => [
            'id' => 'CUS-123456',
            'name' => 'John'
        ],
        'items' => [
            [
                'product_id' => 'PRD-1234',
                'quantity' => 2
            ]
        ]
    ];
    $validErrors = $this->validator->validateAgainstJsonSchema($validOrder, $orderSchema);
    expect($validErrors)->toBeEmpty();

    // Invalid - bad customer ID format
    $badCustomerIdOrder = [
        'customer' => [
            'id' => 'CUST-123', // Wrong format
            'name' => 'John'
        ],
        'items' => [
            [
                'product_id' => 'PRD-1234',
                'quantity' => 2
            ]
        ]
    ];
    $customerIdErrors = $this->validator->validateAgainstJsonSchema($badCustomerIdOrder, $orderSchema);
    expect($customerIdErrors)->not->toBeEmpty()
        ->and($customerIdErrors[0]['keyword'])->toBe('pattern');

    // Invalid - empty items array
    $emptyItemsOrder = [
        'customer' => [
            'id' => 'CUS-123456',
            'name' => 'John'
        ],
        'items' => []
    ];
    $emptyItemsErrors = $this->validator->validateAgainstJsonSchema($emptyItemsOrder, $orderSchema);
    expect($emptyItemsErrors)->not->toBeEmpty()
        ->and($emptyItemsErrors[0]['keyword'])->toBe('minItems');

    // Invalid - missing required property in items
    $missingProductIdOrder = [
        'customer' => [
            'id' => 'CUS-123456',
            'name' => 'John'
        ],
        'items' => [
            [
                // Missing product_id
                'quantity' => 2
            ]
        ]
    ];
    $missingProductIdErrors = $this->validator->validateAgainstJsonSchema($missingProductIdOrder, $orderSchema);
    expect($missingProductIdErrors)->not->toBeEmpty()
        ->and($missingProductIdErrors[0]['keyword'])->toBe('required');
});

```

--------------------------------------------------------------------------------
/src/Registry.php:
--------------------------------------------------------------------------------

```php
<?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);
    }
}

```

--------------------------------------------------------------------------------
/src/Dispatcher.php:
--------------------------------------------------------------------------------

```php
<?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;
    }
}

```
Page 3/5FirstPrevNextLast