#
tokens: 46402/50000 9/154 files (page 4/5)
lines: off (toggle) GitHub
raw markdown copy
This is page 4 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/Protocol.php:
--------------------------------------------------------------------------------

```php
<?php

declare(strict_types=1);

namespace PhpMcp\Server;

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

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

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

    protected ?ServerTransportInterface $transport = null;

    protected LoggerInterface $logger;

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

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

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

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

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

        $this->transport = $transport;

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

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

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

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

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

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

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

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

            return;
        }

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

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

        $response = null;

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

        $session->save();

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            default:
                break;
        }
    }

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

        $valid = true;

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

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

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

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

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

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

            default:
                break;
        }

        return $valid;
    }

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

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

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

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

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

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

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

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

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

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

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

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

```

--------------------------------------------------------------------------------
/src/Utils/Discoverer.php:
--------------------------------------------------------------------------------

```php
<?php

declare(strict_types=1);

namespace PhpMcp\Server\Utils;

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

class Discoverer
{
    private DocBlockParser $docBlockParser;

    private SchemaGenerator $schemaGenerator;

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

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

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

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

                return;
            }

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

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

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

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

            return;
        }

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

            return;
        }

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

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

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

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

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

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

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

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

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

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

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

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

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

        return $completionProviders;
    }

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

            return null;
        }

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

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

                return null;
            }

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

            return null;
        }

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

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

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

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

                continue;
            }

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

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

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

            return $potentialClasses[0];
        }

        return null;
    }
}

```

--------------------------------------------------------------------------------
/tests/Integration/SchemaGenerationTest.php:
--------------------------------------------------------------------------------

```php
<?php

uses(\PhpMcp\Server\Tests\TestCase::class);

use PhpMcp\Server\Utils\DocBlockParser;
use PhpMcp\Server\Utils\SchemaGenerator;
use PhpMcp\Server\Tests\Fixtures\Utils\SchemaGeneratorFixture;

beforeEach(function () {
    $docBlockParser = new DocBlockParser();
    $this->schemaGenerator = new SchemaGenerator($docBlockParser);
});

it('generates an empty properties object for a method with no parameters', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'noParams');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema)->toEqual([
        'type' => 'object',
        'properties' => new stdClass()
    ]);
    expect($schema)->not->toHaveKey('required');
});

it('infers basic types from PHP type hints when no DocBlocks or Schema attributes are present', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsOnly');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['name'])->toEqual(['type' => 'string']);
    expect($schema['properties']['age'])->toEqual(['type' => 'integer']);
    expect($schema['properties']['active'])->toEqual(['type' => 'boolean']);
    expect($schema['properties']['tags'])->toEqual(['type' => 'array']);
    expect($schema['properties']['config'])->toEqual(['type' => ['null', 'object'], 'default' => null]);

    expect($schema['required'])->toEqualCanonicalizing(['name', 'age', 'active', 'tags']);
});

it('infers types and descriptions from DocBlock @param tags when no PHP type hints are present', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOnly');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['username'])->toEqual(['type' => 'string', 'description' => 'The username']);
    expect($schema['properties']['count'])->toEqual(['type' => 'integer', 'description' => 'Number of items']);
    expect($schema['properties']['enabled'])->toEqual(['type' => 'boolean', 'description' => 'Whether enabled']);
    expect($schema['properties']['data'])->toEqual(['type' => 'array', 'description' => 'Some data']);

    expect($schema['required'])->toEqualCanonicalizing(['username', 'count', 'enabled', 'data']);
});

it('uses PHP type hints for type and DocBlock @param tags for descriptions when both are present', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsWithDocBlock');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['email'])->toEqual(['type' => 'string', 'description' => 'User email address']);
    expect($schema['properties']['score'])->toEqual(['type' => 'integer', 'description' => 'User score']);
    expect($schema['properties']['verified'])->toEqual(['type' => 'boolean', 'description' => 'Whether user is verified']);

    expect($schema['required'])->toEqualCanonicalizing(['email', 'score', 'verified']);
});

it('ignores Context parameter for schema', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'contextParameter');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema)->toEqual([
        'type' => 'object',
        'properties' => new stdClass()
    ]);
});

it('uses the complete schema definition provided by a method-level #[Schema(definition: ...)] attribute', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelCompleteDefinition');
    $schema = $this->schemaGenerator->generate($method);

    // Should return the complete definition as-is
    expect($schema)->toEqual([
        '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']]
        ]
    ]);
});

it('generates schema from a method-level #[Schema] attribute defining properties for each parameter', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelWithProperties');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['description'])->toBe("Creates a new user with detailed information.");
    expect($schema['properties']['username'])->toEqual(['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$']);
    expect($schema['properties']['email'])->toEqual(['type' => 'string', 'format' => 'email']);
    expect($schema['properties']['age'])->toEqual(['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.']);
    expect($schema['properties']['isActive'])->toEqual(['type' => 'boolean', 'default' => true]);

    expect($schema['required'])->toEqualCanonicalizing(['age', 'username', 'email']);
});

it('generates schema for a single array argument defined by a method-level #[Schema] attribute', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelArrayArgument');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['profiles'])->toEqual([
        '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']
        ]
    ]);

    expect($schema['required'])->toEqual(['profiles']);
});

it('generates schema from individual parameter-level #[Schema] attributes', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterLevelOnly');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['recipientId'])->toEqual(['description' => "Recipient ID", 'pattern' => "^user_", 'type' => 'string']);
    expect($schema['properties']['messageBody'])->toEqual(['maxLength' => 1024, 'type' => 'string']);
    expect($schema['properties']['priority'])->toEqual(['type' => 'integer', 'enum' => [1, 2, 5], 'default' => 1]);
    expect($schema['properties']['notificationConfig'])->toEqual([
        'type' => 'object',
        'properties' => [
            'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']],
            'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push']
        ],
        'required' => ['type'],
        'default' => null
    ]);

    expect($schema['required'])->toEqualCanonicalizing(['recipientId', 'messageBody']);
});

it('applies string constraints from parameter-level #[Schema] attributes', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterStringConstraints');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['email'])->toEqual(['format' => 'email', 'type' => 'string']);
    expect($schema['properties']['password'])->toEqual(['minLength' => 8, 'pattern' => '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$', 'type' => 'string']);
    expect($schema['properties']['regularString'])->toEqual(['type' => 'string']);

    expect($schema['required'])->toEqualCanonicalizing(['email', 'password', 'regularString']);
});

it('applies numeric constraints from parameter-level #[Schema] attributes', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterNumericConstraints');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['age'])->toEqual(['minimum' => 18, 'maximum' => 120, 'type' => 'integer']);
    expect($schema['properties']['rating'])->toEqual(['minimum' => 0, 'maximum' => 5, 'exclusiveMaximum' => true, 'type' => 'number']);
    expect($schema['properties']['count'])->toEqual(['multipleOf' => 10, 'type' => 'integer']);

    expect($schema['required'])->toEqualCanonicalizing(['age', 'rating', 'count']);
});

it('applies array constraints (minItems, uniqueItems, items schema) from parameter-level #[Schema]', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterArrayConstraints');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['tags'])->toEqual(['type' => 'array', 'items' => ['type' => 'string'], 'minItems' => 1, 'uniqueItems' => true]);
    expect($schema['properties']['scores'])->toEqual(['type' => 'array', 'items' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], 'minItems' => 1, 'maxItems' => 5]);

    expect($schema['required'])->toEqualCanonicalizing(['tags', 'scores']);
});

it('merges method-level and parameter-level #[Schema] attributes, with parameter-level taking precedence', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodAndParameterLevel');
    $schema = $this->schemaGenerator->generate($method);

    // Method level defines base properties
    expect($schema['properties']['settingKey'])->toEqual(['type' => 'string', 'description' => 'The key of the setting.']);

    // Parameter level Schema overrides method level for newValue
    expect($schema['properties']['newValue'])->toEqual(['description' => "The specific new boolean value.", 'type' => 'boolean']);

    expect($schema['required'])->toEqualCanonicalizing(['settingKey', 'newValue']);
});

it('combines PHP type hints, DocBlock descriptions, and parameter-level #[Schema] constraints', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintDocBlockAndParameterSchema');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['username'])->toEqual(['minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$', 'type' => 'string', 'description' => "The user's name"]);
    expect($schema['properties']['priority'])->toEqual(['minimum' => 1, 'maximum' => 10, 'type' => 'integer', 'description' => 'Task priority level']);

    expect($schema['required'])->toEqualCanonicalizing(['username', 'priority']);
});

it('generates correct schema for backed and unit enum parameters, inferring from type hints', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['stringEnum'])->toEqual(['type' => 'string', 'description' => 'Backed string enum', 'enum' => ['A', 'B']]);
    expect($schema['properties']['intEnum'])->toEqual(['type' => 'integer', 'description' => 'Backed int enum', 'enum' => [1, 2]]);
    expect($schema['properties']['unitEnum'])->toEqual(['type' => 'string', 'description' => 'Unit enum', 'enum' => ['Yes', 'No']]);
    expect($schema['properties']['nullableEnum'])->toEqual(['type' => ['null', 'string'], 'enum' => ['A', 'B'], 'default' => null]);
    expect($schema['properties']['enumWithDefault'])->toEqual(['type' => 'integer', 'enum' => [1, 2], 'default' => 1]);

    expect($schema['required'])->toEqualCanonicalizing(['stringEnum', 'intEnum', 'unitEnum']);
});

it('correctly generates schemas for various array type declarations (generic, typed, shape)', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['genericArray'])->toEqual(['type' => 'array', 'description' => 'Generic array']);
    expect($schema['properties']['stringArray'])->toEqual(['type' => 'array', 'description' => 'Array of strings', 'items' => ['type' => 'string']]);
    expect($schema['properties']['intArray'])->toEqual(['type' => 'array', 'description' => 'Array of integers', 'items' => ['type' => 'integer']]);
    expect($schema['properties']['mixedMap'])->toEqual(['type' => 'array', 'description' => 'Mixed array map']);

    // Object-like arrays should be converted to object type
    expect($schema['properties']['objectLikeArray'])->toHaveKey('type');
    expect($schema['properties']['objectLikeArray']['type'])->toBe('object');
    expect($schema['properties']['objectLikeArray'])->toHaveKey('properties');
    expect($schema['properties']['objectLikeArray']['properties'])->toHaveKeys(['name', 'age']);

    expect($schema['required'])->toEqualCanonicalizing(['genericArray', 'stringArray', 'intArray', 'mixedMap', 'objectLikeArray', 'nestedObjectArray']);
});

it('handles nullable type hints and optional parameters with default values correctly', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['nullableString'])->toEqual(['type' => ['null', 'string'], 'description' => 'Nullable string']);
    expect($schema['properties']['nullableInt'])->toEqual(['type' => ['null', 'integer'], 'description' => 'Nullable integer', 'default' => null]);
    expect($schema['properties']['optionalString'])->toEqual(['type' => 'string', 'default' => 'default']);
    expect($schema['properties']['optionalBool'])->toEqual(['type' => 'boolean', 'default' => true]);
    expect($schema['properties']['optionalArray'])->toEqual(['type' => 'array', 'default' => []]);

    expect($schema['required'])->toEqualCanonicalizing(['nullableString']);
});

it('generates schema for PHP union types, sorting types alphabetically', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'unionTypes');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['stringOrInt'])->toEqual(['type' => ['integer', 'string'], 'description' => 'String or integer']);
    expect($schema['properties']['multiUnion'])->toEqual(['type' => ['null', 'boolean', 'string'], 'description' => 'Bool, string or null']);

    expect($schema['required'])->toEqualCanonicalizing(['stringOrInt', 'multiUnion']);
});

it('represents variadic string parameters as an array of strings', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'variadicStrings');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['items'])->toEqual(['type' => 'array', 'description' => 'Variadic strings', 'items' => ['type' => 'string']]);
    expect($schema)->not->toHaveKey('required');
    // Variadic is optional
});

it('applies item constraints from parameter-level #[Schema] to variadic parameters', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'variadicWithConstraints');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['numbers'])->toEqual(['items' => ['type' => 'integer', 'minimum' => 0], 'type' => 'array', 'description' => 'Variadic integers']);
    expect($schema)->not->toHaveKey('required');
});

it('handles mixed type hints, omitting explicit type in schema and using defaults', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['anyValue'])->toEqual(['description' => 'Any value']);
    expect($schema['properties']['optionalAny'])->toEqual(['description' => 'Optional any value', 'default' => 'default']);

    expect($schema['required'])->toEqualCanonicalizing(['anyValue']);
});

it('generates schema for complex nested object and array structures defined in parameter-level #[Schema]', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['order'])->toEqual([
        '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']
    ]);

    expect($schema['required'])->toEqual(['order']);
});

it('demonstrates type precedence: parameter #[Schema] overrides DocBlock, which overrides PHP type hint', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typePrecedenceTest');
    $schema = $this->schemaGenerator->generate($method);

    // DocBlock type (integer) should override PHP type (string)
    expect($schema['properties']['numericString'])->toEqual(['type' => 'integer', 'description' => 'DocBlock says integer despite string type hint']);

    // Schema constraints should be applied with PHP type
    expect($schema['properties']['stringWithConstraints'])->toEqual(['format' => 'email', 'minLength' => 5, 'type' => 'string', 'description' => 'String with Schema constraints']);

    // Schema should override DocBlock array item type
    expect($schema['properties']['arrayWithItems'])->toEqual(['items' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], 'type' => 'array', 'description' => 'Array with Schema item overrides']);

    expect($schema['required'])->toEqualCanonicalizing(['numericString', 'stringWithConstraints', 'arrayWithItems']);
});

it('generates an empty properties object for a method with no parameters even if a method-level #[Schema] is present', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['description'])->toBe("Gets server status. Takes no arguments.");
    expect($schema['properties'])->toBeInstanceOf(stdClass::class);
    expect($schema)->not->toHaveKey('required');
});

it('infers parameter type as "any" (omits type) if only constraints are given in #[Schema] without type hint or DocBlock type', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterSchemaInferredType');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['inferredParam'])->toEqual(['description' => "Some parameter", 'minLength' => 3]);

    expect($schema['required'])->toEqual(['inferredParam']);
});

it('uses raw parameter-level schema definition as-is', function () {
    $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterWithRawDefinition');
    $schema = $this->schemaGenerator->generate($method);

    expect($schema['properties']['custom'])->toEqual([
        'description' => 'Custom-defined schema',
        'type' => 'string',
        'format' => 'uuid'
    ]);

    expect($schema['required'])->toEqual(['custom']);
});

```

--------------------------------------------------------------------------------
/src/ServerBuilder.php:
--------------------------------------------------------------------------------

```php
<?php

declare(strict_types=1);

namespace PhpMcp\Server;

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

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

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

    private ?ServerCapabilities $capabilities = null;

    private ?LoggerInterface $logger = null;

    private ?CacheInterface $cache = null;

    private ?ContainerInterface $container = null;

    private ?LoopInterface $loop = null;

    private ?SessionHandlerInterface $sessionHandler = null;

    private ?string $sessionDriver = null;

    private ?int $sessionTtl = 3600;

    private ?int $paginationLimit = 50;

    private ?string $instructions = null;

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

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

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

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

    public function __construct() {}

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

        return $this;
    }

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

        return $this;
    }

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

        return $this;
    }

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

        return $this;
    }

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

        return $this;
    }

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

        return $this;
    }

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

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

        return $this;
    }

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

        return $this;
    }

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

        return $this;
    }

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

        return $this;
    }

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

        return $this;
    }

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

        return $this;
    }

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

        return $this;
    }

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

        return $this;
    }

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

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

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

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

        $registry->disableNotifications();

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

        $registry->enableNotifications();

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

        return $server;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        return $completionProviders;
    }
}

```

--------------------------------------------------------------------------------
/tests/Unit/ServerBuilderTest.php:
--------------------------------------------------------------------------------

```php
<?php

namespace PhpMcp\Server\Tests\Unit;

use Mockery;
use PhpMcp\Schema\Implementation;
use PhpMcp\Schema\ServerCapabilities;
use PhpMcp\Server\Attributes\CompletionProvider;
use PhpMcp\Server\Contracts\CompletionProviderInterface;
use PhpMcp\Server\Contracts\SessionHandlerInterface;
use PhpMcp\Server\Contracts\SessionInterface;
use PhpMcp\Server\Defaults\BasicContainer;
use PhpMcp\Server\Elements\RegisteredPrompt;
use PhpMcp\Server\Elements\RegisteredTool;
use PhpMcp\Server\Exception\ConfigurationException;
use PhpMcp\Server\Protocol;
use PhpMcp\Server\Registry;
use PhpMcp\Server\Server;
use PhpMcp\Server\ServerBuilder;
use PhpMcp\Server\Session\ArraySessionHandler;
use PhpMcp\Server\Session\CacheSessionHandler;
use PhpMcp\Server\Session\SessionManager;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Psr\SimpleCache\CacheInterface;
use React\EventLoop\LoopInterface;
use React\EventLoop\TimerInterface;
use ReflectionClass;

class SB_DummyHandlerClass
{
    public function handle(string $arg): string
    {
        return "handled: {$arg}";
    }

    public function noArgsHandler(): string
    {
        return "no-args";
    }

    public function handlerWithCompletion(
        string $name,
        #[CompletionProvider(provider: SB_DummyCompletionProvider::class)]
        string $uriParam
    ): array {
        return [];
    }
}

class SB_DummyInvokableClass
{
    public function __invoke(int $id): array
    {
        return ['id' => $id];
    }
}

class SB_DummyCompletionProvider implements CompletionProviderInterface
{
    public function getCompletions(string $currentValue, SessionInterface $session): array
    {
        return [];
    }
}


beforeEach(function () {
    $this->builder = new ServerBuilder();
});


it('sets server info correctly', function () {
    $this->builder->withServerInfo('MyServer', '1.2.3');
    $serverInfo = getPrivateProperty($this->builder, 'serverInfo');
    expect($serverInfo)->toBeInstanceOf(Implementation::class)
        ->and($serverInfo->name)->toBe('MyServer')
        ->and($serverInfo->version)->toBe('1.2.3');
});

it('sets capabilities correctly', function () {
    $capabilities = ServerCapabilities::make(toolsListChanged: true);
    $this->builder->withCapabilities($capabilities);
    expect(getPrivateProperty($this->builder, 'capabilities'))->toBe($capabilities);
});

it('sets pagination limit correctly', function () {
    $this->builder->withPaginationLimit(100);
    expect(getPrivateProperty($this->builder, 'paginationLimit'))->toBe(100);
});

it('sets logger correctly', function () {
    $logger = Mockery::mock(LoggerInterface::class);
    $this->builder->withLogger($logger);
    expect(getPrivateProperty($this->builder, 'logger'))->toBe($logger);
});

it('sets cache correctly', function () {
    $cache = Mockery::mock(CacheInterface::class);
    $this->builder->withCache($cache);
    expect(getPrivateProperty($this->builder, 'cache'))->toBe($cache);
});

it('sets session handler correctly', function () {
    $handler = Mockery::mock(SessionHandlerInterface::class);
    $this->builder->withSessionHandler($handler, 7200);
    expect(getPrivateProperty($this->builder, 'sessionHandler'))->toBe($handler);
    expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(7200);
});

it('sets session driver to array correctly', function () {
    $this->builder->withSession('array', 1800);
    expect(getPrivateProperty($this->builder, 'sessionDriver'))->toBe('array');
    expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(1800);
});

it('sets session driver to cache correctly', function () {
    $this->builder->withSession('cache', 900);
    expect(getPrivateProperty($this->builder, 'sessionDriver'))->toBe('cache');
    expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(900);
});

it('uses default TTL when not specified for session', function () {
    $this->builder->withSession('array');
    expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(3600);
});

it('throws exception for invalid session driver', function () {
    $this->builder->withSession('redis');
})->throws(\InvalidArgumentException::class, "Unsupported session driver 'redis'. Only 'array' and 'cache' drivers are supported.");

it('throws exception for cache session driver without cache during build', function () {
    $this->builder
        ->withServerInfo('Test', '1.0')
        ->withSession('cache')
        ->build();
})->throws(ConfigurationException::class, 'Cache session driver requires a cache instance');

it('creates ArraySessionHandler when array driver is specified', function () {
    $server = $this->builder
        ->withServerInfo('Test', '1.0')
        ->withSession('array', 1800)
        ->build();

    $sessionManager = $server->getSessionManager();
    $smReflection = new ReflectionClass(SessionManager::class);
    $handlerProp = $smReflection->getProperty('handler');
    $handlerProp->setAccessible(true);
    $handler = $handlerProp->getValue($sessionManager);

    expect($handler)->toBeInstanceOf(ArraySessionHandler::class);
    expect($handler->ttl)->toBe(1800);
});

it('creates CacheSessionHandler when cache driver is specified', function () {
    $cache = Mockery::mock(CacheInterface::class);
    $cache->shouldReceive('get')->with('mcp_session_index', [])->andReturn([]);

    $server = $this->builder
        ->withServerInfo('Test', '1.0')
        ->withCache($cache)
        ->withSession('cache', 900)
        ->build();

    $sessionManager = $server->getSessionManager();
    $smReflection = new ReflectionClass(SessionManager::class);
    $handlerProp = $smReflection->getProperty('handler');
    $handlerProp->setAccessible(true);
    $handler = $handlerProp->getValue($sessionManager);

    expect($handler)->toBeInstanceOf(CacheSessionHandler::class);
    expect($handler->cache)->toBe($cache);
    expect($handler->ttl)->toBe(900);
});

it('prefers custom session handler over session driver', function () {
    $customHandler = Mockery::mock(SessionHandlerInterface::class);

    $server = $this->builder
        ->withServerInfo('Test', '1.0')
        ->withSession('array')
        ->withSessionHandler($customHandler, 1200)
        ->build();

    $sessionManager = $server->getSessionManager();
    $smReflection = new ReflectionClass(SessionManager::class);
    $handlerProp = $smReflection->getProperty('handler');
    $handlerProp->setAccessible(true);

    expect($handlerProp->getValue($sessionManager))->toBe($customHandler);
});


it('sets container correctly', function () {
    $container = Mockery::mock(ContainerInterface::class);
    $this->builder->withContainer($container);
    expect(getPrivateProperty($this->builder, 'container'))->toBe($container);
});

it('sets loop correctly', function () {
    $loop = Mockery::mock(LoopInterface::class);
    $this->builder->withLoop($loop);
    expect(getPrivateProperty($this->builder, 'loop'))->toBe($loop);
});

it('stores manual tool registration data', function () {
    $handler = [SB_DummyHandlerClass::class, 'handle'];
    $this->builder->withTool($handler, 'my-tool', 'Tool desc');
    $manualTools = getPrivateProperty($this->builder, 'manualTools');
    expect($manualTools[0]['handler'])->toBe($handler)
        ->and($manualTools[0]['name'])->toBe('my-tool')
        ->and($manualTools[0]['description'])->toBe('Tool desc');
});

it('stores manual resource registration data', function () {
    $handler = [SB_DummyHandlerClass::class, 'handle'];
    $this->builder->withResource($handler, 'res://resource', 'Resource name');
    $manualResources = getPrivateProperty($this->builder, 'manualResources');
    expect($manualResources[0]['handler'])->toBe($handler)
        ->and($manualResources[0]['uri'])->toBe('res://resource')
        ->and($manualResources[0]['name'])->toBe('Resource name');
});

it('stores manual resource template registration data', function () {
    $handler = [SB_DummyHandlerClass::class, 'handle'];
    $this->builder->withResourceTemplate($handler, 'res://resource', 'Resource name');
    $manualResourceTemplates = getPrivateProperty($this->builder, 'manualResourceTemplates');
    expect($manualResourceTemplates[0]['handler'])->toBe($handler)
        ->and($manualResourceTemplates[0]['uriTemplate'])->toBe('res://resource')
        ->and($manualResourceTemplates[0]['name'])->toBe('Resource name');
});

it('stores manual prompt registration data', function () {
    $handler = [SB_DummyHandlerClass::class, 'handle'];
    $this->builder->withPrompt($handler, 'my-prompt', 'Prompt desc');
    $manualPrompts = getPrivateProperty($this->builder, 'manualPrompts');
    expect($manualPrompts[0]['handler'])->toBe($handler)
        ->and($manualPrompts[0]['name'])->toBe('my-prompt')
        ->and($manualPrompts[0]['description'])->toBe('Prompt desc');
});

it('throws ConfigurationException if server info not provided', function () {
    $this->builder->build();
})->throws(ConfigurationException::class, 'Server name and version must be provided');


it('resolves default Logger, Loop, Container, SessionHandler if not provided', function () {
    $server = $this->builder->withServerInfo('Test', '1.0')->build();
    $config = $server->getConfiguration();

    expect($config->logger)->toBeInstanceOf(NullLogger::class);
    expect($config->loop)->toBeInstanceOf(LoopInterface::class);
    expect($config->container)->toBeInstanceOf(BasicContainer::class);

    $sessionManager = $server->getSessionManager();
    $smReflection = new ReflectionClass(SessionManager::class);
    $handlerProp = $smReflection->getProperty('handler');
    $handlerProp->setAccessible(true);
    expect($handlerProp->getValue($sessionManager))->toBeInstanceOf(ArraySessionHandler::class);
});

it('builds Server with correct Configuration, Registry, Protocol, SessionManager', function () {
    $logger = new NullLogger();
    $loop = Mockery::mock(LoopInterface::class)->shouldIgnoreMissing();
    $cache = Mockery::mock(CacheInterface::class);
    $container = Mockery::mock(ContainerInterface::class);
    $sessionHandler = Mockery::mock(SessionHandlerInterface::class);
    $capabilities = ServerCapabilities::make(promptsListChanged: true, resourcesListChanged: true);

    $loop->shouldReceive('addPeriodicTimer')->with(300, Mockery::type('callable'))->andReturn(Mockery::mock(TimerInterface::class));

    $server = $this->builder
        ->withServerInfo('FullBuild', '3.0')
        ->withLogger($logger)
        ->withLoop($loop)
        ->withCache($cache)
        ->withContainer($container)
        ->withSessionHandler($sessionHandler)
        ->withCapabilities($capabilities)
        ->withPaginationLimit(75)
        ->build();

    expect($server)->toBeInstanceOf(Server::class);

    $config = $server->getConfiguration();
    expect($config->serverInfo->name)->toBe('FullBuild');
    expect($config->serverInfo->version)->toBe('3.0');
    expect($config->capabilities)->toBe($capabilities);
    expect($config->logger)->toBe($logger);
    expect($config->loop)->toBe($loop);
    expect($config->cache)->toBe($cache);
    expect($config->container)->toBe($container);
    expect($config->paginationLimit)->toBe(75);

    expect($server->getRegistry())->toBeInstanceOf(Registry::class);
    expect($server->getProtocol())->toBeInstanceOf(Protocol::class);
    expect($server->getSessionManager())->toBeInstanceOf(SessionManager::class);
    $smReflection = new ReflectionClass($server->getSessionManager());
    $handlerProp = $smReflection->getProperty('handler');
    $handlerProp->setAccessible(true);
    expect($handlerProp->getValue($server->getSessionManager()))->toBe($sessionHandler);
});

it('registers manual tool successfully during build', function () {
    $handler = [SB_DummyHandlerClass::class, 'handle'];

    $server = $this->builder
        ->withServerInfo('ManualToolTest', '1.0')
        ->withTool($handler, 'test-manual-tool', 'A test tool')
        ->build();

    $registry = $server->getRegistry();
    $tool = $registry->getTool('test-manual-tool');

    expect($tool)->toBeInstanceOf(RegisteredTool::class);
    expect($tool->isManual)->toBeTrue();
    expect($tool->schema->name)->toBe('test-manual-tool');
    expect($tool->schema->description)->toBe('A test tool');
    expect($tool->schema->inputSchema)->toEqual(['type' => 'object', 'properties' => ['arg' => ['type' => 'string']], 'required' => ['arg']]);
    expect($tool->handler)->toBe($handler);
});

it('infers tool name from invokable class if not provided', function () {
    $handler = SB_DummyInvokableClass::class;

    $server = $this->builder
        ->withServerInfo('Test', '1.0')
        ->withTool($handler)
        ->build();

    $tool = $server->getRegistry()->getTool('SB_DummyInvokableClass');
    expect($tool)->not->toBeNull();
    expect($tool->schema->name)->toBe('SB_DummyInvokableClass');
});

it('registers tool with closure handler', function () {
    $closure = function (string $message): string {
        return "Hello, $message!";
    };

    $server = $this->builder
        ->withServerInfo('ClosureTest', '1.0')
        ->withTool($closure, 'greet-tool', 'A greeting tool')
        ->build();

    $tool = $server->getRegistry()->getTool('greet-tool');
    expect($tool)->toBeInstanceOf(RegisteredTool::class);
    expect($tool->isManual)->toBeTrue();
    expect($tool->schema->name)->toBe('greet-tool');
    expect($tool->schema->description)->toBe('A greeting tool');
    expect($tool->handler)->toBe($closure);
    expect($tool->schema->inputSchema)->toEqual([
        'type' => 'object',
        'properties' => ['message' => ['type' => 'string']],
        'required' => ['message']
    ]);
});

it('registers tool with static method handler', function () {
    $handler = [SB_DummyHandlerClass::class, 'handle'];

    $server = $this->builder
        ->withServerInfo('StaticTest', '1.0')
        ->withTool($handler, 'static-tool', 'A static method tool')
        ->build();

    $tool = $server->getRegistry()->getTool('static-tool');
    expect($tool)->toBeInstanceOf(RegisteredTool::class);
    expect($tool->isManual)->toBeTrue();
    expect($tool->schema->name)->toBe('static-tool');
    expect($tool->handler)->toBe($handler);
});

it('registers resource with closure handler', function () {
    $closure = function (string $id): array {
        return [
            'uri' => "res://item/$id",
            'name' => "Item $id",
            'mimeType' => 'application/json'
        ];
    };

    $server = $this->builder
        ->withServerInfo('ResourceTest', '1.0')
        ->withResource($closure, 'res://items/{id}', 'dynamic_resource')
        ->build();

    $resource = $server->getRegistry()->getResource('res://items/{id}');
    expect($resource)->not->toBeNull();
    expect($resource->handler)->toBe($closure);
    expect($resource->isManual)->toBeTrue();
});

it('registers prompt with closure handler', function () {
    $closure = function (string $topic): array {
        return [
            'role' => 'user',
            'content' => ['type' => 'text', 'text' => "Tell me about $topic"]
        ];
    };

    $server = $this->builder
        ->withServerInfo('PromptTest', '1.0')
        ->withPrompt($closure, 'topic-prompt', 'A topic-based prompt')
        ->build();

    $prompt = $server->getRegistry()->getPrompt('topic-prompt');
    expect($prompt)->not->toBeNull();
    expect($prompt->handler)->toBe($closure);
    expect($prompt->isManual)->toBeTrue();
});

it('infers closure tool name automatically', function () {
    $closure = function (int $count): array {
        return ['count' => $count];
    };

    $server = $this->builder
        ->withServerInfo('AutoNameTest', '1.0')
        ->withTool($closure)
        ->build();

    $tools = $server->getRegistry()->getTools();
    expect($tools)->toHaveCount(1);

    $toolName = array_keys($tools)[0];
    expect($toolName)->toStartWith('closure_tool_');

    $tool = $server->getRegistry()->getTool($toolName);
    expect($tool->handler)->toBe($closure);
});

it('generates unique names for multiple closures', function () {
    $closure1 = function (string $a): string {
        return $a;
    };
    $closure2 = function (int $b): int {
        return $b;
    };

    $server = $this->builder
        ->withServerInfo('MultiClosureTest', '1.0')
        ->withTool($closure1)
        ->withTool($closure2)
        ->build();

    $tools = $server->getRegistry()->getTools();
    expect($tools)->toHaveCount(2);

    $toolNames = array_keys($tools);
    expect($toolNames[0])->toStartWith('closure_tool_');
    expect($toolNames[1])->toStartWith('closure_tool_');
    expect($toolNames[0])->not->toBe($toolNames[1]);
});

it('infers prompt arguments and completion providers for manual prompt', function () {
    $handler = [SB_DummyHandlerClass::class, 'handlerWithCompletion'];

    $server = $this->builder
        ->withServerInfo('Test', '1.0')
        ->withPrompt($handler, 'myPrompt')
        ->build();

    $prompt = $server->getRegistry()->getPrompt('myPrompt');
    expect($prompt)->toBeInstanceOf(RegisteredPrompt::class);
    expect($prompt->schema->arguments)->toHaveCount(2);
    expect($prompt->schema->arguments[0]->name)->toBe('name');
    expect($prompt->schema->arguments[1]->name)->toBe('uriParam');
    expect($prompt->completionProviders['uriParam'])->toBe(SB_DummyCompletionProvider::class);
});

// Add test fixtures for enhanced completion providers
class SB_DummyHandlerWithEnhancedCompletion
{
    public function handleWithListCompletion(
        #[CompletionProvider(values: ['option1', 'option2', 'option3'])]
        string $choice
    ): array {
        return [['role' => 'user', 'content' => "Selected: {$choice}"]];
    }

    public function handleWithEnumCompletion(
        #[CompletionProvider(enum: SB_TestEnum::class)]
        string $status
    ): array {
        return [['role' => 'user', 'content' => "Status: {$status}"]];
    }
}

enum SB_TestEnum: string
{
    case PENDING = 'pending';
    case ACTIVE = 'active';
    case INACTIVE = 'inactive';
}

it('creates ListCompletionProvider for values attribute in manual registration', function () {
    $handler = [SB_DummyHandlerWithEnhancedCompletion::class, 'handleWithListCompletion'];

    $server = $this->builder
        ->withServerInfo('Test', '1.0')
        ->withPrompt($handler, 'listPrompt')
        ->build();

    $prompt = $server->getRegistry()->getPrompt('listPrompt');
    expect($prompt->completionProviders['choice'])->toBeInstanceOf(\PhpMcp\Server\Defaults\ListCompletionProvider::class);
});

it('creates EnumCompletionProvider for enum attribute in manual registration', function () {
    $handler = [SB_DummyHandlerWithEnhancedCompletion::class, 'handleWithEnumCompletion'];

    $server = $this->builder
        ->withServerInfo('Test', '1.0')
        ->withPrompt($handler, 'enumPrompt')
        ->build();

    $prompt = $server->getRegistry()->getPrompt('enumPrompt');
    expect($prompt->completionProviders['status'])->toBeInstanceOf(\PhpMcp\Server\Defaults\EnumCompletionProvider::class);
});

// it('throws DefinitionException if HandlerResolver fails for a manual element', function () {
//     $handler = ['NonExistentClass', 'method'];

//     $server = $this->builder
//         ->withServerInfo('Test', '1.0')
//         ->withTool($handler, 'badTool')
//         ->build();
// })->throws(DefinitionException::class, '1 error(s) occurred during manual element registration');


it('builds successfully with minimal valid config', function () {
    $server = $this->builder
        ->withServerInfo('TS-Compatible', '0.1')
        ->build();
    expect($server)->toBeInstanceOf(Server::class);
});

it('can be built multiple times with different configurations', function () {
    $builder = new ServerBuilder();

    $server1 = $builder
        ->withServerInfo('ServerOne', '1.0')
        ->withTool([SB_DummyHandlerClass::class, 'handle'], 'toolOne')
        ->build();

    $server2 = $builder
        ->withServerInfo('ServerTwo', '2.0')
        ->withTool([SB_DummyHandlerClass::class, 'noArgsHandler'], 'toolTwo')
        ->build();

    expect($server1->getConfiguration()->serverInfo->name)->toBe('ServerOne');
    $registry1 = $server1->getRegistry();
    expect($registry1->getTool('toolOne'))->not->toBeNull();
    expect($registry1->getTool('toolTwo'))->toBeNull();

    expect($server2->getConfiguration()->serverInfo->name)->toBe('ServerTwo');
    $registry2 = $server2->getRegistry();
    expect($registry2->getTool('toolOne'))->not->toBeNull();
    expect($registry2->getTool('toolTwo'))->not->toBeNull();

    $builder3 = new ServerBuilder();
    $server3 = $builder3->withServerInfo('ServerThree', '3.0')->build();
    expect($server3->getRegistry()->hasElements())->toBeFalse();
});

```

--------------------------------------------------------------------------------
/tests/Integration/HttpServerTransportTest.php:
--------------------------------------------------------------------------------

```php
<?php

use PhpMcp\Server\Protocol;
use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture;
use PhpMcp\Server\Tests\Mocks\Clients\MockSseClient;
use React\ChildProcess\Process;
use React\EventLoop\Loop;
use React\Http\Browser;
use React\Http\Message\ResponseException;
use React\Http\Message\Uri;
use React\Stream\ReadableStreamInterface;

use function React\Async\await;

const HTTP_SERVER_SCRIPT_PATH = __DIR__ . '/../Fixtures/ServerScripts/HttpTestServer.php';
const HTTP_PROCESS_TIMEOUT_SECONDS = 8;
const HTTP_SERVER_HOST = '127.0.0.1';
const HTTP_MCP_PATH_PREFIX = 'mcp_http_integration';

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

    if (!is_file(HTTP_SERVER_SCRIPT_PATH)) {
        $this->markTestSkipped("Server script not found: " . HTTP_SERVER_SCRIPT_PATH);
    }
    if (!is_executable(HTTP_SERVER_SCRIPT_PATH)) {
        chmod(HTTP_SERVER_SCRIPT_PATH, 0755);
    }

    $phpPath = PHP_BINARY ?: 'php';
    $commandPhpPath = str_contains($phpPath, ' ') ? '"' . $phpPath . '"' : $phpPath;
    $commandArgs = [
        escapeshellarg(HTTP_SERVER_HOST),
        escapeshellarg((string)$this->port),
        escapeshellarg(HTTP_MCP_PATH_PREFIX)
    ];
    $commandScriptPath = escapeshellarg(HTTP_SERVER_SCRIPT_PATH);
    $command = $commandPhpPath . ' ' . $commandScriptPath . ' ' . implode(' ', $commandArgs);

    $this->process = new Process($command, getcwd() ?: null, null, []);
    $this->process->start($this->loop);

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

    return await(delay(0.2, $this->loop));
});

afterEach(function () {
    if ($this->sseClient ?? null) {
        $this->sseClient->close();
    }

    if ($this->process instanceof Process && $this->process->isRunning()) {
        if ($this->process->stdout instanceof ReadableStreamInterface) {
            $this->process->stdout->close();
        }
        if ($this->process->stderr instanceof ReadableStreamInterface) {
            $this->process->stderr->close();
        }

        $this->process->terminate(SIGTERM);
        try {
            await(delay(0.02, $this->loop));
        } catch (\Throwable $e) {
        }

        if ($this->process->isRunning()) {
            $this->process->terminate(SIGKILL);
        }
    }
    $this->process = null;
});

afterAll(function () {
    // Loop::stop();
});

it('starts the http server, initializes, calls a tool, and closes', function () {
    $this->sseClient = new MockSseClient();
    $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse";

    // 1. Connect
    await($this->sseClient->connect($sseBaseUrl));
    await(delay(0.05, $this->loop));
    expect($this->sseClient->endpointUrl)->toBeString();
    expect($this->sseClient->clientId)->toBeString();

    // 2. Initialize Request
    await($this->sseClient->sendHttpRequest('init-http-1', 'initialize', [
        'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION,
        'clientInfo' => ['name' => 'HttpPestClient', 'version' => '1.0'],
        'capabilities' => []
    ]));
    $initResponse = await($this->sseClient->getNextMessageResponse('init-http-1'));

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

    // 3. Initialized Notification
    await($this->sseClient->sendHttpNotification('notifications/initialized', ['messageQueueSupported' => true]));
    await(delay(0.05, $this->loop));

    // 4. Call a tool
    await($this->sseClient->sendHttpRequest('tool-http-1', 'tools/call', [
        'name' => 'greet_http_tool',
        'arguments' => ['name' => 'HTTP Integration User']
    ]));
    $toolResponse = await($this->sseClient->getNextMessageResponse('tool-http-1'));

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

    // 5. Close
    $this->sseClient->close();
})->group('integration', 'http_transport');

it('can handle invalid JSON from client', function () {
    $this->sseClient = new MockSseClient();
    $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse";

    // 1. Connect
    await($this->sseClient->connect($sseBaseUrl));
    await(delay(0.05, $this->loop));

    expect($this->sseClient->endpointUrl)->toBeString();

    $malformedJson = '{"jsonrpc":"2.0", "id": "bad-json-1", "method": "tools/list", "params": {"broken"}';

    // 2. Send invalid JSON
    $postPromise = $this->sseClient->browser->post(
        $this->sseClient->endpointUrl,
        ['Content-Type' => 'application/json'],
        $malformedJson
    );

    // 3. Expect error response
    try {
        await(timeout($postPromise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop));
    } catch (ResponseException $e) {
        expect($e->getResponse()->getStatusCode())->toBe(400);

        $errorResponse = json_decode($e->getResponse()->getBody(), true);
        expect($errorResponse['jsonrpc'])->toBe('2.0');
        expect($errorResponse['id'])->toBe('');
        expect($errorResponse['error']['code'])->toBe(-32700);
        expect($errorResponse['error']['message'])->toContain('Invalid JSON-RPC message');
    }
})->group('integration', 'http_transport');

it('can handle request for non-existent method after initialization', function () {
    $this->sseClient = new MockSseClient();
    $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse";

    // 1. Connect
    await($this->sseClient->connect($sseBaseUrl));
    await(delay(0.05, $this->loop));
    expect($this->sseClient->endpointUrl)->toBeString();

    // 2. Initialize Request
    await($this->sseClient->sendHttpRequest('init-http-nonexist', 'initialize', [
        'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION,
        'clientInfo' => ['name' => 'Test'],
        'capabilities' => []
    ]));
    await($this->sseClient->getNextMessageResponse('init-http-nonexist'));
    await($this->sseClient->sendHttpNotification('notifications/initialized', ['messageQueueSupported' => true]));
    await(delay(0.05, $this->loop));

    // 3. Send request for non-existent method
    await($this->sseClient->sendHttpRequest('err-meth-http-1', 'non/existentHttpTool', []));
    $errorResponse = await($this->sseClient->getNextMessageResponse('err-meth-http-1'));

    // 4. Expect error response
    expect($errorResponse['id'])->toBe('err-meth-http-1');
    expect($errorResponse['error']['code'])->toBe(-32601);
    expect($errorResponse['error']['message'])->toContain("Method 'non/existentHttpTool' not found");
})->group('integration', 'http_transport');

it('can handle batch requests correctly over HTTP/SSE', function () {
    $this->sseClient = new MockSseClient();
    $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse";

    // 1. Connect
    await($this->sseClient->connect($sseBaseUrl));
    await(delay(0.05, $this->loop));
    expect($this->sseClient->endpointUrl)->toBeString();

    // 2. Initialize Request
    await($this->sseClient->sendHttpRequest('init-batch-http', 'initialize', [
        'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION,
        'clientInfo' => ['name' => 'HttpBatchClient', 'version' => '1.0'],
        'capabilities' => []
    ]));
    await($this->sseClient->getNextMessageResponse('init-batch-http'));

    // 3. Initialized notification
    await($this->sseClient->sendHttpNotification('notifications/initialized', ['messageQueueSupported' => true]));
    await(delay(0.05, $this->loop));

    // 4. Send Batch Request
    $batchRequests = [
        ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_http_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_http_tool', 'arguments' => ['name' => 'Batch Item 2']]],
        ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method']
    ];

    await($this->sseClient->sendHttpBatchRequest($batchRequests));

    // 5. Read Batch Response
    $batchResponseArray = await($this->sseClient->getNextBatchMessageResponse(3));

    expect($batchResponseArray)->toBeArray()->toHaveCount(3);

    $findResponseById = function (array $batch, $id) {
        foreach ($batch as $item) {
            if (isset($item['id']) && $item['id'] === $id) {
                return $item;
            }
        }
        return null;
    };

    $response1 = $findResponseById($batchResponseArray, 'batch-req-1');
    $response2 = $findResponseById($batchResponseArray, 'batch-req-2');
    $response3 = $findResponseById($batchResponseArray, 'batch-req-3');

    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->sseClient->close();
})->group('integration', 'http_transport');

it('can handle tool list request over HTTP/SSE', function () {
    $this->sseClient = new MockSseClient();
    $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse";
    await($this->sseClient->connect($sseBaseUrl));
    await(delay(0.05, $this->loop));

    await($this->sseClient->sendHttpRequest('init-http-tools', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]));
    await($this->sseClient->getNextMessageResponse('init-http-tools'));
    await($this->sseClient->sendHttpNotification('notifications/initialized'));
    await(delay(0.1, $this->loop));

    await($this->sseClient->sendHttpRequest('tool-list-http-1', 'tools/list', []));
    $toolListResponse = await($this->sseClient->getNextMessageResponse('tool-list-http-1'));

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

    $this->sseClient->close();
})->group('integration', 'http_transport');

it('can read a registered resource over HTTP/SSE', function () {
    $this->sseClient = new MockSseClient();
    $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse";
    await($this->sseClient->connect($sseBaseUrl));
    await(delay(0.05, $this->loop));

    await($this->sseClient->sendHttpRequest('init-http-res', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]));
    await($this->sseClient->getNextMessageResponse('init-http-res'));
    await($this->sseClient->sendHttpNotification('notifications/initialized'));
    await(delay(0.1, $this->loop));

    await($this->sseClient->sendHttpRequest('res-read-http-1', 'resources/read', ['uri' => 'test://http/static']));
    $resourceResponse = await($this->sseClient->getNextMessageResponse('res-read-http-1'));

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

    $this->sseClient->close();
})->group('integration', 'http_transport');

it('can get a registered prompt over HTTP/SSE', function () {
    $this->sseClient = new MockSseClient();
    $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse";
    await($this->sseClient->connect($sseBaseUrl));
    await(delay(0.05, $this->loop));

    await($this->sseClient->sendHttpRequest('init-http-prompt', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]));
    await($this->sseClient->getNextMessageResponse('init-http-prompt'));
    await($this->sseClient->sendHttpNotification('notifications/initialized'));
    await(delay(0.1, $this->loop));

    await($this->sseClient->sendHttpRequest('prompt-get-http-1', 'prompts/get', [
        'name' => 'simple_http_prompt',
        'arguments' => ['name' => 'HttpPromptUser', 'style' => 'polite']
    ]));
    $promptResponse = await($this->sseClient->getNextMessageResponse('prompt-get-http-1'));

    expect($promptResponse['id'])->toBe('prompt-get-http-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 polite greeting for HttpPromptUser.');

    $this->sseClient->close();
})->group('integration', 'http_transport');

it('rejects subsequent requests if client does not send initialized notification', function () {
    $this->sseClient = new MockSseClient();
    $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse";
    await($this->sseClient->connect($sseBaseUrl));
    await(delay(0.05, $this->loop));

    // 1. Send Initialize
    await($this->sseClient->sendHttpRequest('init-http-no-ack', 'initialize', [
        'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION,
        'clientInfo' => ['name' => 'HttpForgetfulClient', 'version' => '1.0'],
        'capabilities' => []
    ]));
    await($this->sseClient->getNextMessageResponse('init-http-no-ack'));
    // Client "forgets" to send notifications/initialized back

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

    // 2. Attempt to Call a tool
    await($this->sseClient->sendHttpRequest('tool-call-http-no-ack', 'tools/call', [
        'name' => 'greet_http_tool',
        'arguments' => ['name' => 'NoAckHttpUser']
    ]));
    $toolResponse = await($this->sseClient->getNextMessageResponse('tool-call-http-no-ack'));

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

    $this->sseClient->close();
})->group('integration', 'http_transport');

it('returns 404 for POST to /message without valid clientId in query', function () {
    $this->sseClient = new MockSseClient();
    $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse";
    await($this->sseClient->connect($sseBaseUrl));
    await(delay(0.05, $this->loop));
    $validEndpointUrl = $this->sseClient->endpointUrl;
    $this->sseClient->close();

    $malformedEndpoint = (string) (new Uri($validEndpointUrl))->withQuery('');

    $payload = ['jsonrpc' => '2.0', 'id' => 'post-no-clientid', 'method' => 'ping', 'params' => []];
    $postPromise = $this->sseClient->browser->post(
        $malformedEndpoint,
        ['Content-Type' => 'application/json'],
        json_encode($payload)
    );

    try {
        await(timeout($postPromise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop));
    } catch (ResponseException $e) {
        expect($e->getResponse()->getStatusCode())->toBe(400);
        $bodyContent = (string) $e->getResponse()->getBody();
        $errorData = json_decode($bodyContent, true);
        expect($errorData['error']['message'])->toContain('Missing or invalid clientId');
    }
})->group('integration', 'http_transport');

it('returns 404 for POST to /message with clientId for a disconnected SSE stream', function () {
    $this->sseClient = new MockSseClient();
    $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse";

    await($this->sseClient->connect($sseBaseUrl));
    await(delay(0.05, $this->loop));
    $originalEndpointUrl = $this->sseClient->endpointUrl;
    $this->sseClient->close();

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

    $payload = ['jsonrpc' => '2.0', 'id' => 'post-stale-clientid', 'method' => 'ping', 'params' => []];
    $postPromise = $this->sseClient->browser->post(
        $originalEndpointUrl,
        ['Content-Type' => 'application/json'],
        json_encode($payload)
    );

    try {
        await(timeout($postPromise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop));
    } catch (ResponseException $e) {
        $bodyContent = (string) $e->getResponse()->getBody();
        $errorData = json_decode($bodyContent, true);
        expect($errorData['error']['message'])->toContain('Session ID not found or disconnected');
    }
})->group('integration', 'http_transport');

it('returns 404 for unknown paths', function () {
    $browser = new Browser($this->loop);
    $unknownUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/unknown/path";

    $promise = $browser->get($unknownUrl);

    try {
        await(timeout($promise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop));
        $this->fail("Request to unknown path should have failed with 404.");
    } catch (ResponseException $e) {
        expect($e->getResponse()->getStatusCode())->toBe(404);
        $body = (string) $e->getResponse()->getBody();
        expect($body)->toContain("Not Found");
    } catch (\Throwable $e) {
        $this->fail("Request to unknown path failed with unexpected error: " . $e->getMessage());
    }
})->group('integration', 'http_transport');

it('executes middleware that adds headers to response', function () {
    $this->sseClient = new MockSseClient();
    $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse";

    // 1. Connect
    await($this->sseClient->connect($sseBaseUrl));
    await(delay(0.05, $this->loop));

    // 2. Check that the middleware-added header is present in the response
    expect($this->sseClient->lastConnectResponse->getHeaderLine('X-Test-Middleware'))->toBe('header-added');

    $this->sseClient->close();
})->group('integration', 'http_transport', 'middleware');

it('executes middleware that modifies request attributes', function () {
    $this->sseClient = new MockSseClient();
    $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse";

    // 1. Connect 
    await($this->sseClient->connect($sseBaseUrl));
    await(delay(0.05, $this->loop));

    // 2. Initialize
    await($this->sseClient->sendHttpRequest('init-middleware-attr', 'initialize', [
        'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION,
        'clientInfo' => ['name' => 'MiddlewareTestClient'],
        'capabilities' => []
    ]));
    await($this->sseClient->getNextMessageResponse('init-middleware-attr'));
    await($this->sseClient->sendHttpNotification('notifications/initialized'));
    await(delay(0.05, $this->loop));

    // 3. Call tool that checks for middleware-added attribute
    await($this->sseClient->sendHttpRequest('tool-attr-check', 'tools/call', [
        'name' => 'check_request_attribute_tool',
        'arguments' => []
    ]));
    $toolResponse = await($this->sseClient->getNextMessageResponse('tool-attr-check'));

    expect($toolResponse['result']['content'][0]['text'])->toBe('middleware-value-found: middleware-value');

    $this->sseClient->close();
})->group('integration', 'http_transport', 'middleware');

it('executes middleware that can short-circuit request processing', function () {
    $browser = new Browser($this->loop);
    $shortCircuitUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/short-circuit";

    $promise = $browser->get($shortCircuitUrl);

    try {
        $response = await(timeout($promise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop));
        $this->fail("Expected a 418 status code response, but request succeeded");
    } catch (ResponseException $e) {
        expect($e->getResponse()->getStatusCode())->toBe(418);
        $body = (string) $e->getResponse()->getBody();
        expect($body)->toBe('Short-circuited by middleware');
    } catch (\Throwable $e) {
        $this->fail("Short-circuit middleware test failed: " . $e->getMessage());
    }
})->group('integration', 'http_transport', 'middleware');

it('executes multiple middlewares in correct order', function () {
    $this->sseClient = new MockSseClient();
    $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse";

    // 1. Connect
    await($this->sseClient->connect($sseBaseUrl));
    await(delay(0.05, $this->loop));

    // 2. Check that headers from multiple middlewares are present in correct order
    expect($this->sseClient->lastConnectResponse->getHeaderLine('X-Middleware-Order'))->toBe('third,second,first');

    $this->sseClient->close();
})->group('integration', 'http_transport', 'middleware');

it('handles middleware that throws exceptions gracefully', function () {
    $browser = new Browser($this->loop);
    $errorUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/error-middleware";

    $promise = $browser->get($errorUrl);

    try {
        await(timeout($promise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop));
        $this->fail("Error middleware should have thrown an exception.");
    } catch (ResponseException $e) {
        expect($e->getResponse()->getStatusCode())->toBe(500);
        $body = (string) $e->getResponse()->getBody();
        // ReactPHP handles exceptions and returns a generic error message
        expect($body)->toContain('Internal Server Error');
    }
})->group('integration', 'http_transport', 'middleware');

```

--------------------------------------------------------------------------------
/tests/Unit/ProtocolTest.php:
--------------------------------------------------------------------------------

```php
<?php

namespace PhpMcp\Server\Tests\Unit;

use Mockery;
use Mockery\MockInterface;
use PhpMcp\Schema\Implementation;
use PhpMcp\Server\Context;
use PhpMcp\Server\Configuration;
use PhpMcp\Server\Contracts\ServerTransportInterface;
use PhpMcp\Server\Dispatcher;
use PhpMcp\Server\Exception\McpServerException;
use PhpMcp\Schema\JsonRpc\BatchRequest;
use PhpMcp\Schema\JsonRpc\BatchResponse;
use PhpMcp\Schema\JsonRpc\Error;
use PhpMcp\Schema\JsonRpc\Notification;
use PhpMcp\Schema\JsonRpc\Request;
use PhpMcp\Schema\JsonRpc\Response;
use PhpMcp\Schema\Notification\ResourceListChangedNotification;
use PhpMcp\Schema\Notification\ResourceUpdatedNotification;
use PhpMcp\Schema\Notification\ToolListChangedNotification;
use PhpMcp\Schema\Result\EmptyResult;
use PhpMcp\Schema\ServerCapabilities;
use PhpMcp\Server\Protocol;
use PhpMcp\Server\Registry;
use PhpMcp\Server\Session\SessionManager;
use PhpMcp\Server\Contracts\SessionInterface;
use PhpMcp\Server\Session\SubscriptionManager;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
use React\EventLoop\LoopInterface;

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

const SESSION_ID = 'session-test-789';
const SUPPORTED_VERSION_PROTO = Protocol::LATEST_PROTOCOL_VERSION;
const SERVER_NAME_PROTO = 'Test Protocol Server';
const SERVER_VERSION_PROTO = '0.3.0';

function createRequest(string $method, array $params = [], string|int $id = 'req-proto-1'): Request
{
    return new Request('2.0', $id, $method, $params);
}

function createNotification(string $method, array $params = []): Notification
{
    return new Notification('2.0', $method, $params);
}

function expectErrorResponse(mixed $response, int $expectedCode, string|int|null $expectedId = 'req-proto-1'): void
{
    test()->expect($response)->toBeInstanceOf(Error::class);
    test()->expect($response->id)->toBe($expectedId);
    test()->expect($response->code)->toBe($expectedCode);
    test()->expect($response->jsonrpc)->toBe('2.0');
}

function expectSuccessResponse(mixed $response, mixed $expectedResult, string|int|null $expectedId = 'req-proto-1'): void
{
    test()->expect($response)->toBeInstanceOf(Response::class);
    test()->expect($response->id)->toBe($expectedId);
    test()->expect($response->jsonrpc)->toBe('2.0');
    test()->expect($response->result)->toBe($expectedResult);
}


beforeEach(function () {
    /** @var MockInterface&Registry $registry */
    $this->registry = Mockery::mock(Registry::class);
    /** @var MockInterface&SessionManager $sessionManager */
    $this->sessionManager = Mockery::mock(SessionManager::class);
    /** @var MockInterface&Dispatcher $dispatcher */
    $this->dispatcher = Mockery::mock(Dispatcher::class);
    /** @var MockInterface&SubscriptionManager $subscriptionManager */
    $this->subscriptionManager = Mockery::mock(SubscriptionManager::class);
    /** @var MockInterface&LoggerInterface $logger */
    $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing();
    /** @var MockInterface&ServerTransportInterface $transport */
    $this->transport = Mockery::mock(ServerTransportInterface::class);
    /** @var MockInterface&SessionInterface $session */
    $this->session = Mockery::mock(SessionInterface::class);

    /** @var MockInterface&LoopInterface $loop */
    $loop = Mockery::mock(LoopInterface::class);
    /** @var MockInterface&CacheInterface $cache */
    $cache = Mockery::mock(CacheInterface::class);
    /** @var MockInterface&ContainerInterface $container */
    $container = Mockery::mock(ContainerInterface::class);

    $this->configuration = new Configuration(
        serverInfo: Implementation::make(SERVER_NAME_PROTO, SERVER_VERSION_PROTO),
        capabilities: ServerCapabilities::make(),
        logger: $this->logger,
        loop: $loop,
        cache: $cache,
        container: $container
    );

    $this->sessionManager->shouldReceive('getSession')->with(SESSION_ID)->andReturn($this->session)->byDefault();
    $this->sessionManager->shouldReceive('on')->withAnyArgs()->byDefault();

    $this->registry->shouldReceive('on')->withAnyArgs()->byDefault();

    $this->session->shouldReceive('get')->with('initialized', false)->andReturn(true)->byDefault();
    $this->session->shouldReceive('save')->byDefault();

    $this->transport->shouldReceive('on')->withAnyArgs()->byDefault();
    $this->transport->shouldReceive('removeListener')->withAnyArgs()->byDefault();
    $this->transport->shouldReceive('sendMessage')
        ->withAnyArgs()
        ->andReturn(resolve(null))
        ->byDefault();

    $this->protocol = new Protocol(
        $this->configuration,
        $this->registry,
        $this->sessionManager,
        $this->dispatcher,
        $this->subscriptionManager
    );

    $this->protocol->bindTransport($this->transport);
});

it('listens to SessionManager events on construction', function () {
    $this->sessionManager->shouldHaveReceived('on')->with('session_deleted', Mockery::type('callable'));
});

it('listens to Registry events on construction', function () {
    $this->registry->shouldHaveReceived('on')->with('list_changed', Mockery::type('callable'));
});

it('binds to a transport and attaches listeners', function () {
    $newTransport = Mockery::mock(ServerTransportInterface::class);
    $newTransport->shouldReceive('on')->with('message', Mockery::type('callable'))->once();
    $newTransport->shouldReceive('on')->with('client_connected', Mockery::type('callable'))->once();
    $newTransport->shouldReceive('on')->with('client_disconnected', Mockery::type('callable'))->once();
    $newTransport->shouldReceive('on')->with('error', Mockery::type('callable'))->once();

    $this->protocol->bindTransport($newTransport);
});

it('unbinds from a previous transport when binding a new one', function () {
    $this->transport->shouldReceive('removeListener')->times(4);

    $newTransport = Mockery::mock(ServerTransportInterface::class);
    $newTransport->shouldReceive('on')->times(4);

    $this->protocol->bindTransport($newTransport);
});

it('unbinds transport and removes listeners', function () {
    $this->transport->shouldReceive('removeListener')->with('message', Mockery::type('callable'))->once();
    $this->transport->shouldReceive('removeListener')->with('client_connected', Mockery::type('callable'))->once();
    $this->transport->shouldReceive('removeListener')->with('client_disconnected', Mockery::type('callable'))->once();
    $this->transport->shouldReceive('removeListener')->with('error', Mockery::type('callable'))->once();

    $this->protocol->unbindTransport();

    $reflection = new \ReflectionClass($this->protocol);
    $transportProp = $reflection->getProperty('transport');
    $transportProp->setAccessible(true);
    expect($transportProp->getValue($this->protocol))->toBeNull();
});

it('processes a valid Request message', function () {
    $request = createRequest('test/method', ['param' => 1]);
    $result = new EmptyResult();
    $expectedResponse = Response::make($request->id, $result);

    $this->dispatcher->shouldReceive('handleRequest')->once()
        ->with(
            Mockery::on(fn ($arg) => $arg instanceof Request && $arg->method === 'test/method'),
            Mockery::on(fn ($arg) => $arg instanceof Context && $arg->session === $this->session),
        )
        ->andReturn($result);

    $this->transport->shouldReceive('sendMessage')->once()
        ->with(Mockery::on(fn ($arg) => $arg instanceof Response && $arg->id === $request->id && $arg->result === $result), SESSION_ID, Mockery::any())
        ->andReturn(resolve(null));

    $this->protocol->processMessage($request, SESSION_ID);
    $this->session->shouldHaveReceived('save');
});

it('processes a valid Notification message', function () {
    $notification = createNotification('test/notify', ['data' => 'info']);

    $this->dispatcher->shouldReceive('handleNotification')->once()
        ->with(Mockery::on(fn ($arg) => $arg instanceof Notification && $arg->method === 'test/notify'), $this->session)
        ->andReturnNull();

    $this->transport->shouldNotReceive('sendMessage');

    $this->protocol->processMessage($notification, SESSION_ID);
    $this->session->shouldHaveReceived('save');
});

it('processes a BatchRequest with mixed requests and notifications', function () {
    $req1 = createRequest('req/1', [], 'batch-id-1');
    $notif1 = createNotification('notif/1');
    $req2 = createRequest('req/2', [], 'batch-id-2');
    $batchRequest = new BatchRequest([$req1, $notif1, $req2]);

    $result1 = new EmptyResult();
    $result2 = new EmptyResult();

    $this->dispatcher->shouldReceive('handleRequest')
        ->once()
        ->with(
            Mockery::on(fn (Request $r) => $r->id === 'batch-id-1'),
            Mockery::on(fn ($arg) => $arg instanceof Context && $arg->session === $this->session),
        )
        ->andReturn($result1);
    $this->dispatcher->shouldReceive('handleNotification')
        ->once()
        ->with(Mockery::on(fn (Notification $n) => $n->method === 'notif/1'), $this->session);
    $this->dispatcher->shouldReceive('handleRequest')
        ->once()
        ->with(
            Mockery::on(fn (Request $r) => $r->id === 'batch-id-2'),
            Mockery::on(fn ($arg) => $arg instanceof Context && $arg->session === $this->session)
        )
        ->andReturn($result2);


    $this->transport->shouldReceive('sendMessage')->once()
        ->with(Mockery::on(function (BatchResponse $response) use ($req1, $req2, $result1, $result2) {
            expect(count($response->items))->toBe(2);
            expect($response->items[0]->id)->toBe($req1->id);
            expect($response->items[0]->result)->toBe($result1);
            expect($response->items[1]->id)->toBe($req2->id);
            expect($response->items[1]->result)->toBe($result2);
            return true;
        }), SESSION_ID, Mockery::any())
        ->andReturn(resolve(null));

    $this->protocol->processMessage($batchRequest, SESSION_ID);
    $this->session->shouldHaveReceived('save');
});

it('processes a BatchRequest with only notifications and sends no response', function () {
    $notif1 = createNotification('notif/only1');
    $notif2 = createNotification('notif/only2');
    $batchRequest = new BatchRequest([$notif1, $notif2]);

    $this->dispatcher->shouldReceive('handleNotification')->twice();
    $this->transport->shouldNotReceive('sendMessage');

    $this->protocol->processMessage($batchRequest, SESSION_ID);
    $this->session->shouldHaveReceived('save');
});


it('sends error response if session is not found', function () {
    $request = createRequest('test/method');
    $this->sessionManager->shouldReceive('getSession')->with('unknown-client')->andReturn(null);

    $this->transport->shouldReceive('sendMessage')->once()
        ->with(Mockery::on(function (Error $error) use ($request) {
            expectErrorResponse($error, \PhpMcp\Schema\Constants::INVALID_REQUEST, $request->id);
            expect($error->message)->toContain('Invalid or expired session');
            return true;
        }), 'unknown-client', ['status_code' => 404, 'is_initialize_request' => false])
        ->andReturn(resolve(null));

    $this->protocol->processMessage($request, 'unknown-client', ['is_initialize_request' => false]);
    $this->session->shouldNotHaveReceived('save');
});

it('sends error response if session is not initialized for non-initialize request', function () {
    $request = createRequest('tools/list');
    $this->session->shouldReceive('get')->with('initialized', false)->andReturn(false);

    $this->transport->shouldReceive('sendMessage')->once()
        ->with(Mockery::on(function (Error $error) use ($request) {
            expectErrorResponse($error, \PhpMcp\Schema\Constants::INVALID_REQUEST, $request->id);
            expect($error->message)->toContain('Client session not initialized');
            return true;
        }), SESSION_ID, Mockery::any())
        ->andReturn(resolve(null));

    $this->protocol->processMessage($request, SESSION_ID);
});

it('sends error response if capability for request method is disabled', function () {
    $request = createRequest('tools/list');
    $configuration = new Configuration(
        serverInfo: $this->configuration->serverInfo,
        capabilities: ServerCapabilities::make(tools: false),
        logger: $this->logger,
        loop: $this->configuration->loop,
        cache: $this->configuration->cache,
        container: $this->configuration->container,
    );

    $protocol = new Protocol(
        $configuration,
        $this->registry,
        $this->sessionManager,
        $this->dispatcher,
        $this->subscriptionManager
    );

    $protocol->bindTransport($this->transport);

    $this->transport->shouldReceive('sendMessage')->once()
        ->with(Mockery::on(function (Error $error) use ($request) {
            expectErrorResponse($error, \PhpMcp\Schema\Constants::METHOD_NOT_FOUND, $request->id);
            expect($error->message)->toContain('Tools are not enabled');
            return true;
        }), SESSION_ID, Mockery::any())
        ->andReturn(resolve(null));

    $protocol->processMessage($request, SESSION_ID);
});

it('sends exceptions thrown while handling request as JSON-RPC error', function () {
    $request = createRequest('fail/method');
    $exception = McpServerException::methodNotFound('fail/method');

    $this->dispatcher->shouldReceive('handleRequest')->once()->andThrow($exception);

    $this->transport->shouldReceive('sendMessage')->once()
        ->with(Mockery::on(function (Error $error) use ($request) {
            expectErrorResponse($error, \PhpMcp\Schema\Constants::METHOD_NOT_FOUND, $request->id);
            expect($error->message)->toContain('Method not found');
            return true;
        }), SESSION_ID, Mockery::any())
        ->andReturn(resolve(null));

    $this->protocol->processMessage($request, SESSION_ID);


    $request = createRequest('explode/method');
    $exception = new \RuntimeException('Something bad happened');

    $this->dispatcher->shouldReceive('handleRequest')->once()->andThrow($exception);

    $this->transport->shouldReceive('sendMessage')->once()
        ->with(Mockery::on(function (Error $error) use ($request) {
            expectErrorResponse($error, \PhpMcp\Schema\Constants::INTERNAL_ERROR, $request->id);
            expect($error->message)->toContain('Internal error processing method explode/method');
            expect($error->data)->toBe('Something bad happened');
            return true;
        }), SESSION_ID, Mockery::any())
        ->andReturn(resolve(null));

    $this->protocol->processMessage($request, SESSION_ID);
});

it('sends a notification successfully', function () {
    $notification = createNotification('event/occurred', ['value' => true]);

    $this->transport->shouldReceive('sendMessage')->once()
        ->with($notification, SESSION_ID, [])
        ->andReturn(resolve(null));

    $promise = $this->protocol->sendNotification($notification, SESSION_ID);
    await($promise);
});

it('rejects sending notification if transport not bound', function () {
    $this->protocol->unbindTransport();
    $notification = createNotification('event/occurred');

    $promise = $this->protocol->sendNotification($notification, SESSION_ID);

    await($promise->then(null, function (McpServerException $e) {
        expect($e->getMessage())->toContain('Transport not bound');
    }));
});

it('rejects sending notification if transport send fails', function () {
    $notification = createNotification('event/occurred');
    $transportException = new \PhpMcp\Server\Exception\TransportException('Send failed');
    $this->transport->shouldReceive('sendMessage')->once()->andReturn(reject($transportException));

    $promise = $this->protocol->sendNotification($notification, SESSION_ID);
    await($promise->then(null, function (McpServerException $e) use ($transportException) {
        expect($e->getMessage())->toContain('Failed to send notification: Send failed');
        expect($e->getPrevious())->toBe($transportException);
    }));
});

it('notifies resource updated to subscribers', function () {
    $uri = 'test://resource/123';
    $subscribers = ['client-sub-1', 'client-sub-2'];
    $this->subscriptionManager->shouldReceive('getSubscribers')->with($uri)->andReturn($subscribers);

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

    $this->transport->shouldReceive('sendMessage')->twice()
        ->with(Mockery::on(function (Notification $notification) use ($expectedNotification) {
            expect($notification->method)->toBe($expectedNotification->method);
            expect($notification->params)->toBe($expectedNotification->params);
            return true;
        }), Mockery::anyOf(...$subscribers), [])
        ->andReturn(resolve(null));

    $this->protocol->notifyResourceUpdated($uri);
});

it('handles client connected event', function () {
    $this->logger->shouldReceive('info')->with('Client connected', ['sessionId' => SESSION_ID])->once();
    $this->sessionManager->shouldReceive('createSession')->with(SESSION_ID)->once();

    $this->protocol->handleClientConnected(SESSION_ID);
});

it('handles client disconnected event', function () {
    $reason = 'Connection closed';
    $this->logger->shouldReceive('info')->with('Client disconnected', ['clientId' => SESSION_ID, 'reason' => $reason])->once();
    $this->sessionManager->shouldReceive('deleteSession')->with(SESSION_ID)->once();

    $this->protocol->handleClientDisconnected(SESSION_ID, $reason);
});

it('handles transport error event with client ID', function () {
    $error = new \RuntimeException('Socket error');
    $this->logger->shouldReceive('error')
        ->with('Transport error for client', ['error' => 'Socket error', 'exception_class' => \RuntimeException::class, 'clientId' => SESSION_ID])
        ->once();

    $this->protocol->handleTransportError($error, SESSION_ID);
});

it('handles transport error event without client ID', function () {
    $error = new \RuntimeException('Listener setup failed');
    $this->logger->shouldReceive('error')
        ->with('General transport error', ['error' => 'Listener setup failed', 'exception_class' => \RuntimeException::class])
        ->once();

    $this->protocol->handleTransportError($error, null);
});

it('handles list changed event from registry and notifies subscribers', function (string $listType, string $expectedNotificationClass) {
    $listChangeUri = "mcp://changes/{$listType}";
    $subscribers = ['client-sub-A', 'client-sub-B'];

    $this->subscriptionManager->shouldReceive('getSubscribers')->with($listChangeUri)->andReturn($subscribers);
    $capabilities = ServerCapabilities::make(
        toolsListChanged: true,
        resourcesListChanged: true,
        promptsListChanged: true,
    );

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

    $protocol = new Protocol(
        $configuration,
        $this->registry,
        $this->sessionManager,
        $this->dispatcher,
        $this->subscriptionManager
    );

    $protocol->bindTransport($this->transport);

    $this->transport->shouldReceive('sendMessage')
        ->with(Mockery::type($expectedNotificationClass), Mockery::anyOf(...$subscribers), [])
        ->times(count($subscribers))
        ->andReturn(resolve(null));

    $protocol->handleListChanged($listType);
})->with([
    'tools' => ['tools', ToolListChangedNotification::class],
    'resources' => ['resources', ResourceListChangedNotification::class],
]);

it('does not send list changed notification if capability is disabled', function (string $listType) {
    $listChangeUri = "mcp://changes/{$listType}";
    $subscribers = ['client-sub-A'];
    $this->subscriptionManager->shouldReceive('getSubscribers')->with($listChangeUri)->andReturn($subscribers);

    $caps = ServerCapabilities::make(
        toolsListChanged: $listType !== 'tools',
        resourcesListChanged: $listType !== 'resources',
        promptsListChanged: $listType !== 'prompts',
    );

    $configuration = new Configuration(
        serverInfo: $this->configuration->serverInfo,
        capabilities: $caps,
        logger: $this->logger,
        loop: $this->configuration->loop,
        cache: $this->configuration->cache,
        container: $this->configuration->container,
    );

    $protocol = new Protocol(
        $configuration,
        $this->registry,
        $this->sessionManager,
        $this->dispatcher,
        $this->subscriptionManager
    );

    $protocol->bindTransport($this->transport);
    $this->transport->shouldNotReceive('sendMessage');
})->with(['tools', 'resources', 'prompts',]);

it('allows initialize request when session not initialized', function () {
    $request = createRequest('initialize', ['protocolVersion' => SUPPORTED_VERSION_PROTO]);
    $this->session->shouldReceive('get')->with('initialized', false)->andReturn(false);

    $this->dispatcher->shouldReceive('handleRequest')->once()
        ->with(
            Mockery::type(Request::class),
            Mockery::on(fn ($arg) => $arg instanceof Context && $arg->session === $this->session)
        )
        ->andReturn(new EmptyResult());

    $this->transport->shouldReceive('sendMessage')->once()
        ->andReturn(resolve(null));

    $this->protocol->processMessage($request, SESSION_ID);
});

it('allows initialize and ping regardless of capabilities', function (string $method) {
    $request = createRequest($method);
    $capabilities = ServerCapabilities::make(
        tools: false,
        resources: false,
        prompts: false,
        logging: false,
    );
    $configuration = new Configuration(
        serverInfo: $this->configuration->serverInfo,
        capabilities: $capabilities,
        logger: $this->logger,
        loop: $this->configuration->loop,
        cache: $this->configuration->cache,
        container: $this->configuration->container,
    );

    $protocol = new Protocol(
        $configuration,
        $this->registry,
        $this->sessionManager,
        $this->dispatcher,
        $this->subscriptionManager
    );

    $protocol->bindTransport($this->transport);

    $this->dispatcher->shouldReceive('handleRequest')->once()->andReturn(new EmptyResult());
    $this->transport->shouldReceive('sendMessage')->once()
        ->andReturn(resolve(null));

    $protocol->processMessage($request, SESSION_ID);
})->with(['initialize', 'ping']);

```

--------------------------------------------------------------------------------
/tests/Unit/RegistryTest.php:
--------------------------------------------------------------------------------

```php
<?php

namespace PhpMcp\Server\Tests\Unit;

use Mockery;
use Mockery\MockInterface;
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\Registry;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException;

const DISCOVERED_CACHE_KEY_REG = 'mcp_server_discovered_elements';

function createTestToolSchema(string $name = 'test-tool'): Tool
{
    return Tool::make(name: $name, inputSchema: ['type' => 'object'], description: 'Desc ' . $name);
}

function createTestResourceSchema(string $uri = 'test://res', string $name = 'test-res'): Resource
{
    return Resource::make(uri: $uri, name: $name, description: 'Desc ' . $name, mimeType: 'text/plain');
}

function createTestPromptSchema(string $name = 'test-prompt'): Prompt
{
    return Prompt::make(name: $name, description: 'Desc ' . $name, arguments: []);
}

function createTestTemplateSchema(string $uriTemplate = 'tmpl://{id}', string $name = 'test-tmpl'): ResourceTemplate
{
    return ResourceTemplate::make(uriTemplate: $uriTemplate, name: $name, description: 'Desc ' . $name, mimeType: 'application/json');
}

beforeEach(function () {
    /** @var MockInterface&LoggerInterface $logger */
    $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing();
    /** @var MockInterface&CacheInterface $cache */
    $this->cache = Mockery::mock(CacheInterface::class);

    // Default cache behavior: miss on get, success on set/delete
    $this->cache->allows('get')->with(DISCOVERED_CACHE_KEY_REG)->andReturn(null)->byDefault();
    $this->cache->allows('set')->with(DISCOVERED_CACHE_KEY_REG, Mockery::any())->andReturn(true)->byDefault();
    $this->cache->allows('delete')->with(DISCOVERED_CACHE_KEY_REG)->andReturn(true)->byDefault();

    $this->registry = new Registry($this->logger, $this->cache);
    $this->registryNoCache = new Registry($this->logger, null);
});

function getRegistryProperty(Registry $reg, string $propName)
{
    $reflector = new \ReflectionClass($reg);
    $prop = $reflector->getProperty($propName);
    $prop->setAccessible(true);
    return $prop->getValue($reg);
}

it('registers manual tool correctly', function () {
    $toolSchema = createTestToolSchema('manual-tool-1');
    $this->registry->registerTool($toolSchema, ['HandlerClass', 'method'], true);

    $registeredTool = $this->registry->getTool('manual-tool-1');
    expect($registeredTool)->toBeInstanceOf(RegisteredTool::class)
        ->and($registeredTool->schema)->toBe($toolSchema)
        ->and($registeredTool->isManual)->toBeTrue();
    expect($this->registry->getTools())->toHaveKey('manual-tool-1');
});

it('registers discovered tool correctly', function () {
    $toolSchema = createTestToolSchema('discovered-tool-1');
    $this->registry->registerTool($toolSchema, ['HandlerClass', 'method'], false);

    $registeredTool = $this->registry->getTool('discovered-tool-1');
    expect($registeredTool)->toBeInstanceOf(RegisteredTool::class)
        ->and($registeredTool->schema)->toBe($toolSchema)
        ->and($registeredTool->isManual)->toBeFalse();
});

it('registers manual resource correctly', function () {
    $resourceSchema = createTestResourceSchema('manual://res/1');
    $this->registry->registerResource($resourceSchema, ['HandlerClass', 'method'], true);

    $registeredResource = $this->registry->getResource('manual://res/1');
    expect($registeredResource)->toBeInstanceOf(RegisteredResource::class)
        ->and($registeredResource->schema)->toBe($resourceSchema)
        ->and($registeredResource->isManual)->toBeTrue();
    expect($this->registry->getResources())->toHaveKey('manual://res/1');
});

it('registers discovered resource correctly', function () {
    $resourceSchema = createTestResourceSchema('discovered://res/1');
    $this->registry->registerResource($resourceSchema, ['HandlerClass', 'method'], false);

    $registeredResource = $this->registry->getResource('discovered://res/1');
    expect($registeredResource)->toBeInstanceOf(RegisteredResource::class)
        ->and($registeredResource->schema)->toBe($resourceSchema)
        ->and($registeredResource->isManual)->toBeFalse();
});

it('registers manual prompt correctly', function () {
    $promptSchema = createTestPromptSchema('manual-prompt-1');
    $this->registry->registerPrompt($promptSchema, ['HandlerClass', 'method'], [], true);

    $registeredPrompt = $this->registry->getPrompt('manual-prompt-1');
    expect($registeredPrompt)->toBeInstanceOf(RegisteredPrompt::class)
        ->and($registeredPrompt->schema)->toBe($promptSchema)
        ->and($registeredPrompt->isManual)->toBeTrue();
    expect($this->registry->getPrompts())->toHaveKey('manual-prompt-1');
});

it('registers discovered prompt correctly', function () {
    $promptSchema = createTestPromptSchema('discovered-prompt-1');
    $this->registry->registerPrompt($promptSchema, ['HandlerClass', 'method'], [], false);

    $registeredPrompt = $this->registry->getPrompt('discovered-prompt-1');
    expect($registeredPrompt)->toBeInstanceOf(RegisteredPrompt::class)
        ->and($registeredPrompt->schema)->toBe($promptSchema)
        ->and($registeredPrompt->isManual)->toBeFalse();
});

it('registers manual resource template correctly', function () {
    $templateSchema = createTestTemplateSchema('manual://tmpl/{id}');
    $this->registry->registerResourceTemplate($templateSchema, ['HandlerClass', 'method'], [], true);

    $registeredTemplate = $this->registry->getResourceTemplate('manual://tmpl/{id}');
    expect($registeredTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class)
        ->and($registeredTemplate->schema)->toBe($templateSchema)
        ->and($registeredTemplate->isManual)->toBeTrue();
    expect($this->registry->getResourceTemplates())->toHaveKey('manual://tmpl/{id}');
});

it('registers discovered resource template correctly', function () {
    $templateSchema = createTestTemplateSchema('discovered://tmpl/{id}');
    $this->registry->registerResourceTemplate($templateSchema, ['HandlerClass', 'method'], [], false);

    $registeredTemplate = $this->registry->getResourceTemplate('discovered://tmpl/{id}');
    expect($registeredTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class)
        ->and($registeredTemplate->schema)->toBe($templateSchema)
        ->and($registeredTemplate->isManual)->toBeFalse();
});

test('getResource finds exact URI match before template match', function () {
    $exactResourceSchema = createTestResourceSchema('test://item/exact');
    $templateSchema = createTestTemplateSchema('test://item/{itemId}');

    $this->registry->registerResource($exactResourceSchema, ['H', 'm']);
    $this->registry->registerResourceTemplate($templateSchema, ['H', 'm']);

    $found = $this->registry->getResource('test://item/exact');
    expect($found)->toBeInstanceOf(RegisteredResource::class)
        ->and($found->schema->uri)->toBe('test://item/exact');
});

test('getResource finds template match if no exact URI match', function () {
    $templateSchema = createTestTemplateSchema('test://item/{itemId}');
    $this->registry->registerResourceTemplate($templateSchema, ['H', 'm']);

    $found = $this->registry->getResource('test://item/123');
    expect($found)->toBeInstanceOf(RegisteredResourceTemplate::class)
        ->and($found->schema->uriTemplate)->toBe('test://item/{itemId}');
});

test('getResource returns null if no match and templates excluded', function () {
    $templateSchema = createTestTemplateSchema('test://item/{itemId}');
    $this->registry->registerResourceTemplate($templateSchema, ['H', 'm']);

    $found = $this->registry->getResource('test://item/123', false);
    expect($found)->toBeNull();
});

test('getResource returns null if no match at all', function () {
    $found = $this->registry->getResource('nonexistent://uri');
    expect($found)->toBeNull();
});

it('hasElements returns true if any manual elements exist', function () {
    expect($this->registry->hasElements())->toBeFalse();
    $this->registry->registerTool(createTestToolSchema('manual-only'), ['H', 'm'], true);
    expect($this->registry->hasElements())->toBeTrue();
});

it('hasElements returns true if any discovered elements exist', function () {
    expect($this->registry->hasElements())->toBeFalse();
    $this->registry->registerTool(createTestToolSchema('discovered-only'), ['H', 'm'], false);
    expect($this->registry->hasElements())->toBeTrue();
});

it('overrides existing discovered element with manual registration', function (string $type) {
    $nameOrUri = $type === 'resource' ? 'conflict://res' : 'conflict-element';
    $templateUri = 'conflict://tmpl/{id}';

    $discoveredSchema = match ($type) {
        'tool' => createTestToolSchema($nameOrUri),
        'resource' => createTestResourceSchema($nameOrUri),
        'prompt' => createTestPromptSchema($nameOrUri),
        'template' => createTestTemplateSchema($templateUri),
    };
    $manualSchema = clone $discoveredSchema;

    match ($type) {
        'tool' => $this->registry->registerTool($discoveredSchema, ['H', 'm'], false),
        'resource' => $this->registry->registerResource($discoveredSchema, ['H', 'm'], false),
        'prompt' => $this->registry->registerPrompt($discoveredSchema, ['H', 'm'], [], false),
        'template' => $this->registry->registerResourceTemplate($discoveredSchema, ['H', 'm'], [], false),
    };

    match ($type) {
        'tool' => $this->registry->registerTool($manualSchema, ['H', 'm'], true),
        'resource' => $this->registry->registerResource($manualSchema, ['H', 'm'], true),
        'prompt' => $this->registry->registerPrompt($manualSchema, ['H', 'm'], [], true),
        'template' => $this->registry->registerResourceTemplate($manualSchema, ['H', 'm'], [], true),
    };

    $registeredElement = match ($type) {
        'tool' => $this->registry->getTool($nameOrUri),
        'resource' => $this->registry->getResource($nameOrUri),
        'prompt' => $this->registry->getPrompt($nameOrUri),
        'template' => $this->registry->getResourceTemplate($templateUri),
    };

    expect($registeredElement->schema)->toBe($manualSchema);
    expect($registeredElement->isManual)->toBeTrue();
})->with(['tool', 'resource', 'prompt', 'template']);

it('does not override existing manual element with discovered registration', function (string $type) {
    $nameOrUri = $type === 'resource' ? 'manual-priority://res' : 'manual-priority-element';
    $templateUri = 'manual-priority://tmpl/{id}';

    $manualSchema = match ($type) {
        'tool' => createTestToolSchema($nameOrUri),
        'resource' => createTestResourceSchema($nameOrUri),
        'prompt' => createTestPromptSchema($nameOrUri),
        'template' => createTestTemplateSchema($templateUri),
    };
    $discoveredSchema = clone $manualSchema;

    match ($type) {
        'tool' => $this->registry->registerTool($manualSchema, ['H', 'm'], true),
        'resource' => $this->registry->registerResource($manualSchema, ['H', 'm'], true),
        'prompt' => $this->registry->registerPrompt($manualSchema, ['H', 'm'], [], true),
        'template' => $this->registry->registerResourceTemplate($manualSchema, ['H', 'm'], [], true),
    };

    match ($type) {
        'tool' => $this->registry->registerTool($discoveredSchema, ['H', 'm'], false),
        'resource' => $this->registry->registerResource($discoveredSchema, ['H', 'm'], false),
        'prompt' => $this->registry->registerPrompt($discoveredSchema, ['H', 'm'], [], false),
        'template' => $this->registry->registerResourceTemplate($discoveredSchema, ['H', 'm'], [], false),
    };

    $registeredElement = match ($type) {
        'tool' => $this->registry->getTool($nameOrUri),
        'resource' => $this->registry->getResource($nameOrUri),
        'prompt' => $this->registry->getPrompt($nameOrUri),
        'template' => $this->registry->getResourceTemplate($templateUri),
    };

    expect($registeredElement->schema)->toBe($manualSchema);
    expect($registeredElement->isManual)->toBeTrue();
})->with(['tool', 'resource', 'prompt', 'template']);


it('loads discovered elements from cache correctly on construction', function () {
    $toolSchema1 = createTestToolSchema('cached-tool-1');
    $resourceSchema1 = createTestResourceSchema('cached://res/1');
    $cachedData = [
        'tools' => [$toolSchema1->name => json_encode(RegisteredTool::make($toolSchema1, ['H', 'm']))],
        'resources' => [$resourceSchema1->uri => json_encode(RegisteredResource::make($resourceSchema1, ['H', 'm']))],
        'prompts' => [],
        'resourceTemplates' => [],
    ];
    $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn($cachedData);

    $registry = new Registry($this->logger, $this->cache);

    expect($registry->getTool('cached-tool-1'))->toBeInstanceOf(RegisteredTool::class)
        ->and($registry->getTool('cached-tool-1')->isManual)->toBeFalse();
    expect($registry->getResource('cached://res/1'))->toBeInstanceOf(RegisteredResource::class)
        ->and($registry->getResource('cached://res/1')->isManual)->toBeFalse();
    expect($registry->hasElements())->toBeTrue();
});

it('skips loading cached element if manual one with same key is registered later', function () {
    $conflictName = 'conflict-tool';
    $cachedToolSchema = createTestToolSchema($conflictName);
    $manualToolSchema = createTestToolSchema($conflictName); // Different instance

    $cachedData = ['tools' => [$conflictName => json_encode(RegisteredTool::make($cachedToolSchema, ['H', 'm']))]];
    $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn($cachedData);

    $registry = new Registry($this->logger, $this->cache);

    expect($registry->getTool($conflictName)->schema->name)->toBe($cachedToolSchema->name);
    expect($registry->getTool($conflictName)->isManual)->toBeFalse();

    $registry->registerTool($manualToolSchema, ['H', 'm'], true);

    expect($registry->getTool($conflictName)->schema->name)->toBe($manualToolSchema->name);
    expect($registry->getTool($conflictName)->isManual)->toBeTrue();
});


it('saves only non-manual elements to cache', function () {
    $manualToolSchema = createTestToolSchema('manual-save');
    $discoveredToolSchema = createTestToolSchema('discovered-save');
    $expectedRegisteredDiscoveredTool = RegisteredTool::make($discoveredToolSchema, ['H', 'm'], false);

    $this->registry->registerTool($manualToolSchema, ['H', 'm'], true);
    $this->registry->registerTool($discoveredToolSchema, ['H', 'm'], false);

    $expectedCachedData = [
        'tools' => ['discovered-save' => json_encode($expectedRegisteredDiscoveredTool)],
        'resources' => [],
        'prompts' => [],
        'resourceTemplates' => [],
    ];

    $this->cache->shouldReceive('set')->once()
        ->with(DISCOVERED_CACHE_KEY_REG, $expectedCachedData)
        ->andReturn(true);

    $result = $this->registry->save();
    expect($result)->toBeTrue();
});

it('does not attempt to save to cache if cache is null', function () {
    $this->registryNoCache->registerTool(createTestToolSchema('discovered-no-cache'), ['H', 'm'], false);
    $result = $this->registryNoCache->save();
    expect($result)->toBeFalse();
});

it('handles invalid (non-array) data from cache gracefully during load', function () {
    $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn('this is not an array');
    $this->logger->shouldReceive('warning')->with(Mockery::pattern('/Invalid or missing data found in registry cache/'), Mockery::any())->once();

    $registry = new Registry($this->logger, $this->cache);

    expect($registry->hasElements())->toBeFalse();
});

it('handles cache unserialization errors gracefully during load', function () {
    $badSerializedData = ['tools' => ['bad-tool' => 'not a serialized object']];
    $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn($badSerializedData);

    $registry = new Registry($this->logger, $this->cache);

    expect($registry->hasElements())->toBeFalse();
});

it('handles cache general exceptions during load gracefully', function () {
    $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new \RuntimeException('Cache unavailable'));

    $registry = new Registry($this->logger, $this->cache);

    expect($registry->hasElements())->toBeFalse();
});

it('handles cache InvalidArgumentException during load gracefully', function () {
    $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new class() extends \Exception implements CacheInvalidArgumentException {});

    $registry = new Registry($this->logger, $this->cache);
    expect($registry->hasElements())->toBeFalse();
});


it('clears non-manual elements and deletes cache file', function () {
    $this->registry->registerTool(createTestToolSchema('manual-clear'), ['H', 'm'], true);
    $this->registry->registerTool(createTestToolSchema('discovered-clear'), ['H', 'm'], false);

    $this->cache->shouldReceive('delete')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn(true);

    $this->registry->clear();

    expect($this->registry->getTool('manual-clear'))->not->toBeNull();
    expect($this->registry->getTool('discovered-clear'))->toBeNull();
});


it('handles cache exceptions during clear gracefully', function () {
    $this->registry->registerTool(createTestToolSchema('discovered-clear'), ['H', 'm'], false);
    $this->cache->shouldReceive('delete')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new \RuntimeException("Cache delete failed"));

    $this->registry->clear();

    expect($this->registry->getTool('discovered-clear'))->toBeNull();
});

it('emits list_changed event when a new tool is registered', function () {
    $emitted = null;
    $this->registry->on('list_changed', function ($listType) use (&$emitted) {
        $emitted = $listType;
    });

    $this->registry->registerTool(createTestToolSchema('notifying-tool'), ['H', 'm']);
    expect($emitted)->toBe('tools');
});

it('emits list_changed event when a new resource is registered', function () {
    $emitted = null;
    $this->registry->on('list_changed', function ($listType) use (&$emitted) {
        $emitted = $listType;
    });

    $this->registry->registerResource(createTestResourceSchema('notify://res'), ['H', 'm']);
    expect($emitted)->toBe('resources');
});

it('does not emit list_changed event if notifications are disabled', function () {
    $this->registry->disableNotifications();
    $emitted = false;
    $this->registry->on('list_changed', function () use (&$emitted) {
        $emitted = true;
    });

    $this->registry->registerTool(createTestToolSchema('silent-tool'), ['H', 'm']);
    expect($emitted)->toBeFalse();

    $this->registry->enableNotifications();
});

it('computes different hashes for different collections', function () {
    $method = new \ReflectionMethod(Registry::class, 'computeHash');
    $method->setAccessible(true);

    $hash1 = $method->invoke($this->registry, ['a' => 1, 'b' => 2]);
    $hash2 = $method->invoke($this->registry, ['b' => 2, 'a' => 1]);
    $hash3 = $method->invoke($this->registry, ['a' => 1, 'c' => 3]);

    expect($hash1)->toBeString()->not->toBeEmpty();
    expect($hash2)->toBe($hash1);
    expect($hash3)->not->toBe($hash1);
    expect($method->invoke($this->registry, []))->toBe('');
});

it('recomputes and emits list_changed only when content actually changes', function () {
    $tool1 = createTestToolSchema('tool1');
    $tool2 = createTestToolSchema('tool2');
    $callCount = 0;

    $this->registry->on('list_changed', function ($listType) use (&$callCount) {
        if ($listType === 'tools') {
            $callCount++;
        }
    });

    $this->registry->registerTool($tool1, ['H', 'm1']);
    expect($callCount)->toBe(1);

    $this->registry->registerTool($tool1, ['H', 'm1']);
    expect($callCount)->toBe(1);

    $this->registry->registerTool($tool2, ['H', 'm2']);
    expect($callCount)->toBe(2);
});

it('registers tool with closure handler correctly', function () {
    $toolSchema = createTestToolSchema('closure-tool');
    $closure = function (string $input): string {
        return "processed: $input";
    };

    $this->registry->registerTool($toolSchema, $closure, true);

    $registeredTool = $this->registry->getTool('closure-tool');
    expect($registeredTool)->toBeInstanceOf(RegisteredTool::class)
        ->and($registeredTool->schema)->toBe($toolSchema)
        ->and($registeredTool->isManual)->toBeTrue()
        ->and($registeredTool->handler)->toBe($closure);
});

it('registers resource with closure handler correctly', function () {
    $resourceSchema = createTestResourceSchema('closure://res');
    $closure = function (string $uri): array {
        return [new \PhpMcp\Schema\Content\TextContent("Resource: $uri")];
    };

    $this->registry->registerResource($resourceSchema, $closure, true);

    $registeredResource = $this->registry->getResource('closure://res');
    expect($registeredResource)->toBeInstanceOf(RegisteredResource::class)
        ->and($registeredResource->schema)->toBe($resourceSchema)
        ->and($registeredResource->isManual)->toBeTrue()
        ->and($registeredResource->handler)->toBe($closure);
});

it('registers prompt with closure handler correctly', function () {
    $promptSchema = createTestPromptSchema('closure-prompt');
    $closure = function (string $topic): array {
        return [
            \PhpMcp\Schema\Content\PromptMessage::make(
                \PhpMcp\Schema\Enum\Role::User,
                new \PhpMcp\Schema\Content\TextContent("Tell me about $topic")
            )
        ];
    };

    $this->registry->registerPrompt($promptSchema, $closure, [], true);

    $registeredPrompt = $this->registry->getPrompt('closure-prompt');
    expect($registeredPrompt)->toBeInstanceOf(RegisteredPrompt::class)
        ->and($registeredPrompt->schema)->toBe($promptSchema)
        ->and($registeredPrompt->isManual)->toBeTrue()
        ->and($registeredPrompt->handler)->toBe($closure);
});

it('registers resource template with closure handler correctly', function () {
    $templateSchema = createTestTemplateSchema('closure://item/{id}');
    $closure = function (string $uri, string $id): array {
        return [new \PhpMcp\Schema\Content\TextContent("Item $id from $uri")];
    };

    $this->registry->registerResourceTemplate($templateSchema, $closure, [], true);

    $registeredTemplate = $this->registry->getResourceTemplate('closure://item/{id}');
    expect($registeredTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class)
        ->and($registeredTemplate->schema)->toBe($templateSchema)
        ->and($registeredTemplate->isManual)->toBeTrue()
        ->and($registeredTemplate->handler)->toBe($closure);
});

it('does not save closure handlers to cache', function () {
    $closure = function (): string {
        return 'test';
    };
    $arrayHandler = ['TestClass', 'testMethod'];

    $closureTool = createTestToolSchema('closure-tool');
    $arrayTool = createTestToolSchema('array-tool');

    $this->registry->registerTool($closureTool, $closure, true);
    $this->registry->registerTool($arrayTool, $arrayHandler, false);

    $expectedCachedData = [
        'tools' => ['array-tool' => json_encode(RegisteredTool::make($arrayTool, $arrayHandler, false))],
        'resources' => [],
        'prompts' => [],
        'resourceTemplates' => [],
    ];

    $this->cache->shouldReceive('set')->once()
        ->with(DISCOVERED_CACHE_KEY_REG, $expectedCachedData)
        ->andReturn(true);

    $result = $this->registry->save();
    expect($result)->toBeTrue();
});

it('handles static method handlers correctly', function () {
    $toolSchema = createTestToolSchema('static-tool');
    $staticHandler = [TestStaticHandler::class, 'handle'];

    $this->registry->registerTool($toolSchema, $staticHandler, true);

    $registeredTool = $this->registry->getTool('static-tool');
    expect($registeredTool)->toBeInstanceOf(RegisteredTool::class)
        ->and($registeredTool->handler)->toBe($staticHandler);
});

it('handles invokable class string handlers correctly', function () {
    $toolSchema = createTestToolSchema('invokable-tool');
    $invokableHandler = TestInvokableHandler::class;

    $this->registry->registerTool($toolSchema, $invokableHandler, true);

    $registeredTool = $this->registry->getTool('invokable-tool');
    expect($registeredTool)->toBeInstanceOf(RegisteredTool::class)
        ->and($registeredTool->handler)->toBe($invokableHandler);
});

// Test helper classes
class TestStaticHandler
{
    public static function handle(): string
    {
        return 'static result';
    }
}

class TestInvokableHandler
{
    public function __invoke(): string
    {
        return 'invokable result';
    }
}

```

--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------

```markdown
# Changelog

All notable changes to `php-mcp/server` will be documented in this file.

## v3.3.0 - 2025-07-12

### What's Changed

* Feat: Add stateless mode for StreamableHttpServerTransport by @CodeWithKyrian in https://github.com/php-mcp/server/pull/48
* Fix: Make PCNTL extension optional for StdioServerTransport by @CodeWithKyrian in https://github.com/php-mcp/server/pull/49

**Full Changelog**: https://github.com/php-mcp/server/compare/3.2.2...3.3.0

## v3.2.2 - 2025-07-09

### What's Changed

* Fix Architecture graph by @szepeviktor in https://github.com/php-mcp/server/pull/42
* Fix: Correctly handle invokable class tool handlers by @CodeWithKyrian in https://github.com/php-mcp/server/pull/47

### New Contributors

* @szepeviktor made their first contribution in https://github.com/php-mcp/server/pull/42

**Full Changelog**: https://github.com/php-mcp/server/compare/3.2.1...3.2.2

## v3.2.1 - 2025-06-30

### What's Changed

* feat:  use callable instead of Closure|array|string for handler type by @CodeWithKyrian in https://github.com/php-mcp/server/pull/41

**Full Changelog**: https://github.com/php-mcp/server/compare/3.2.0...3.2.1

## v3.2.0 - 2025-06-30

### What's Changed

* fix: resolve cache session handler index inconsistencies by @CodeWithKyrian in https://github.com/php-mcp/server/pull/36
* feat: Add comprehensive callable handler support for closures, static methods, and invokable classes by @CodeWithKyrian in https://github.com/php-mcp/server/pull/38
* feat: Enhanced Completion Providers with Values and Enum Support by @CodeWithKyrian in https://github.com/php-mcp/server/pull/40

### Upgrade Guide

If you're using the `CompletionProvider` attribute with the named `providerClass` parameter, consider updating to the new `provider` parameter for consistency:

```php
// Before (still works)
#[CompletionProvider(providerClass: UserProvider::class)]

// After (recommended)
#[CompletionProvider(provider: UserProvider::class)]




```
The old `providerClass` parameter continues to work for backward compatibility, but may be dropped in a future major version release.

**Full Changelog**: https://github.com/php-mcp/server/compare/3.1.1...3.2.0

## v3.1.1 - 2025-06-26

### What's Changed

* Fix: implement proper MCP protocol version negotiation by @CodeWithKyrian in https://github.com/php-mcp/server/pull/35

**Full Changelog**: https://github.com/php-mcp/server/compare/3.1.0...3.1.1

## v3.1.0 - 2025-06-25

### What's Changed

* Refactor: expose session garbage collection method for integration by @CodeWithKyrian in https://github.com/php-mcp/server/pull/31
* feat: add instructions in server initialization result by @CodeWithKyrian in https://github.com/php-mcp/server/pull/32
* fix(cache): handle missing session in index for CacheSessionHandler by @CodeWithKyrian in https://github.com/php-mcp/server/pull/33

**Full Changelog**: https://github.com/php-mcp/server/compare/3.0.2...3.1.0

## v3.0.2 - 2025-06-25

### What's Changed

* fix: Registry cache clearing bug preventing effective caching by @CodeWithKyrian in https://github.com/php-mcp/server/pull/29
* Fix ServerBuilder error handling for manual element registration by @CodeWithKyrian in https://github.com/php-mcp/server/pull/30

**Full Changelog**: https://github.com/php-mcp/server/compare/3.0.1...3.0.2

## v3.0.1 - 2025-06-24

### What's Changed

* Fix validation failure for MCP tools without parameters by @CodeWithKyrian in https://github.com/php-mcp/server/pull/28

**Full Changelog**: https://github.com/php-mcp/server/compare/3.0.0...3.0.1

## v3.0.0 - 2025-06-21

This release brings support for the latest MCP protocol version along with enhanced schema generation, new transport capabilities, and streamlined APIs.

### ✨ New Features

* **StreamableHttpServerTransport**: New transport with resumability, event sourcing, and JSON response mode for production deployments
* **Smart Schema Generation**: Automatic JSON schema generation from method signatures with optional `#[Schema]` attribute enhancements
* **Completion Providers**: `#[CompletionProvider]` attribute for auto-completion in resource templates and prompts
* **Batch Request Processing**: Full support for JSON-RPC 2.0 batch requests
* **Enhanced Session Management**: Multiple session backends (array, cache, custom) with persistence and garbage collection

### 🔥 Breaking Changes

* **Schema Package Integration**: Now uses `php-mcp/schema` package for all DTOs, requests, responses, and content types
* **Session Management**: `ClientStateManager` replaced with `SessionManager` and `Session` classes
* **Component Reorganization**: `Support\*` classes moved to `Utils\*` namespace
* **Request Processing**: `RequestHandler` renamed to `Dispatcher`

*Note: Most of these changes are internal and won't affect your existing MCP element definitions and handlers.*

### 🔧 Enhanced Features

* **Improved Schema System**: The `#[Schema]` attribute can now be used at both method-level and parameter-level (previously parameter-level only)
* **Better Error Handling**: Enhanced JSON-RPC error responses with proper status codes
* **PSR-20 Clock Interface**: Time management with `SystemClock` implementation
* **Event Store Interface**: Pluggable event storage for resumable connections

### 📦 Dependencies

* Now requires `php-mcp/schema` ^1.0
* Enhanced PSR compliance (PSR-3, PSR-11, PSR-16, PSR-20)

### 🚧 Migration Guide

#### Capabilities Configuration

**Before:**

```php
->withCapabilities(Capabilities::forServer(
    resourcesEnabled: true,
    promptsEnabled: true,
    toolsEnabled: true,
    resourceSubscribe: true
))









```
**After:**

```php
->withCapabilities(ServerCapabilities::make(
    resources: true,
    prompts: true,
    tools: true,
    resourcesSubscribe: true
))









```
#### Transport Upgrade (Optional)

For production HTTP deployments, consider upgrading to the new `StreamableHttpServerTransport`:

**Before:**

```php
$transport = new HttpServerTransport(host: '127.0.0.1', port: 8080);









```
**After:**

```php
$transport = new StreamableHttpServerTransport(host: '127.0.0.1',  port: 8080);









```
### 📚 Documentation

* Complete README rewrite with comprehensive examples and deployment guides
* New production deployment section covering VPS, Docker, and SSL setup
* Enhanced schema generation documentation
* Migration guide for v2.x users

**Full Changelog**: https://github.com/php-mcp/server/compare/2.3.1...3.0.0

## v2.3.1 - 2025-06-13

### What's Changed

* Streamline Registry Notifications and Add Discovery Suppression Support by @CodeWithKyrian in https://github.com/php-mcp/server/pull/22

**Full Changelog**: https://github.com/php-mcp/server/compare/2.3.0...2.3.1

## v2.3.0 - 2025-06-12

### What's Changed

* Fix: Require react/promise ^3.0 for Promise API Compatibility by @CodeWithKyrian in https://github.com/php-mcp/server/pull/18
* Fix: Correct object serialization in FileCache using serialize/unserialize by @CodeWithKyrian in https://github.com/php-mcp/server/pull/19
* check the the header X-Forwarded-Proto for scheme by @bangnokia in https://github.com/php-mcp/server/pull/14
* Feat: Improve HttpServerTransport Extensibility via Protected Methods by @CodeWithKyrian in https://github.com/php-mcp/server/pull/20

### New Contributors

* @bangnokia made their first contribution in https://github.com/php-mcp/server/pull/14

**Full Changelog**: https://github.com/php-mcp/server/compare/2.2.1...2.3.0

## v2.2.1 - 2025-06-07

### What's Changed

* Fix tool name generation for invokable classes with MCP attributes by @CodeWithKyrian in https://github.com/php-mcp/server/pull/13

**Full Changelog**: https://github.com/php-mcp/server/compare/2.2.0...2.2.1

## v2.2.0 - 2025-06-03

### What's Changed

* feat(pagination): Added configuration for a server-wide pagination limit, enabling more controlled data retrieval for list-based MCP operations. This limit is utilized by the `RequestProcessor`.
* feat(handlers): Introduced `HandlerResolver` to provide more robust validation and resolution mechanisms for MCP element handlers, improving the reliability of element registration and invocation.
* refactor(server): Modified the server listening mechanism to allow initialization and transport binding without an immediately blocking event loop. This enhances flexibility for embedding the server or managing its lifecycle in diverse application environments.
* refactor(core): Performed general cleanup and enhancements to the internal architecture and dependencies, contributing to improved code maintainability and overall system stability.

**Full Changelog**: https://github.com/php-mcp/server/compare/2.1.0...2.2.0

## v2.1.0 - 2025-05-17

### What's Changed

* feat(schema): add Schema attributes and enhance DocBlock array type parsing by @CodeWithKyrian in https://github.com/php-mcp/server/pull/8

**Full Changelog**: https://github.com/php-mcp/server/compare/2.0.1...2.1.0

## PHP MCP Server v2.0.1 (HotFix) - 2025-05-11

### What's Changed

* Fix: Ensure react/http is a runtime dependency for HttpServerTransport by @CodeWithKyrian in https://github.com/php-mcp/server/pull/7

**Full Changelog**: https://github.com/php-mcp/server/compare/2.0.0...2.0.1

## PHP MCP Server v2.0.0 - 2025-05-11

This release marks a significant architectural refactoring of the package, aimed at improving modularity, testability, flexibility, and aligning its structure more closely with the `php-mcp/client` library. The core functionality remains, but the way servers are configured, run, and integrated has fundamentally changed.

### What's Changed

#### Core Architecture Overhaul

* **Decoupled Design:** The server core logic is now separated from the transport (network/IO) layer.
  
  * **`ServerBuilder`:** A new fluent builder (`Server::make()`) is the primary way to configure server identity, dependencies (Logger, Cache, Container, Loop), capabilities, and manually registered elements.
  * **`Server` Object:** The main `Server` class, created by the builder, now holds the configured core components (`Registry`, `Processor`, `ClientStateManager`, `Configuration`) but is transport-agnostic itself.
  * **`ServerTransportInterface`:** A new event-driven interface defines the contract for server-side transports (Stdio, Http). Transports are now responsible solely for listening and raw data transfer, emitting events for lifecycle and messages.
  * **`Protocol`:** A new internal class acts as a bridge, listening to events from a bound `ServerTransportInterface` and coordinating interactions with the `Processor` and `ClientStateManager`.
  
* **Explicit Server Execution:**
  
  * The old `$server->run(?string)` method is **removed**.
  * **`$server->listen(ServerTransportInterface $transport)`:** Introduced as the primary way to start a *standalone* server. It binds the `Protocol` to the provided transport, starts the listener, and runs the event loop (making it a blocking call).
  

#### Discovery and Caching Refinements

* **Explicit Discovery:** Attribute discovery is no longer triggered automatically during `build()`. You must now explicitly call `$server->discover(basePath: ..., scanDirs: ...)` *after* building the server instance if you want to find elements via attributes.
  
* **Caching Behavior:**
  
  * Only *discovered* elements are eligible for caching. Manually registered elements (via `ServerBuilder->with*` methods) are **never cached**.
  * The `Registry` attempts to load discovered elements from cache upon instantiation (during `ServerBuilder::build()`).
  * Calling `$server->discover()` will first clear any previously discovered/cached elements from the registry before scanning. It then saves the *newly discovered* results to the cache if enabled (`saveToCache: true`).
  * `Registry` cache methods renamed for clarity: `saveDiscoveredElementsToCache()` and `clearDiscoveredElements()`.
  * `Registry::isLoaded()` renamed to `discoveryRanOrCached()` for better clarity.
  
* **Manual vs. Discovered Precedence:** If an element is registered both manually and found via discovery/cache with the same identifier (name/URI), the **manually registered version always takes precedence**.
  

#### Dependency Injection and Configuration

* **`ConfigurationRepositoryInterface` Removed:** This interface and its default implementation (`ArrayConfigurationRepository`) have been removed.
* **`Configuration` Value Object:** A new `PhpMcp\Server\Configuration` readonly value object bundles core dependencies (Logger, Loop, Cache, Container, Server Info, Capabilities, TTLs) assembled by the `ServerBuilder`.
* **Simplified Dependencies:** Core components (`Registry`, `Processor`, `ClientStateManager`, `DocBlockParser`, `Discoverer`) now have simpler constructors, accepting direct dependencies.
* **PSR-11 Container Role:** The container provided via `ServerBuilder->withContainer()` (or the default `BasicContainer`) is now primarily used by the `Processor` to resolve *user-defined handler classes* and their dependencies.
* **Improved `BasicContainer`:** The default DI container (`PhpMcp\Server\Defaults\BasicContainer`) now supports simple constructor auto-wiring.
* **`ClientStateManager` Default Cache:** If no `CacheInterface` is provided to the `ClientStateManager`, it now defaults to an in-memory `PhpMcp\Server\Defaults\ArrayCache`.

#### Schema Generation and Validation

* **Removed Optimistic String Format Inference:** The `SchemaGenerator` no longer automatically infers JSON Schema `format` keywords (like "date-time", "email") for string parameters. This makes default schemas less strict, avoiding validation issues for users with simpler string formats. Specific format validation should now be handled within tool/resource methods or via future explicit schema annotation features.
* **Improved Tool Call Validation Error Messages:** When `tools/call` parameters fail schema validation, the JSON-RPC error response now includes a more informative summary message detailing the specific validation failures, in addition to the structured error data.

#### Transports

* **New Implementations:** Introduced `PhpMcp\Server\Transports\StdioServerTransport` and `PhpMcp\Server\Transports\HttpServerTransport`, both implementing `ServerTransportInterface`.
  
  * `StdioServerTransport` constructor now accepts custom input/output stream resources, improving testability and flexibility (defaults to `STDIN`/`STDOUT`).
  * `HttpServerTransport` constructor now accepts an array of request interceptor callables for custom request pre-processing (e.g., authentication), and also takes `host`, `port`, `mcpPathPrefix`, and `sslContext` for server configuration.
  
* **Windows `stdio` Limitation:** `StdioServerTransport` now throws a `TransportException` if instantiated with default `STDIN`/`STDOUT` on Windows, due to PHP's limitations with non-blocking pipes, guiding users to `WSL` or `HttpServerTransport`.
  
* **Aware Interfaces:** Transports can implement `LoggerAwareInterface` and `LoopAwareInterface` to receive the configured Logger and Loop instances when `$server->listen()` is called.
  
* **Removed:** The old `StdioTransportHandler`, `HttpTransportHandler`, and `ReactPhpHttpTransportHandler` classes.
  

#### Capabilities Configuration

* **`Model\Capabilities` Class:** Introduced a new `PhpMcp\Server\Model\Capabilities` value object (created via `Capabilities::forServer(...)`) to explicitly configure and represent server capabilities.

#### Exception Handling

* **`McpServerException`:** Renamed the base exception from `McpException` to `PhpMcp\Server\Exception\McpServerException`.
* **New Exception Types:** Added more specific exceptions: `ConfigurationException`, `DiscoveryException`, `DefinitionException`, `TransportException`, `ProtocolException`.

#### Fixes

* Fixed `StdioServerTransport` not cleanly exiting on `Ctrl+C` due to event loop handling.
* Fixed `TypeError` in `JsonRpc\Response` for parse errors with `null` ID.
* Corrected discovery caching logic for explicit `discover()` calls.
* Improved `HttpServerTransport` robustness for initial SSE event delivery and POST body handling.
* Ensured manual registrations correctly take precedence over discovered/cached elements with the same identifier.

#### Internal Changes

* Introduced `LoggerAwareInterface` and `LoopAwareInterface` for dependency injection into transports.
* Refined internal event handling between transport implementations and the `Protocol`.
* Renamed `TransportState` to `ClientStateManager` and introduced a `ClientState` Value Object.

#### Documentation and Examples

* Significantly revised `README.md` to reflect the new architecture, API, discovery flow, transport usage, and configuration.
* Added new and updated examples for standalone `stdio` and `http` servers, demonstrating discovery, manual registration, custom dependency injection, complex schemas, and environment variable usage.

### Breaking Changes

This is a major refactoring with significant breaking changes:

1. **`Server->run()` Method Removed:** Replace calls to `$server->run('stdio')` with:
   
    ```php
    $transport = new StdioServerTransport();
   // Optionally call $server->discover(...) first
   $server->listen($transport);
   
   
   
   
   
   
   
   
   
   
   
   
   
   
   
   
    ```
   The `http` and `reactphp` options for `run()` were already invalid and are fully removed.
   
2. **Configuration (`ConfigurationRepositoryInterface` Removed):** Configuration is now handled via the `Configuration` VO assembled by `ServerBuilder`. Remove any usage of the old `ConfigurationRepositoryInterface`. Core settings like server name/version are set via `withServerInfo`, capabilities via `withCapabilities`.
   
3. **Dependency Injection:**
   
   * If using `ServerBuilder->withContainer()` with a custom PSR-11 container, that container is now only responsible for resolving *your application's handler classes* and their dependencies.
   * Core server dependencies (Logger, Cache, Loop) **must** be provided explicitly to the `ServerBuilder` using `withLogger()`, `withCache()`, `withLoop()` or rely on the builder's defaults.
   
4. **Transport Handlers Replaced:**
   
   * `StdioTransportHandler`, `HttpTransportHandler`, `ReactPhpHttpTransportHandler` are **removed**.
   * Use `new StdioServerTransport()` or `new HttpServerTransport(...)` and pass them to `$server->listen()`.
   * Constructor signatures and interaction patterns have changed.
   
5. **`Registry` Cache Methods Renamed:** `saveElementsToCache` is now `saveDiscoveredElementsToCache`, and `clearCache` is now `clearDiscoveredElements`. Their behavior is also changed to only affect discovered elements.
   
6. **Core Component Constructors:** The constructors for `Registry`, `Processor`, `ClientStateManager` (previously `TransportState`), `Discoverer`, `DocBlockParser` have changed. Update any direct instantiations (though typically these are managed internally).
   
7. **Exception Renaming:** `McpException` is now `McpServerException`. Update `catch` blocks accordingly.
   
8. **Default Null Logger:** Logging is effectively disabled by default. Provide a logger via `ServerBuilder->withLogger()` to enable it.
   
9. **Schema Generation:** Automatic string `format` inference (e.g., "date-time") removed from `SchemaGenerator`. String parameters are now plain strings in the schema unless a more advanced format definition mechanism is used in the future.
   

### Deprecations

* (None introduced in this refactoring, as major breaking changes were made directly).

**Full Changelog**: https://github.com/php-mcp/server/compare/1.1.0...2.0.0

## PHP MCP Server v1.1.0 - 2025-05-01

### Added

* **Manual Element Registration:** Added fluent methods `withTool()`, `withResource()`, `withPrompt()`, and `withResourceTemplate()` to the `Server` class. This allows programmatic registration of MCP elements as an alternative or supplement to attribute discovery. Both `[ClassName::class, 'methodName']` array handlers and invokable class string handlers are supported.
* **Invokable Class Attribute Discovery:** The server's discovery mechanism now supports placing `#[Mcp*]` attributes directly on invokable PHP class definitions (classes with a public `__invoke` method). The `__invoke` method will be used as the handler.
* **Discovery Path Configuration:** Added `withBasePath()`, `withScanDirectories()`, and `withExcludeDirectories()` methods to the `Server` class for finer control over which directories are scanned during attribute discovery.

### Changed

* **Dependency Injection:** Refactored internal dependency management. Core server components (`Processor`, `Registry`, `ClientStateManager`, etc.) now resolve `LoggerInterface`, `CacheInterface`, and `ConfigurationRepositoryInterface` Just-In-Time from the provided PSR-11 container. See **Breaking Changes** for implications.
* **Default Logging Behavior:** Logging is now **disabled by default**. To enable logging, provide a `LoggerInterface` implementation via `withLogger()` (when using the default container) or by registering it within your custom PSR-11 container.
* **Transport Handler Constructors:** Transport Handlers (e.g., `StdioTransportHandler`, `HttpTransportHandler`) now primarily accept the `Server` instance in their constructor, simplifying their instantiation.

### Fixed

* Prevented potential "Constant STDERR not defined" errors in non-CLI environments by changing the default logger behavior (see Changed section).

### Updated

* Extensively updated `README.md` to document manual registration, invokable class discovery, the dependency injection overhaul, discovery path configuration, transport handler changes, and the new default logging behavior.

### Breaking Changes

* **Dependency Injection Responsibility:** Due to the DI refactoring, if you provide a custom PSR-11 container using `withContainer()`, you **MUST** ensure that your container is configured to provide implementations for `LoggerInterface`, `CacheInterface`, and `ConfigurationRepositoryInterface`. The server relies on being able to fetch these from the container.
* **`withLogger/Cache/Config` Behavior with Custom Container:** When a custom container is provided via `withContainer()`, calls to `->withLogger()`, `->withCache()`, or `->withConfig()` on the `Server` instance will **not** override the services resolved from *your* container during runtime. Configuration for these core services must be done directly within your custom container setup.
* **Transport Handler Constructor Signatures:** The constructor signatures for `StdioTransportHandler`, `HttpTransportHandler`, and `ReactPhpHttpTransportHandler` have changed. They now primarily require the `Server` instance. Update any direct instantiations of these handlers accordingly.

**Full Changelog**: https://github.com/php-mcp/server/compare/1.0.0...1.1.0

## Release v1.0.0 - Initial Release

🚀 **Initial release of PHP MCP SERVER!**

This release introduces the core implementation of the Model Context Protocol (MCP) server for PHP applications. The goal is to provide a robust, flexible, and developer-friendly way to expose parts of your PHP application as MCP Tools, Resources, and Prompts, enabling standardized communication with AI assistants like Claude, Cursor, and others.

### ✨ Key Features:

* **Attribute-Based Definitions:** Easily define MCP Tools (`#[McpTool]`), Resources (`#[McpResource]`, `#[McpResourceTemplate]`), and Prompts (`#[McpPrompt]`) using PHP 8 attributes directly on your methods.
  
* **Automatic Metadata Inference:** Leverages method signatures (parameters, type hints) and DocBlocks (`@param`, `@return`, summaries) to automatically generate MCP schemas and descriptions, minimizing boilerplate.
  
* **PSR Compliance:** Integrates seamlessly with standard PHP interfaces:
  
  * `PSR-3` (LoggerInterface) for flexible logging.
  * `PSR-11` (ContainerInterface) for dependency injection and class resolution.
  * `PSR-16` (SimpleCacheInterface) for caching discovered elements and transport state.
  
* **Automatic Discovery:** Scans configured directories to find and register your annotated MCP elements.
  
* **Flexible Configuration:** Uses a configuration repository (`ConfigurationRepositoryInterface`) for fine-grained control over server behaviour, capabilities, and caching.
  
* **Multiple Transports:**
  
  * Built-in support for the `stdio` transport, ideal for command-line driven clients.
  * Includes `HttpTransportHandler` components for building standard `http` (HTTP+SSE) transports (requires integration into an HTTP server).
  * Provides `ReactPhpHttpTransportHandler` for seamless integration with asynchronous ReactPHP applications.
  
* **Protocol Support:** Implements the `2024-11-05` version of the Model Context Protocol.
  
* **Framework Agnostic:** Designed to work in vanilla PHP projects or integrated into any framework.
  

### 🚀 Getting Started

Please refer to the [README.md](README.md) for detailed installation instructions, usage examples, and core concepts. Sample implementations for `stdio` and `reactphp` are available in the `samples/` directory.

### ⚠️ Important Notes

* When implementing the `http` transport using `HttpTransportHandler`, be aware of the critical server environment requirements detailed in the README regarding concurrent request handling for SSE. Standard synchronous PHP servers (like `php artisan serve` or basic Apache/Nginx setups) are generally **not suitable** without proper configuration for concurrency (e.g., PHP-FPM with multiple workers, Octane, Swoole, ReactPHP, RoadRunner, FrankenPHP).

### Future Plans

While this package focuses on the server implementation, future projects within the `php-mcp` organization may include client libraries and other utilities related to MCP in PHP.

```
Page 4/5FirstPrevNextLast