#
tokens: 46663/50000 15/154 files (page 3/7)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 3 of 7. Use http://codebase.md/php-mcp/server?lines=true&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

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

```php
  1 | <?php
  2 | 
  3 | namespace PhpMcp\Server\Tests\Unit\Elements;
  4 | 
  5 | use InvalidArgumentException;
  6 | use Mockery;
  7 | use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
  8 | use PhpMcp\Schema\Tool;
  9 | use PhpMcp\Server\Context;
 10 | use PhpMcp\Server\Contracts\SessionInterface;
 11 | use PhpMcp\Server\Elements\RegisteredTool;
 12 | use PhpMcp\Schema\Content\TextContent;
 13 | use PhpMcp\Schema\Content\ImageContent;
 14 | use PhpMcp\Server\Tests\Fixtures\General\ToolHandlerFixture;
 15 | use Psr\Container\ContainerInterface;
 16 | use JsonException;
 17 | use PhpMcp\Server\Exception\McpServerException;
 18 | 
 19 | uses(MockeryPHPUnitIntegration::class);
 20 | 
 21 | beforeEach(function () {
 22 |     $this->container = Mockery::mock(ContainerInterface::class);
 23 |     $this->handlerInstance = new ToolHandlerFixture();
 24 |     $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)
 25 |         ->andReturn($this->handlerInstance)->byDefault();
 26 | 
 27 |     $this->toolSchema = Tool::make(
 28 |         name: 'test-tool',
 29 |         inputSchema: ['type' => 'object', 'properties' => ['name' => ['type' => 'string']]]
 30 |     );
 31 | 
 32 |     $this->registeredTool = RegisteredTool::make(
 33 |         $this->toolSchema,
 34 |         [ToolHandlerFixture::class, 'greet']
 35 |     );
 36 |     $this->context = new Context(Mockery::mock(SessionInterface::class));
 37 | });
 38 | 
 39 | it('constructs correctly and exposes schema', function () {
 40 |     expect($this->registeredTool->schema)->toBe($this->toolSchema);
 41 |     expect($this->registeredTool->handler)->toBe([ToolHandlerFixture::class, 'greet']);
 42 |     expect($this->registeredTool->isManual)->toBeFalse();
 43 | });
 44 | 
 45 | it('can be made as a manual registration', function () {
 46 |     $manualTool = RegisteredTool::make($this->toolSchema, [ToolHandlerFixture::class, 'greet'], true);
 47 |     expect($manualTool->isManual)->toBeTrue();
 48 | });
 49 | 
 50 | it('calls the handler with prepared arguments', function () {
 51 |     $tool = RegisteredTool::make(
 52 |         Tool::make('sum-tool', ['type' => 'object', 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']]]),
 53 |         [ToolHandlerFixture::class, 'sum']
 54 |     );
 55 |     $mockHandler = Mockery::mock(ToolHandlerFixture::class);
 56 |     $mockHandler->shouldReceive('sum')->with(5, 10)->once()->andReturn(15);
 57 |     $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->andReturn($mockHandler);
 58 | 
 59 |     $resultContents = $tool->call($this->container, ['a' => 5, 'b' => '10'], $this->context); // '10' will be cast to int by prepareArguments
 60 | 
 61 |     expect($resultContents)->toBeArray()->toHaveCount(1);
 62 |     expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('15');
 63 | });
 64 | 
 65 | it('calls handler with no arguments if tool takes none and none provided', function () {
 66 |     $tool = RegisteredTool::make(
 67 |         Tool::make('no-args-tool', ['type' => 'object', 'properties' => []]),
 68 |         [ToolHandlerFixture::class, 'noParamsTool']
 69 |     );
 70 |     $mockHandler = Mockery::mock(ToolHandlerFixture::class);
 71 |     $mockHandler->shouldReceive('noParamsTool')->withNoArgs()->once()->andReturn(['status' => 'done']);
 72 |     $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->andReturn($mockHandler);
 73 | 
 74 |     $resultContents = $tool->call($this->container, [], $this->context);
 75 |     expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode(['status' => 'done'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
 76 | });
 77 | 
 78 | 
 79 | dataset('tool_handler_return_values', [
 80 |     'string'        => ['returnString', "This is a string result."],
 81 |     'integer'       => ['returnInteger', "12345"],
 82 |     'float'         => ['returnFloat', "67.89"],
 83 |     'boolean_true'  => ['returnBooleanTrue', "true"],
 84 |     'boolean_false' => ['returnBooleanFalse', "false"],
 85 |     'null'          => ['returnNull', "(null)"],
 86 |     'array_to_json' => ['returnArray', json_encode(['message' => 'Array result', 'data' => [1, 2, 3]], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)],
 87 |     'object_to_json' => ['returnStdClass', json_encode((object)['property' => "value"], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)],
 88 | ]);
 89 | 
 90 | it('formats various scalar and simple object/array handler results into TextContent', function (string $handlerMethod, string $expectedText) {
 91 |     $tool = RegisteredTool::make(
 92 |         Tool::make('format-test-tool', ['type' => 'object', 'properties' => []]),
 93 |         [ToolHandlerFixture::class, $handlerMethod]
 94 |     );
 95 | 
 96 |     $resultContents = $tool->call($this->container, [], $this->context);
 97 | 
 98 |     expect($resultContents)->toBeArray()->toHaveCount(1);
 99 |     expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe($expectedText);
100 | })->with('tool_handler_return_values');
101 | 
102 | it('returns single Content object from handler as array with one Content object', function () {
103 |     $tool = RegisteredTool::make(
104 |         Tool::make('content-test-tool', ['type' => 'object', 'properties' => []]),
105 |         [ToolHandlerFixture::class, 'returnTextContent']
106 |     );
107 |     $resultContents = $tool->call($this->container, [], $this->context);
108 | 
109 |     expect($resultContents)->toBeArray()->toHaveCount(1);
110 |     expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Pre-formatted TextContent.");
111 | });
112 | 
113 | it('returns array of Content objects from handler as is', function () {
114 |     $tool = RegisteredTool::make(
115 |         Tool::make('content-array-tool', ['type' => 'object', 'properties' => []]),
116 |         [ToolHandlerFixture::class, 'returnArrayOfContent']
117 |     );
118 |     $resultContents = $tool->call($this->container, [], $this->context);
119 | 
120 |     expect($resultContents)->toBeArray()->toHaveCount(2);
121 |     expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Part 1");
122 |     expect($resultContents[1])->toBeInstanceOf(ImageContent::class)->data->toBe("imgdata");
123 | });
124 | 
125 | it('formats mixed array from handler into array of Content objects', function () {
126 |     $tool = RegisteredTool::make(
127 |         Tool::make('mixed-array-tool', ['type' => 'object', 'properties' => []]),
128 |         [ToolHandlerFixture::class, 'returnMixedArray']
129 |     );
130 |     $resultContents = $tool->call($this->container, [], $this->context);
131 | 
132 |     expect($resultContents)->toBeArray()->toHaveCount(8);
133 | 
134 |     expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("A raw string");
135 |     expect($resultContents[1])->toBeInstanceOf(TextContent::class)->text->toBe("A TextContent object"); // Original TextContent is preserved
136 |     expect($resultContents[2])->toBeInstanceOf(TextContent::class)->text->toBe("123");
137 |     expect($resultContents[3])->toBeInstanceOf(TextContent::class)->text->toBe("true");
138 |     expect($resultContents[4])->toBeInstanceOf(TextContent::class)->text->toBe("(null)");
139 |     expect($resultContents[5])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode(['nested_key' => 'nested_value', 'sub_array' => [4, 5]], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
140 |     expect($resultContents[6])->toBeInstanceOf(ImageContent::class)->data->toBe("img_data_mixed"); // Original ImageContent is preserved
141 |     expect($resultContents[7])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode((object)['obj_prop' => 'obj_val'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
142 | });
143 | 
144 | it('formats empty array from handler into TextContent with "[]"', function () {
145 |     $tool = RegisteredTool::make(
146 |         Tool::make('empty-array-tool', ['type' => 'object', 'properties' => []]),
147 |         [ToolHandlerFixture::class, 'returnEmptyArray']
148 |     );
149 |     $resultContents = $tool->call($this->container, [], $this->context);
150 | 
151 |     expect($resultContents)->toBeArray()->toHaveCount(1);
152 |     expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('[]');
153 | });
154 | 
155 | it('throws JsonException during formatResult if handler returns unencodable value', function () {
156 |     $tool = RegisteredTool::make(
157 |         Tool::make('unencodable-tool', ['type' => 'object', 'properties' => []]),
158 |         [ToolHandlerFixture::class, 'toolUnencodableResult']
159 |     );
160 |     $tool->call($this->container, [], $this->context);
161 | })->throws(JsonException::class);
162 | 
163 | it('re-throws exceptions from handler execution wrapped in McpServerException from handle()', function () {
164 |     $tool = RegisteredTool::make(
165 |         Tool::make('exception-tool', ['type' => 'object', 'properties' => []]),
166 |         [ToolHandlerFixture::class, 'toolThatThrows']
167 |     );
168 | 
169 |     $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->once()->andReturn(new ToolHandlerFixture());
170 | 
171 |     $tool->call($this->container, [], $this->context);
172 | })->throws(InvalidArgumentException::class, "Something went wrong in the tool.");
173 | 
```

--------------------------------------------------------------------------------
/src/Transports/StdioServerTransport.php:
--------------------------------------------------------------------------------

```php
  1 | <?php
  2 | 
  3 | declare(strict_types=1);
  4 | 
  5 | namespace PhpMcp\Server\Transports;
  6 | 
  7 | use Evenement\EventEmitterTrait;
  8 | use PhpMcp\Schema\JsonRpc\Parser;
  9 | use PhpMcp\Server\Contracts\LoggerAwareInterface;
 10 | use PhpMcp\Server\Contracts\LoopAwareInterface;
 11 | use PhpMcp\Server\Contracts\ServerTransportInterface;
 12 | use PhpMcp\Server\Exception\TransportException;
 13 | use PhpMcp\Schema\JsonRpc\Error;
 14 | use PhpMcp\Schema\JsonRpc\Message;
 15 | use Psr\Log\LoggerInterface;
 16 | use Psr\Log\NullLogger;
 17 | use React\ChildProcess\Process;
 18 | use React\EventLoop\Loop;
 19 | use React\EventLoop\LoopInterface;
 20 | use React\Promise\Deferred;
 21 | use React\Promise\PromiseInterface;
 22 | use React\Stream\ReadableResourceStream;
 23 | use React\Stream\ReadableStreamInterface;
 24 | use React\Stream\WritableResourceStream;
 25 | use React\Stream\WritableStreamInterface;
 26 | use Throwable;
 27 | 
 28 | use function React\Promise\reject;
 29 | 
 30 | /**
 31 |  * Implementation of the STDIO server transport using ReactPHP Process and Streams.
 32 |  * Listens on STDIN, writes to STDOUT, and emits events for the Protocol.
 33 |  */
 34 | class StdioServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface
 35 | {
 36 |     use EventEmitterTrait;
 37 | 
 38 |     protected LoggerInterface $logger;
 39 | 
 40 |     protected LoopInterface $loop;
 41 | 
 42 |     protected ?Process $process = null;
 43 | 
 44 |     protected ?ReadableStreamInterface $stdin = null;
 45 | 
 46 |     protected ?WritableStreamInterface $stdout = null;
 47 | 
 48 |     protected string $buffer = '';
 49 | 
 50 |     protected bool $closing = false;
 51 | 
 52 |     protected bool $listening = false;
 53 | 
 54 |     private const CLIENT_ID = 'stdio';
 55 | 
 56 |     /**
 57 |      * Constructor takes optional stream resources.
 58 |      * Defaults to STDIN and STDOUT if not provided.
 59 |      * Dependencies like Logger and Loop are injected via setters.
 60 |      *
 61 |      * @param  resource|null  $inputStreamResource  The readable resource (e.g., STDIN).
 62 |      * @param  resource|null  $outputStreamResource  The writable resource (e.g., STDOUT).
 63 |      *
 64 |      * @throws TransportException If provided resources are invalid.
 65 |      */
 66 |     public function __construct(
 67 |         protected $inputStreamResource = STDIN,
 68 |         protected $outputStreamResource = STDOUT
 69 |     ) {
 70 |         if (str_contains(PHP_OS, 'WIN') && ($this->inputStreamResource === STDIN && $this->outputStreamResource === STDOUT)) {
 71 |             $message = 'STDIN and STDOUT are not supported as input and output stream resources' .
 72 |                 'on Windows due to PHP\'s limitations with non blocking pipes.' .
 73 |                 'Please use WSL or HttpServerTransport, or if you are advanced, provide your own stream resources.';
 74 | 
 75 |             throw new TransportException($message);
 76 |         }
 77 | 
 78 |         // if (str_contains(PHP_OS, 'WIN')) {
 79 |         //     $this->inputStreamResource = pclose(popen('winpty -c "'.$this->inputStreamResource.'"', 'r'));
 80 |         //     $this->outputStreamResource = pclose(popen('winpty -c "'.$this->outputStreamResource.'"', 'w'));
 81 |         // }
 82 | 
 83 |         if (! is_resource($this->inputStreamResource) || get_resource_type($this->inputStreamResource) !== 'stream') {
 84 |             throw new TransportException('Invalid input stream resource provided.');
 85 |         }
 86 | 
 87 |         if (! is_resource($this->outputStreamResource) || get_resource_type($this->outputStreamResource) !== 'stream') {
 88 |             throw new TransportException('Invalid output stream resource provided.');
 89 |         }
 90 | 
 91 |         $this->logger = new NullLogger();
 92 |         $this->loop = Loop::get();
 93 |     }
 94 | 
 95 |     public function setLogger(LoggerInterface $logger): void
 96 |     {
 97 |         $this->logger = $logger;
 98 |     }
 99 | 
100 |     public function setLoop(LoopInterface $loop): void
101 |     {
102 |         $this->loop = $loop;
103 |     }
104 | 
105 |     /**
106 |      * Starts listening on STDIN.
107 |      *
108 |      * @throws TransportException If already listening or streams cannot be opened.
109 |      */
110 |     public function listen(): void
111 |     {
112 |         if ($this->listening) {
113 |             throw new TransportException('Stdio transport is already listening.');
114 |         }
115 | 
116 |         if ($this->closing) {
117 |             throw new TransportException('Cannot listen, transport is closing/closed.');
118 |         }
119 | 
120 |         try {
121 |             $this->stdin = new ReadableResourceStream($this->inputStreamResource, $this->loop);
122 |             $this->stdout = new WritableResourceStream($this->outputStreamResource, $this->loop);
123 |         } catch (Throwable $e) {
124 |             $this->logger->error('Failed to open STDIN/STDOUT streams.', ['exception' => $e]);
125 |             throw new TransportException("Failed to open standard streams: {$e->getMessage()}", 0, $e);
126 |         }
127 | 
128 |         $this->stdin->on('data', function ($chunk) {
129 |             $this->buffer .= $chunk;
130 |             $this->processBuffer();
131 |         });
132 | 
133 |         $this->stdin->on('error', function (Throwable $error) {
134 |             $this->logger->error('STDIN stream error.', ['error' => $error->getMessage()]);
135 |             $this->emit('error', [new TransportException("STDIN error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]);
136 |             $this->close();
137 |         });
138 | 
139 |         $this->stdin->on('close', function () {
140 |             $this->logger->info('STDIN stream closed.');
141 |             $this->emit('client_disconnected', [self::CLIENT_ID, 'STDIN Closed']);
142 |             $this->close();
143 |         });
144 | 
145 |         $this->stdout->on('error', function (Throwable $error) {
146 |             $this->logger->error('STDOUT stream error.', ['error' => $error->getMessage()]);
147 |             $this->emit('error', [new TransportException("STDOUT error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]);
148 |             $this->close();
149 |         });
150 | 
151 |         try {
152 |             $signalHandler = function (int $signal) {
153 |                 $this->logger->info("Received signal {$signal}, shutting down.");
154 |                 $this->close();
155 |             };
156 |             $this->loop->addSignal(SIGTERM, $signalHandler);
157 |             $this->loop->addSignal(SIGINT, $signalHandler);
158 |         } catch (Throwable $e) {
159 |             $this->logger->debug('Signal handling not supported by current event loop.');
160 |         }
161 | 
162 |         $this->logger->info('Server is up and listening on STDIN 🚀');
163 | 
164 |         $this->listening = true;
165 |         $this->closing = false;
166 |         $this->emit('ready');
167 |         $this->emit('client_connected', [self::CLIENT_ID]);
168 |     }
169 | 
170 |     /** Processes the internal buffer to find complete lines/frames. */
171 |     private function processBuffer(): void
172 |     {
173 |         while (str_contains($this->buffer, "\n")) {
174 |             $pos = strpos($this->buffer, "\n");
175 |             $line = substr($this->buffer, 0, $pos);
176 |             $this->buffer = substr($this->buffer, $pos + 1);
177 | 
178 |             $trimmedLine = trim($line);
179 |             if (empty($trimmedLine)) {
180 |                 continue;
181 |             }
182 | 
183 |             try {
184 |                 $message = Parser::parse($trimmedLine);
185 |             } catch (Throwable $e) {
186 |                 $this->logger->error('Error parsing message', ['exception' => $e]);
187 |                 $error = Error::forParseError("Invalid JSON: " . $e->getMessage());
188 |                 $this->sendMessage($error, self::CLIENT_ID);
189 |                 continue;
190 |             }
191 | 
192 |             $this->emit('message', [$message, self::CLIENT_ID]);
193 |         }
194 |     }
195 | 
196 |     /**
197 |      * Sends a raw, framed message to STDOUT.
198 |      */
199 |     public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface
200 |     {
201 |         if ($this->closing || ! $this->stdout || ! $this->stdout->isWritable()) {
202 |             return reject(new TransportException('Stdio transport is closed or STDOUT is not writable.'));
203 |         }
204 | 
205 |         $deferred = new Deferred();
206 |         $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
207 |         $written = $this->stdout->write($json . "\n");
208 | 
209 |         if ($written) {
210 |             $deferred->resolve(null);
211 |         } else {
212 |             $this->logger->debug('STDOUT buffer full, waiting for drain.');
213 |             $this->stdout->once('drain', function () use ($deferred) {
214 |                 $this->logger->debug('STDOUT drained.');
215 |                 $deferred->resolve(null);
216 |             });
217 |         }
218 | 
219 |         return $deferred->promise();
220 |     }
221 | 
222 |     /**
223 |      * Stops listening and cleans up resources.
224 |      */
225 |     public function close(): void
226 |     {
227 |         if ($this->closing) {
228 |             return;
229 |         }
230 |         $this->closing = true;
231 |         $this->listening = false;
232 |         $this->logger->info('Closing Stdio transport...');
233 | 
234 |         $this->stdin?->close();
235 |         $this->stdout?->close();
236 | 
237 |         $this->stdin = null;
238 |         $this->stdout = null;
239 | 
240 |         $this->emit('close', ['StdioTransport closed.']);
241 |         $this->removeAllListeners();
242 |     }
243 | }
244 | 
```

--------------------------------------------------------------------------------
/src/Elements/RegisteredResource.php:
--------------------------------------------------------------------------------

```php
  1 | <?php
  2 | 
  3 | declare(strict_types=1);
  4 | 
  5 | namespace PhpMcp\Server\Elements;
  6 | 
  7 | use PhpMcp\Schema\Content\BlobResourceContents;
  8 | use PhpMcp\Schema\Content\EmbeddedResource;
  9 | use PhpMcp\Schema\Content\ResourceContents;
 10 | use PhpMcp\Schema\Content\TextResourceContents;
 11 | use PhpMcp\Schema\Resource;
 12 | use PhpMcp\Server\Context;
 13 | use Psr\Container\ContainerInterface;
 14 | use Throwable;
 15 | 
 16 | class RegisteredResource extends RegisteredElement
 17 | {
 18 |     public function __construct(
 19 |         public readonly Resource $schema,
 20 |         callable|array|string $handler,
 21 |         bool $isManual = false,
 22 |     ) {
 23 |         parent::__construct($handler, $isManual);
 24 |     }
 25 | 
 26 |     public static function make(Resource $schema, callable|array|string $handler, bool $isManual = false): self
 27 |     {
 28 |         return new self($schema, $handler, $isManual);
 29 |     }
 30 | 
 31 |     /**
 32 |      * Reads the resource content.
 33 |      *
 34 |      * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
 35 |      */
 36 |     public function read(ContainerInterface $container, string $uri, Context $context): array
 37 |     {
 38 |         $result = $this->handle($container, ['uri' => $uri], $context);
 39 | 
 40 |         return $this->formatResult($result, $uri, $this->schema->mimeType);
 41 |     }
 42 | 
 43 |     /**
 44 |      * Formats the raw result of a resource read operation into MCP ResourceContent items.
 45 |      *
 46 |      * @param  mixed  $readResult  The raw result from the resource handler method.
 47 |      * @param  string  $uri  The URI of the resource that was read.
 48 |      * @param  ?string  $mimeType  The MIME type from the ResourceDefinition.
 49 |      * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
 50 |      *
 51 |      * @throws \RuntimeException If the result cannot be formatted.
 52 |      *
 53 |      * Supported result types:
 54 |      * - ResourceContent: Used as-is
 55 |      * - EmbeddedResource: Resource is extracted from the EmbeddedResource
 56 |      * - string: Converted to text content with guessed or provided MIME type
 57 |      * - stream resource: Read and converted to blob with provided MIME type
 58 |      * - array with 'blob' key: Used as blob content
 59 |      * - array with 'text' key: Used as text content
 60 |      * - SplFileInfo: Read and converted to blob
 61 |      * - array: Converted to JSON if MIME type is application/json or contains 'json'
 62 |      *          For other MIME types, will try to convert to JSON with a warning
 63 |      */
 64 |     protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array
 65 |     {
 66 |         if ($readResult instanceof ResourceContents) {
 67 |             return [$readResult];
 68 |         }
 69 | 
 70 |         if ($readResult instanceof EmbeddedResource) {
 71 |             return [$readResult->resource];
 72 |         }
 73 | 
 74 |         if (is_array($readResult)) {
 75 |             if (empty($readResult)) {
 76 |                 return [TextResourceContents::make($uri, 'application/json', '[]')];
 77 |             }
 78 | 
 79 |             $allAreResourceContents = true;
 80 |             $hasResourceContents = false;
 81 |             $allAreEmbeddedResource = true;
 82 |             $hasEmbeddedResource = false;
 83 | 
 84 |             foreach ($readResult as $item) {
 85 |                 if ($item instanceof ResourceContents) {
 86 |                     $hasResourceContents = true;
 87 |                     $allAreEmbeddedResource = false;
 88 |                 } elseif ($item instanceof EmbeddedResource) {
 89 |                     $hasEmbeddedResource = true;
 90 |                     $allAreResourceContents = false;
 91 |                 } else {
 92 |                     $allAreResourceContents = false;
 93 |                     $allAreEmbeddedResource = false;
 94 |                 }
 95 |             }
 96 | 
 97 |             if ($allAreResourceContents && $hasResourceContents) {
 98 |                 return $readResult;
 99 |             }
100 | 
101 |             if ($allAreEmbeddedResource && $hasEmbeddedResource) {
102 |                 return array_map(fn($item) => $item->resource, $readResult);
103 |             }
104 | 
105 |             if ($hasResourceContents || $hasEmbeddedResource) {
106 |                 $result = [];
107 |                 foreach ($readResult as $item) {
108 |                     if ($item instanceof ResourceContents) {
109 |                         $result[] = $item;
110 |                     } elseif ($item instanceof EmbeddedResource) {
111 |                         $result[] = $item->resource;
112 |                     } else {
113 |                         $result = array_merge($result, $this->formatResult($item, $uri, $mimeType));
114 |                     }
115 |                 }
116 |                 return $result;
117 |             }
118 |         }
119 | 
120 |         if (is_string($readResult)) {
121 |             $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult);
122 | 
123 |             return [TextResourceContents::make($uri, $mimeType, $readResult)];
124 |         }
125 | 
126 |         if (is_resource($readResult) && get_resource_type($readResult) === 'stream') {
127 |             $result = BlobResourceContents::fromStream(
128 |                 $uri,
129 |                 $readResult,
130 |                 $mimeType ?? 'application/octet-stream'
131 |             );
132 | 
133 |             @fclose($readResult);
134 | 
135 |             return [$result];
136 |         }
137 | 
138 |         if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) {
139 |             $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream';
140 | 
141 |             return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])];
142 |         }
143 | 
144 |         if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) {
145 |             $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain';
146 | 
147 |             return [TextResourceContents::make($uri, $mimeType, $readResult['text'])];
148 |         }
149 | 
150 |         if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) {
151 |             if ($mimeType && str_contains(strtolower($mimeType), 'text')) {
152 |                 return [TextResourceContents::make($uri, $mimeType, file_get_contents($readResult->getPathname()))];
153 |             }
154 | 
155 |             return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)];
156 |         }
157 | 
158 |         if (is_array($readResult)) {
159 |             if ($mimeType && (str_contains(strtolower($mimeType), 'json') ||
160 |                 $mimeType === 'application/json')) {
161 |                 try {
162 |                     $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
163 | 
164 |                     return [TextResourceContents::make($uri, $mimeType, $jsonString)];
165 |                 } catch (\JsonException $e) {
166 |                     throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
167 |                 }
168 |             }
169 | 
170 |             try {
171 |                 $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
172 |                 $mimeType = $mimeType ?? 'application/json';
173 | 
174 |                 return [TextResourceContents::make($uri, $mimeType, $jsonString)];
175 |             } catch (\JsonException $e) {
176 |                 throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
177 |             }
178 |         }
179 | 
180 |         throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult));
181 |     }
182 | 
183 |     /** Guesses MIME type from string content (very basic) */
184 |     private function guessMimeTypeFromString(string $content): string
185 |     {
186 |         $trimmed = ltrim($content);
187 | 
188 |         if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) {
189 |             if (str_contains($trimmed, '<html')) {
190 |                 return 'text/html';
191 |             }
192 |             if (str_contains($trimmed, '<?xml')) {
193 |                 return 'application/xml';
194 |             }
195 | 
196 |             return 'text/plain';
197 |         }
198 | 
199 |         if (str_starts_with($trimmed, '{') && str_ends_with(rtrim($content), '}')) {
200 |             return 'application/json';
201 |         }
202 | 
203 |         if (str_starts_with($trimmed, '[') && str_ends_with(rtrim($content), ']')) {
204 |             return 'application/json';
205 |         }
206 | 
207 |         return 'text/plain';
208 |     }
209 | 
210 |     public function toArray(): array
211 |     {
212 |         return [
213 |             'schema' => $this->schema->toArray(),
214 |             ...parent::toArray(),
215 |         ];
216 |     }
217 | 
218 |     public static function fromArray(array $data): self|false
219 |     {
220 |         try {
221 |             if (! isset($data['schema']) || ! isset($data['handler'])) {
222 |                 return false;
223 |             }
224 | 
225 |             return new self(
226 |                 Resource::fromArray($data['schema']),
227 |                 $data['handler'],
228 |                 $data['isManual'] ?? false,
229 |             );
230 |         } catch (Throwable $e) {
231 |             return false;
232 |         }
233 |     }
234 | }
235 | 
```

--------------------------------------------------------------------------------
/src/Defaults/FileCache.php:
--------------------------------------------------------------------------------

```php
  1 | <?php
  2 | 
  3 | namespace PhpMcp\Server\Defaults;
  4 | 
  5 | use DateInterval;
  6 | use DateTimeImmutable;
  7 | use InvalidArgumentException;
  8 | use Psr\SimpleCache\CacheInterface;
  9 | use Throwable;
 10 | 
 11 | /**
 12 |  * Basic PSR-16 file cache implementation.
 13 |  *
 14 |  * Stores cache entries serialized in a JSON file.
 15 |  * Uses file locking for basic concurrency control during writes.
 16 |  * Not recommended for high-concurrency environments.
 17 |  */
 18 | class FileCache implements CacheInterface
 19 | {
 20 |     /**
 21 |      * @param  string  $cacheFile  Absolute path to the cache file.
 22 |      *                             The directory will be created if it doesn't exist.
 23 |      * @param  int  $filePermission  Optional file mode (octal) for the cache file (default: 0664).
 24 |      * @param  int  $dirPermission  Optional directory mode (octal) for the cache directory (default: 0775).
 25 |      */
 26 |     public function __construct(
 27 |         private readonly string $cacheFile,
 28 |         private readonly int $filePermission = 0664,
 29 |         private readonly int $dirPermission = 0775
 30 |     ) {
 31 |         $this->ensureDirectoryExists(dirname($this->cacheFile));
 32 |     }
 33 | 
 34 |     // ---------------------------------------------------------------------
 35 |     // PSR-16 Methods
 36 |     // ---------------------------------------------------------------------
 37 | 
 38 |     public function get(string $key, mixed $default = null): mixed
 39 |     {
 40 |         $data = $this->readCacheFile();
 41 |         $key = $this->sanitizeKey($key);
 42 | 
 43 |         if (! isset($data[$key])) {
 44 |             return $default;
 45 |         }
 46 | 
 47 |         if ($this->isExpired($data[$key]['expiry'])) {
 48 |             $this->delete($key); // Clean up expired entry
 49 | 
 50 |             return $default;
 51 |         }
 52 | 
 53 |         return $data[$key]['value'] ?? $default;
 54 |     }
 55 | 
 56 |     public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool
 57 |     {
 58 |         $data = $this->readCacheFile();
 59 |         $key = $this->sanitizeKey($key);
 60 | 
 61 |         $data[$key] = [
 62 |             'value' => $value,
 63 |             'expiry' => $this->calculateExpiry($ttl),
 64 |         ];
 65 | 
 66 |         return $this->writeCacheFile($data);
 67 |     }
 68 | 
 69 |     public function delete(string $key): bool
 70 |     {
 71 |         $data = $this->readCacheFile();
 72 |         $key = $this->sanitizeKey($key);
 73 | 
 74 |         if (isset($data[$key])) {
 75 |             unset($data[$key]);
 76 | 
 77 |             return $this->writeCacheFile($data);
 78 |         }
 79 | 
 80 |         return true; // Key didn't exist, considered successful delete
 81 |     }
 82 | 
 83 |     public function clear(): bool
 84 |     {
 85 |         // Write an empty array to the file
 86 |         return $this->writeCacheFile([]);
 87 |     }
 88 | 
 89 |     public function getMultiple(iterable $keys, mixed $default = null): iterable
 90 |     {
 91 |         $keys = $this->iterableToArray($keys);
 92 |         $this->validateKeys($keys);
 93 | 
 94 |         $data = $this->readCacheFile();
 95 |         $results = [];
 96 |         $needsWrite = false;
 97 | 
 98 |         foreach ($keys as $key) {
 99 |             $sanitizedKey = $this->sanitizeKey($key);
100 |             if (! isset($data[$sanitizedKey])) {
101 |                 $results[$key] = $default;
102 | 
103 |                 continue;
104 |             }
105 | 
106 |             if ($this->isExpired($data[$sanitizedKey]['expiry'])) {
107 |                 unset($data[$sanitizedKey]); // Clean up expired entry
108 |                 $needsWrite = true;
109 |                 $results[$key] = $default;
110 | 
111 |                 continue;
112 |             }
113 | 
114 |             $results[$key] = $data[$sanitizedKey]['value'] ?? $default;
115 |         }
116 | 
117 |         if ($needsWrite) {
118 |             $this->writeCacheFile($data);
119 |         }
120 | 
121 |         return $results;
122 |     }
123 | 
124 |     public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool
125 |     {
126 |         $values = $this->iterableToArray($values);
127 |         $this->validateKeys(array_keys($values));
128 | 
129 |         $data = $this->readCacheFile();
130 |         $expiry = $this->calculateExpiry($ttl);
131 | 
132 |         foreach ($values as $key => $value) {
133 |             $sanitizedKey = $this->sanitizeKey((string) $key);
134 |             $data[$sanitizedKey] = [
135 |                 'value' => $value,
136 |                 'expiry' => $expiry,
137 |             ];
138 |         }
139 | 
140 |         return $this->writeCacheFile($data);
141 |     }
142 | 
143 |     public function deleteMultiple(iterable $keys): bool
144 |     {
145 |         $keys = $this->iterableToArray($keys);
146 |         $this->validateKeys($keys);
147 | 
148 |         $data = $this->readCacheFile();
149 |         $deleted = false;
150 | 
151 |         foreach ($keys as $key) {
152 |             $sanitizedKey = $this->sanitizeKey($key);
153 |             if (isset($data[$sanitizedKey])) {
154 |                 unset($data[$sanitizedKey]);
155 |                 $deleted = true;
156 |             }
157 |         }
158 | 
159 |         if ($deleted) {
160 |             return $this->writeCacheFile($data);
161 |         }
162 | 
163 |         return true; // No keys existed or no changes made
164 |     }
165 | 
166 |     public function has(string $key): bool
167 |     {
168 |         $data = $this->readCacheFile();
169 |         $key = $this->sanitizeKey($key);
170 | 
171 |         if (! isset($data[$key])) {
172 |             return false;
173 |         }
174 | 
175 |         if ($this->isExpired($data[$key]['expiry'])) {
176 |             $this->delete($key); // Clean up expired
177 | 
178 |             return false;
179 |         }
180 | 
181 |         return true;
182 |     }
183 | 
184 |     // ---------------------------------------------------------------------
185 |     // Internal Methods
186 |     // ---------------------------------------------------------------------
187 | 
188 |     private function readCacheFile(): array
189 |     {
190 |         if (! file_exists($this->cacheFile) || filesize($this->cacheFile) === 0) {
191 |             return [];
192 |         }
193 | 
194 |         $handle = @fopen($this->cacheFile, 'rb');
195 |         if ($handle === false) {
196 |             return [];
197 |         }
198 | 
199 |         try {
200 |             if (! flock($handle, LOCK_SH)) {
201 |                 return [];
202 |             }
203 |             $content = stream_get_contents($handle);
204 |             flock($handle, LOCK_UN);
205 | 
206 |             if ($content === false || $content === '') {
207 |                 return [];
208 |             }
209 | 
210 |             $data = unserialize($content);
211 |             if ($data === false) {
212 |                 return [];
213 |             }
214 | 
215 |             return $data;
216 |         } finally {
217 |             if (is_resource($handle)) {
218 |                 fclose($handle);
219 |             }
220 |         }
221 |     }
222 | 
223 |     private function writeCacheFile(array $data): bool
224 |     {
225 |         $jsonData = serialize($data);
226 | 
227 |         if ($jsonData === false) {
228 |             return false;
229 |         }
230 | 
231 |         $handle = @fopen($this->cacheFile, 'cb');
232 |         if ($handle === false) {
233 |             return false;
234 |         }
235 | 
236 |         try {
237 |             if (! flock($handle, LOCK_EX)) {
238 |                 return false;
239 |             }
240 |             if (! ftruncate($handle, 0)) {
241 |                 return false;
242 |             }
243 |             if (fwrite($handle, $jsonData) === false) {
244 |                 return false;
245 |             }
246 |             fflush($handle);
247 |             flock($handle, LOCK_UN);
248 |             @chmod($this->cacheFile, $this->filePermission);
249 | 
250 |             return true;
251 |         } catch (Throwable $e) {
252 |             flock($handle, LOCK_UN); // Ensure lock release on error
253 | 
254 |             return false;
255 |         } finally {
256 |             if (is_resource($handle)) {
257 |                 fclose($handle);
258 |             }
259 |         }
260 |     }
261 | 
262 |     private function ensureDirectoryExists(string $directory): void
263 |     {
264 |         if (! is_dir($directory)) {
265 |             if (! @mkdir($directory, $this->dirPermission, true)) {
266 |                 throw new InvalidArgumentException("Cache directory does not exist and could not be created: {$directory}");
267 |             }
268 |             @chmod($directory, $this->dirPermission);
269 |         }
270 |     }
271 | 
272 |     private function calculateExpiry(DateInterval|int|null $ttl): ?int
273 |     {
274 |         if ($ttl === null) {
275 |             return null;
276 |         }
277 |         $now = time();
278 |         if (is_int($ttl)) {
279 |             return $ttl <= 0 ? $now - 1 : $now + $ttl;
280 |         }
281 |         if ($ttl instanceof DateInterval) {
282 |             try {
283 |                 return (new DateTimeImmutable())->add($ttl)->getTimestamp();
284 |             } catch (Throwable $e) {
285 |                 return null;
286 |             }
287 |         }
288 |         throw new InvalidArgumentException('Invalid TTL type provided. Must be null, int, or DateInterval.');
289 |     }
290 | 
291 |     private function isExpired(?int $expiry): bool
292 |     {
293 |         return $expiry !== null && time() >= $expiry;
294 |     }
295 | 
296 |     private function sanitizeKey(string $key): string
297 |     {
298 |         if ($key === '') {
299 |             throw new InvalidArgumentException('Cache key cannot be empty.');
300 |         }
301 | 
302 |         // PSR-16 validation (optional stricter check)
303 |         // if (preg_match('/[{}()\/@:]/', $key)) {
304 |         //     throw new InvalidArgumentException("Cache key \"{$key}\" contains reserved characters.");
305 |         // }
306 |         return $key;
307 |     }
308 | 
309 |     private function validateKeys(array $keys): void
310 |     {
311 |         foreach ($keys as $key) {
312 |             if (! is_string($key)) {
313 |                 throw new InvalidArgumentException('Cache key must be a string, got ' . gettype($key));
314 |             }
315 |             $this->sanitizeKey($key);
316 |         }
317 |     }
318 | 
319 |     private function iterableToArray(iterable $iterable): array
320 |     {
321 |         if (is_array($iterable)) {
322 |             return $iterable;
323 |         }
324 | 
325 |         return iterator_to_array($iterable);
326 |     }
327 | }
328 | 
```

--------------------------------------------------------------------------------
/tests/Integration/DiscoveryTest.php:
--------------------------------------------------------------------------------

```php
  1 | <?php
  2 | 
  3 | use PhpMcp\Server\Defaults\EnumCompletionProvider;
  4 | use PhpMcp\Server\Defaults\ListCompletionProvider;
  5 | use PhpMcp\Server\Elements\RegisteredPrompt;
  6 | use PhpMcp\Server\Elements\RegisteredResource;
  7 | use PhpMcp\Server\Elements\RegisteredResourceTemplate;
  8 | use PhpMcp\Server\Elements\RegisteredTool;
  9 | use PhpMcp\Server\Registry;
 10 | use PhpMcp\Server\Tests\Fixtures\Discovery\DiscoverableToolHandler;
 11 | use PhpMcp\Server\Tests\Fixtures\Discovery\InvocablePromptFixture;
 12 | use PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceFixture;
 13 | use PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceTemplateFixture;
 14 | use PhpMcp\Server\Tests\Fixtures\Discovery\InvocableToolFixture;
 15 | use PhpMcp\Server\Utils\Discoverer;
 16 | use PhpMcp\Server\Utils\DocBlockParser;
 17 | use PhpMcp\Server\Utils\SchemaGenerator;
 18 | use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture;
 19 | use Psr\Log\NullLogger;
 20 | 
 21 | beforeEach(function () {
 22 |     $logger = new NullLogger();
 23 |     $this->registry = new Registry($logger);
 24 | 
 25 |     $docBlockParser = new DocBlockParser($logger);
 26 |     $schemaGenerator = new SchemaGenerator($docBlockParser);
 27 |     $this->discoverer = new Discoverer($this->registry, $logger, $docBlockParser, $schemaGenerator);
 28 | 
 29 |     $this->fixtureBasePath = realpath(__DIR__ . '/../Fixtures');
 30 | });
 31 | 
 32 | it('discovers all element types correctly from fixture files', function () {
 33 |     $scanDir = 'Discovery';
 34 | 
 35 |     $this->discoverer->discover($this->fixtureBasePath, [$scanDir]);
 36 | 
 37 |     $tools = $this->registry->getTools();
 38 |     expect($tools)->toHaveCount(4); // greet_user, repeatAction, InvokableCalculator, hidden_subdir_tool
 39 | 
 40 |     $greetUserTool = $this->registry->getTool('greet_user');
 41 |     expect($greetUserTool)->toBeInstanceOf(RegisteredTool::class)
 42 |         ->and($greetUserTool->isManual)->toBeFalse()
 43 |         ->and($greetUserTool->schema->name)->toBe('greet_user')
 44 |         ->and($greetUserTool->schema->description)->toBe('Greets a user by name.')
 45 |         ->and($greetUserTool->handler)->toBe([DiscoverableToolHandler::class, 'greet']);
 46 |     expect($greetUserTool->schema->inputSchema['properties'] ?? [])->toHaveKey('name');
 47 | 
 48 |     $repeatActionTool = $this->registry->getTool('repeatAction');
 49 |     expect($repeatActionTool)->toBeInstanceOf(RegisteredTool::class)
 50 |         ->and($repeatActionTool->isManual)->toBeFalse()
 51 |         ->and($repeatActionTool->schema->description)->toBe('A tool with more complex parameters and inferred name/description.')
 52 |         ->and($repeatActionTool->schema->annotations->readOnlyHint)->toBeTrue();
 53 |     expect(array_keys($repeatActionTool->schema->inputSchema['properties'] ?? []))->toEqual(['count', 'loudly', 'mode']);
 54 | 
 55 |     $invokableCalcTool = $this->registry->getTool('InvokableCalculator');
 56 |     expect($invokableCalcTool)->toBeInstanceOf(RegisteredTool::class)
 57 |         ->and($invokableCalcTool->isManual)->toBeFalse()
 58 |         ->and($invokableCalcTool->handler)->toBe([InvocableToolFixture::class, '__invoke']);
 59 | 
 60 |     expect($this->registry->getTool('private_tool_should_be_ignored'))->toBeNull();
 61 |     expect($this->registry->getTool('protected_tool_should_be_ignored'))->toBeNull();
 62 |     expect($this->registry->getTool('static_tool_should_be_ignored'))->toBeNull();
 63 | 
 64 | 
 65 |     $resources = $this->registry->getResources();
 66 |     expect($resources)->toHaveCount(3); // app_version, ui_settings_discovered, InvocableResourceFixture
 67 | 
 68 |     $appVersionRes = $this->registry->getResource('app://info/version');
 69 |     expect($appVersionRes)->toBeInstanceOf(RegisteredResource::class)
 70 |         ->and($appVersionRes->isManual)->toBeFalse()
 71 |         ->and($appVersionRes->schema->name)->toBe('app_version')
 72 |         ->and($appVersionRes->schema->mimeType)->toBe('text/plain');
 73 | 
 74 |     $invokableStatusRes = $this->registry->getResource('invokable://config/status');
 75 |     expect($invokableStatusRes)->toBeInstanceOf(RegisteredResource::class)
 76 |         ->and($invokableStatusRes->isManual)->toBeFalse()
 77 |         ->and($invokableStatusRes->handler)->toBe([InvocableResourceFixture::class, '__invoke']);
 78 | 
 79 | 
 80 |     $prompts = $this->registry->getPrompts();
 81 |     expect($prompts)->toHaveCount(4); // creative_story_prompt, simpleQuestionPrompt, InvocablePromptFixture, content_creator
 82 | 
 83 |     $storyPrompt = $this->registry->getPrompt('creative_story_prompt');
 84 |     expect($storyPrompt)->toBeInstanceOf(RegisteredPrompt::class)
 85 |         ->and($storyPrompt->isManual)->toBeFalse()
 86 |         ->and($storyPrompt->schema->arguments)->toHaveCount(2) // genre, lengthWords
 87 |         ->and($storyPrompt->completionProviders['genre'])->toBe(CompletionProviderFixture::class);
 88 | 
 89 |     $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); // Inferred name
 90 |     expect($simplePrompt)->toBeInstanceOf(RegisteredPrompt::class)
 91 |         ->and($simplePrompt->isManual)->toBeFalse();
 92 | 
 93 |     $invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt');
 94 |     expect($invokableGreeter)->toBeInstanceOf(RegisteredPrompt::class)
 95 |         ->and($invokableGreeter->isManual)->toBeFalse()
 96 |         ->and($invokableGreeter->handler)->toBe([InvocablePromptFixture::class, '__invoke']);
 97 | 
 98 |     $contentCreatorPrompt = $this->registry->getPrompt('content_creator');
 99 |     expect($contentCreatorPrompt)->toBeInstanceOf(RegisteredPrompt::class)
100 |         ->and($contentCreatorPrompt->isManual)->toBeFalse()
101 |         ->and($contentCreatorPrompt->completionProviders)->toHaveCount(3);
102 | 
103 |     $templates = $this->registry->getResourceTemplates();
104 |     expect($templates)->toHaveCount(4); // product_details_template, getFileContent, InvocableResourceTemplateFixture, content_template
105 | 
106 |     $productTemplate = $this->registry->getResourceTemplate('product://{region}/details/{productId}');
107 |     expect($productTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class)
108 |         ->and($productTemplate->isManual)->toBeFalse()
109 |         ->and($productTemplate->schema->name)->toBe('product_details_template')
110 |         ->and($productTemplate->completionProviders['region'])->toBe(CompletionProviderFixture::class);
111 |     expect($productTemplate->getVariableNames())->toEqualCanonicalizing(['region', 'productId']);
112 | 
113 |     $invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}');
114 |     expect($invokableUserTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class)
115 |         ->and($invokableUserTemplate->isManual)->toBeFalse()
116 |         ->and($invokableUserTemplate->handler)->toBe([InvocableResourceTemplateFixture::class, '__invoke']);
117 | });
118 | 
119 | it('does not discover elements from excluded directories', function () {
120 |     $this->discoverer->discover($this->fixtureBasePath, ['Discovery']);
121 | 
122 |     expect($this->registry->getTool('hidden_subdir_tool'))->not->toBeNull();
123 | 
124 |     $this->registry->clear();
125 | 
126 |     $this->discoverer->discover($this->fixtureBasePath, ['Discovery'], ['SubDir']);
127 |     expect($this->registry->getTool('hidden_subdir_tool'))->toBeNull();
128 | });
129 | 
130 | it('handles empty directories or directories with no PHP files', function () {
131 |     $this->discoverer->discover($this->fixtureBasePath, ['EmptyDir']);
132 |     expect($this->registry->getTools())->toBeEmpty();
133 | });
134 | 
135 | it('correctly infers names and descriptions from methods/classes if not set in attribute', function () {
136 |     $this->discoverer->discover($this->fixtureBasePath, ['Discovery']);
137 | 
138 |     $repeatActionTool = $this->registry->getTool('repeatAction');
139 |     expect($repeatActionTool->schema->name)->toBe('repeatAction'); // Method name
140 |     expect($repeatActionTool->schema->description)->toBe('A tool with more complex parameters and inferred name/description.'); // Docblock summary
141 | 
142 |     $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt');
143 |     expect($simplePrompt->schema->name)->toBe('simpleQuestionPrompt');
144 |     expect($simplePrompt->schema->description)->toBeNull();
145 | 
146 |     $invokableCalc = $this->registry->getTool('InvokableCalculator'); // Name comes from Attr
147 |     expect($invokableCalc->schema->name)->toBe('InvokableCalculator');
148 |     expect($invokableCalc->schema->description)->toBe('An invokable calculator tool.');
149 | });
150 | 
151 | it('discovers enhanced completion providers with values and enum attributes', function () {
152 |     $this->discoverer->discover($this->fixtureBasePath, ['Discovery']);
153 | 
154 |     $contentPrompt = $this->registry->getPrompt('content_creator');
155 |     expect($contentPrompt)->toBeInstanceOf(RegisteredPrompt::class);
156 | 
157 |     expect($contentPrompt->completionProviders)->toHaveCount(3);
158 | 
159 |     $typeProvider = $contentPrompt->completionProviders['type'];
160 |     expect($typeProvider)->toBeInstanceOf(ListCompletionProvider::class);
161 | 
162 |     $statusProvider = $contentPrompt->completionProviders['status'];
163 |     expect($statusProvider)->toBeInstanceOf(EnumCompletionProvider::class);
164 | 
165 |     $priorityProvider = $contentPrompt->completionProviders['priority'];
166 |     expect($priorityProvider)->toBeInstanceOf(EnumCompletionProvider::class);
167 | 
168 |     $contentTemplate = $this->registry->getResourceTemplate('content://{category}/{slug}');
169 |     expect($contentTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class);
170 |     expect($contentTemplate->completionProviders)->toHaveCount(1);
171 | 
172 |     $categoryProvider = $contentTemplate->completionProviders['category'];
173 |     expect($categoryProvider)->toBeInstanceOf(ListCompletionProvider::class);
174 | });
175 | 
```

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

```php
  1 | <?php
  2 | 
  3 | namespace PhpMcp\Server\Tests\Unit\Session;
  4 | 
  5 | use Mockery;
  6 | use Mockery\MockInterface;
  7 | use PhpMcp\Server\Contracts\SessionHandlerInterface;
  8 | use PhpMcp\Server\Contracts\SessionInterface;
  9 | use PhpMcp\Server\Session\ArraySessionHandler;
 10 | use PhpMcp\Server\Session\SessionManager;
 11 | use PhpMcp\Server\Tests\Mocks\Clock\FixedClock;
 12 | use Psr\Log\LoggerInterface;
 13 | use React\EventLoop\Loop;
 14 | use React\EventLoop\LoopInterface;
 15 | use React\EventLoop\TimerInterface;
 16 | 
 17 | const SESSION_ID_MGR_1 = 'manager-session-1';
 18 | const SESSION_ID_MGR_2 = 'manager-session-2';
 19 | const DEFAULT_TTL_MGR = 3600;
 20 | const GC_INTERVAL_MGR = 5;
 21 | 
 22 | beforeEach(function () {
 23 |     /** @var MockInterface&SessionHandlerInterface $sessionHandler */
 24 |     $this->sessionHandler = Mockery::mock(SessionHandlerInterface::class);
 25 |     /** @var MockInterface&LoggerInterface $logger */
 26 |     $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing();
 27 |     $this->loop = Loop::get();
 28 | 
 29 |     $this->sessionManager = new SessionManager(
 30 |         $this->sessionHandler,
 31 |         $this->logger,
 32 |         $this->loop,
 33 |         DEFAULT_TTL_MGR
 34 |     );
 35 | 
 36 |     $this->sessionHandler->shouldReceive('read')->with(Mockery::any())->andReturn(false)->byDefault();
 37 |     $this->sessionHandler->shouldReceive('write')->with(Mockery::any(), Mockery::any())->andReturn(true)->byDefault();
 38 |     $this->sessionHandler->shouldReceive('destroy')->with(Mockery::any())->andReturn(true)->byDefault();
 39 |     $this->sessionHandler->shouldReceive('gc')->with(Mockery::any())->andReturn([])->byDefault();
 40 | });
 41 | 
 42 | it('creates a new session with default hydrated values and saves it', function () {
 43 |     $this->sessionHandler->shouldReceive('write')
 44 |         ->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) {
 45 |             $data = json_decode($dataJson, true);
 46 |             expect($data['initialized'])->toBeFalse();
 47 |             expect($data['client_info'])->toBeNull();
 48 |             expect($data['protocol_version'])->toBeNull();
 49 |             expect($data['subscriptions'])->toEqual([]);
 50 |             expect($data['message_queue'])->toEqual([]);
 51 |             expect($data['log_level'])->toBeNull();
 52 |             return true;
 53 |         }))->once()->andReturn(true);
 54 | 
 55 |     $sessionCreatedEmitted = false;
 56 |     $emittedSessionId = null;
 57 |     $emittedSessionObj = null;
 58 |     $this->sessionManager->on('session_created', function ($id, $session) use (&$sessionCreatedEmitted, &$emittedSessionId, &$emittedSessionObj) {
 59 |         $sessionCreatedEmitted = true;
 60 |         $emittedSessionId = $id;
 61 |         $emittedSessionObj = $session;
 62 |     });
 63 | 
 64 |     $session = $this->sessionManager->createSession(SESSION_ID_MGR_1);
 65 | 
 66 |     expect($session)->toBeInstanceOf(SessionInterface::class);
 67 |     expect($session->getId())->toBe(SESSION_ID_MGR_1);
 68 |     expect($session->get('initialized'))->toBeFalse();
 69 |     $this->logger->shouldHaveReceived('info')->with('Session created', ['sessionId' => SESSION_ID_MGR_1]);
 70 |     expect($sessionCreatedEmitted)->toBeTrue();
 71 |     expect($emittedSessionId)->toBe(SESSION_ID_MGR_1);
 72 |     expect($emittedSessionObj)->toBe($session);
 73 | });
 74 | 
 75 | it('gets an existing session if handler read returns data', function () {
 76 |     $existingData = ['user_id' => 123, 'initialized' => true];
 77 |     $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->once()->andReturn(json_encode($existingData));
 78 | 
 79 |     $session = $this->sessionManager->getSession(SESSION_ID_MGR_1);
 80 |     expect($session)->toBeInstanceOf(SessionInterface::class);
 81 |     expect($session->getId())->toBe(SESSION_ID_MGR_1);
 82 |     expect($session->get('user_id'))->toBe(123);
 83 | });
 84 | 
 85 | it('returns null from getSession if session does not exist (handler read returns false)', function () {
 86 |     $this->sessionHandler->shouldReceive('read')->with('non-existent')->once()->andReturn(false);
 87 |     $session = $this->sessionManager->getSession('non-existent');
 88 |     expect($session)->toBeNull();
 89 | });
 90 | 
 91 | it('returns null from getSession if session data is empty after load', function () {
 92 |     $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->once()->andReturn(false);
 93 |     $session = $this->sessionManager->getSession(SESSION_ID_MGR_1);
 94 |     expect($session)->toBeNull();
 95 | });
 96 | 
 97 | 
 98 | it('deletes a session successfully and emits event', function () {
 99 |     $this->sessionHandler->shouldReceive('destroy')->with(SESSION_ID_MGR_1)->once()->andReturn(true);
100 | 
101 |     $sessionDeletedEmitted = false;
102 |     $emittedSessionId = null;
103 |     $this->sessionManager->on('session_deleted', function ($id) use (&$sessionDeletedEmitted, &$emittedSessionId) {
104 |         $sessionDeletedEmitted = true;
105 |         $emittedSessionId = $id;
106 |     });
107 | 
108 |     $success = $this->sessionManager->deleteSession(SESSION_ID_MGR_1);
109 | 
110 |     expect($success)->toBeTrue();
111 |     $this->logger->shouldHaveReceived('info')->with('Session deleted', ['sessionId' => SESSION_ID_MGR_1]);
112 |     expect($sessionDeletedEmitted)->toBeTrue();
113 |     expect($emittedSessionId)->toBe(SESSION_ID_MGR_1);
114 | });
115 | 
116 | it('logs warning and does not emit event if deleteSession fails', function () {
117 |     $this->sessionHandler->shouldReceive('destroy')->with(SESSION_ID_MGR_1)->once()->andReturn(false);
118 |     $sessionDeletedEmitted = false;
119 |     $this->sessionManager->on('session_deleted', function () use (&$sessionDeletedEmitted) {
120 |         $sessionDeletedEmitted = true;
121 |     });
122 | 
123 |     $success = $this->sessionManager->deleteSession(SESSION_ID_MGR_1);
124 | 
125 |     expect($success)->toBeFalse();
126 |     $this->logger->shouldHaveReceived('warning')->with('Failed to delete session', ['sessionId' => SESSION_ID_MGR_1]);
127 |     expect($sessionDeletedEmitted)->toBeFalse();
128 | });
129 | 
130 | it('queues message for existing session', function () {
131 |     $sessionData = ['message_queue' => []];
132 |     $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode($sessionData));
133 |     $message = '{"id":1}';
134 | 
135 |     $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) use ($message) {
136 |         $data = json_decode($dataJson, true);
137 |         expect($data['message_queue'])->toEqual([$message]);
138 |         return true;
139 |     }))->once()->andReturn(true);
140 | 
141 |     $this->sessionManager->queueMessage(SESSION_ID_MGR_1, $message);
142 | });
143 | 
144 | it('does nothing on queueMessage if session does not exist', function () {
145 |     $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false);
146 |     $this->sessionHandler->shouldNotReceive('write');
147 |     $this->sessionManager->queueMessage('no-such-session', '{"id":1}');
148 | });
149 | 
150 | it('dequeues messages from existing session', function () {
151 |     $messages = ['{"id":1}', '{"id":2}'];
152 |     $sessionData = ['message_queue' => $messages];
153 |     $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode($sessionData));
154 |     $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) {
155 |         $data = json_decode($dataJson, true);
156 |         expect($data['message_queue'])->toEqual([]);
157 |         return true;
158 |     }))->once()->andReturn(true);
159 | 
160 |     $dequeued = $this->sessionManager->dequeueMessages(SESSION_ID_MGR_1);
161 |     expect($dequeued)->toEqual($messages);
162 | });
163 | 
164 | it('returns empty array from dequeueMessages if session does not exist', function () {
165 |     $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false);
166 |     expect($this->sessionManager->dequeueMessages('no-such-session'))->toBe([]);
167 | });
168 | 
169 | it('checks hasQueuedMessages for existing session', function () {
170 |     $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode(['message_queue' => ['msg']]));
171 |     expect($this->sessionManager->hasQueuedMessages(SESSION_ID_MGR_1))->toBeTrue();
172 | 
173 |     $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_2)->andReturn(json_encode(['message_queue' => []]));
174 |     expect($this->sessionManager->hasQueuedMessages(SESSION_ID_MGR_2))->toBeFalse();
175 | });
176 | 
177 | it('returns false from hasQueuedMessages if session does not exist', function () {
178 |     $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false);
179 |     expect($this->sessionManager->hasQueuedMessages('no-such-session'))->toBeFalse();
180 | });
181 | 
182 | it('can stop GC timer on stopGcTimer ', function () {
183 |     $loop = Mockery::mock(LoopInterface::class);
184 |     $loop->shouldReceive('addPeriodicTimer')->with(Mockery::any(), Mockery::type('callable'))->once()->andReturn(Mockery::mock(TimerInterface::class));
185 |     $loop->shouldReceive('cancelTimer')->with(Mockery::type(TimerInterface::class))->once();
186 | 
187 |     $manager = new SessionManager($this->sessionHandler, $this->logger, $loop);
188 |     $manager->startGcTimer();
189 |     $manager->stopGcTimer();
190 | });
191 | 
192 | it('GC timer callback deletes expired sessions', function () {
193 |     $clock = new FixedClock();
194 | 
195 |     $sessionHandler = new ArraySessionHandler(60, $clock);
196 |     $sessionHandler->write('sess_expired', 'data');
197 | 
198 |     // $clock->addSeconds(100);
199 | 
200 |     $manager = new SessionManager(
201 |         $sessionHandler,
202 |         $this->logger,
203 |         ttl: 30,
204 |         gcInterval: 0.01
205 |     );
206 | 
207 |     $session = $manager->getSession('sess_expired');
208 |     expect($session)->toBeNull();
209 | });
210 | 
211 | 
212 | it('does not start GC timer if already started', function () {
213 |     $this->loop = Mockery::mock(LoopInterface::class);
214 |     $this->loop->shouldReceive('addPeriodicTimer')->once()->andReturn(Mockery::mock(TimerInterface::class));
215 | 
216 |     $manager = new SessionManager($this->sessionHandler, $this->logger, $this->loop);
217 |     $manager->startGcTimer();
218 | });
219 | 
```

--------------------------------------------------------------------------------
/src/Elements/RegisteredElement.php:
--------------------------------------------------------------------------------

```php
  1 | <?php
  2 | 
  3 | declare(strict_types=1);
  4 | 
  5 | namespace PhpMcp\Server\Elements;
  6 | 
  7 | use InvalidArgumentException;
  8 | use JsonSerializable;
  9 | use PhpMcp\Server\Context;
 10 | use PhpMcp\Server\Exception\McpServerException;
 11 | use Psr\Container\ContainerInterface;
 12 | use ReflectionException;
 13 | use ReflectionFunctionAbstract;
 14 | use ReflectionMethod;
 15 | use ReflectionNamedType;
 16 | use ReflectionParameter;
 17 | use Throwable;
 18 | use TypeError;
 19 | 
 20 | class RegisteredElement implements JsonSerializable
 21 | {
 22 |     /** @var callable|array|string */
 23 |     public readonly mixed $handler;
 24 |     public readonly bool $isManual;
 25 | 
 26 |     public function __construct(
 27 |         callable|array|string $handler,
 28 |         bool $isManual = false,
 29 |     ) {
 30 |         $this->handler = $handler;
 31 |         $this->isManual = $isManual;
 32 |     }
 33 | 
 34 |     public function handle(ContainerInterface $container, array $arguments, Context $context): mixed
 35 |     {
 36 |         if (is_string($this->handler)) {
 37 |             if (class_exists($this->handler) && method_exists($this->handler, '__invoke')) {
 38 |                 $reflection = new \ReflectionMethod($this->handler, '__invoke');
 39 |                 $arguments = $this->prepareArguments($reflection, $arguments, $context);
 40 |                 $instance = $container->get($this->handler);
 41 |                 return call_user_func($instance, ...$arguments);
 42 |             }
 43 | 
 44 |             if (function_exists($this->handler)) {
 45 |                 $reflection = new \ReflectionFunction($this->handler);
 46 |                 $arguments = $this->prepareArguments($reflection, $arguments, $context);
 47 |                 return call_user_func($this->handler, ...$arguments);
 48 |             }
 49 |         }
 50 | 
 51 |         if (is_callable($this->handler)) {
 52 |             $reflection = $this->getReflectionForCallable($this->handler);
 53 |             $arguments = $this->prepareArguments($reflection, $arguments, $context);
 54 |             return call_user_func($this->handler, ...$arguments);
 55 |         }
 56 | 
 57 |         if (is_array($this->handler)) {
 58 |             [$className, $methodName] = $this->handler;
 59 |             $reflection = new \ReflectionMethod($className, $methodName);
 60 |             $arguments = $this->prepareArguments($reflection, $arguments, $context);
 61 | 
 62 |             $instance = $container->get($className);
 63 |             return call_user_func([$instance, $methodName], ...$arguments);
 64 |         }
 65 | 
 66 |         throw new \InvalidArgumentException('Invalid handler type');
 67 |     }
 68 | 
 69 | 
 70 |     protected function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments, Context $context): array
 71 |     {
 72 |         $finalArgs = [];
 73 | 
 74 |         foreach ($reflection->getParameters() as $parameter) {
 75 |             // TODO: Handle variadic parameters.
 76 |             $paramName = $parameter->getName();
 77 |             $paramType = $parameter->getType();
 78 |             $paramPosition = $parameter->getPosition();
 79 | 
 80 |             if ($paramType instanceof ReflectionNamedType && $paramType->getName() === Context::class) {
 81 |                 $finalArgs[$paramPosition] = $context;
 82 | 
 83 |                 continue;
 84 |             }
 85 | 
 86 |             if (isset($arguments[$paramName])) {
 87 |                 $argument = $arguments[$paramName];
 88 |                 try {
 89 |                     $finalArgs[$paramPosition] = $this->castArgumentType($argument, $parameter);
 90 |                 } catch (InvalidArgumentException $e) {
 91 |                     throw McpServerException::invalidParams($e->getMessage(), $e);
 92 |                 } catch (Throwable $e) {
 93 |                     throw McpServerException::internalError(
 94 |                         "Error processing parameter `{$paramName}`: {$e->getMessage()}",
 95 |                         $e
 96 |                     );
 97 |                 }
 98 |             } elseif ($parameter->isDefaultValueAvailable()) {
 99 |                 $finalArgs[$paramPosition] = $parameter->getDefaultValue();
100 |             } elseif ($parameter->allowsNull()) {
101 |                 $finalArgs[$paramPosition] = null;
102 |             } elseif ($parameter->isOptional()) {
103 |                 continue;
104 |             } else {
105 |                 $reflectionName = $reflection instanceof \ReflectionMethod
106 |                     ? $reflection->class . '::' . $reflection->name
107 |                     : 'Closure';
108 |                 throw McpServerException::internalError(
109 |                     "Missing required argument `{$paramName}` for {$reflectionName}."
110 |                 );
111 |             }
112 |         }
113 | 
114 |         return array_values($finalArgs);
115 |     }
116 | 
117 |     /**
118 |      * Gets a ReflectionMethod or ReflectionFunction for a callable.
119 |      */
120 |     private function getReflectionForCallable(callable $handler): \ReflectionMethod|\ReflectionFunction
121 |     {
122 |         if (is_string($handler)) {
123 |             return new \ReflectionFunction($handler);
124 |         }
125 | 
126 |         if ($handler instanceof \Closure) {
127 |             return new \ReflectionFunction($handler);
128 |         }
129 | 
130 |         if (is_array($handler) && count($handler) === 2) {
131 |             [$class, $method] = $handler;
132 |             return new \ReflectionMethod($class, $method);
133 |         }
134 | 
135 |         throw new \InvalidArgumentException('Cannot create reflection for this callable type');
136 |     }
137 | 
138 |     /**
139 |      * Attempts type casting based on ReflectionParameter type hints.
140 |      *
141 |      * @throws InvalidArgumentException If casting is impossible for the required type.
142 |      * @throws TypeError If internal PHP casting fails unexpectedly.
143 |      */
144 |     private function castArgumentType(mixed $argument, ReflectionParameter $parameter): mixed
145 |     {
146 |         $type = $parameter->getType();
147 | 
148 |         if ($argument === null) {
149 |             if ($type && $type->allowsNull()) {
150 |                 return null;
151 |             }
152 |         }
153 | 
154 |         if (! $type instanceof ReflectionNamedType) {
155 |             return $argument;
156 |         }
157 | 
158 |         $typeName = $type->getName();
159 | 
160 |         if (enum_exists($typeName)) {
161 |             if (is_object($argument) && $argument instanceof $typeName) {
162 |                 return $argument;
163 |             }
164 | 
165 |             if (is_subclass_of($typeName, \BackedEnum::class)) {
166 |                 $value = $typeName::tryFrom($argument);
167 |                 if ($value === null) {
168 |                     throw new InvalidArgumentException(
169 |                         "Invalid value '{$argument}' for backed enum {$typeName}. Expected one of its backing values.",
170 |                     );
171 |                 }
172 |                 return $value;
173 |             } else {
174 |                 if (is_string($argument)) {
175 |                     foreach ($typeName::cases() as $case) {
176 |                         if ($case->name === $argument) {
177 |                             return $case;
178 |                         }
179 |                     }
180 |                     $validNames = array_map(fn($c) => $c->name, $typeName::cases());
181 |                     throw new InvalidArgumentException(
182 |                         "Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: " . implode(', ', $validNames) . "."
183 |                     );
184 |                 } else {
185 |                     throw new InvalidArgumentException(
186 |                         "Invalid value type '{$argument}' for unit enum {$typeName}. Expected a string matching a case name."
187 |                     );
188 |                 }
189 |             }
190 |         }
191 | 
192 |         try {
193 |             return match (strtolower($typeName)) {
194 |                 'int', 'integer' => $this->castToInt($argument),
195 |                 'string' => (string) $argument,
196 |                 'bool', 'boolean' => $this->castToBoolean($argument),
197 |                 'float', 'double' => $this->castToFloat($argument),
198 |                 'array' => $this->castToArray($argument),
199 |                 default => $argument,
200 |             };
201 |         } catch (TypeError $e) {
202 |             throw new InvalidArgumentException(
203 |                 "Value cannot be cast to required type `{$typeName}`.",
204 |                 0,
205 |                 $e
206 |             );
207 |         }
208 |     }
209 | 
210 |     /** Helper to cast strictly to boolean */
211 |     private function castToBoolean(mixed $argument): bool
212 |     {
213 |         if (is_bool($argument)) {
214 |             return $argument;
215 |         }
216 |         if ($argument === 1 || $argument === '1' || strtolower((string) $argument) === 'true') {
217 |             return true;
218 |         }
219 |         if ($argument === 0 || $argument === '0' || strtolower((string) $argument) === 'false') {
220 |             return false;
221 |         }
222 |         throw new InvalidArgumentException('Cannot cast value to boolean. Use true/false/1/0.');
223 |     }
224 | 
225 |     /** Helper to cast strictly to integer */
226 |     private function castToInt(mixed $argument): int
227 |     {
228 |         if (is_int($argument)) {
229 |             return $argument;
230 |         }
231 |         if (is_numeric($argument) && floor((float) $argument) == $argument && ! is_string($argument)) {
232 |             return (int) $argument;
233 |         }
234 |         if (is_string($argument) && ctype_digit(ltrim($argument, '-'))) {
235 |             return (int) $argument;
236 |         }
237 |         throw new InvalidArgumentException('Cannot cast value to integer. Expected integer representation.');
238 |     }
239 | 
240 |     /** Helper to cast strictly to float */
241 |     private function castToFloat(mixed $argument): float
242 |     {
243 |         if (is_float($argument)) {
244 |             return $argument;
245 |         }
246 |         if (is_int($argument)) {
247 |             return (float) $argument;
248 |         }
249 |         if (is_numeric($argument)) {
250 |             return (float) $argument;
251 |         }
252 |         throw new InvalidArgumentException('Cannot cast value to float. Expected numeric representation.');
253 |     }
254 | 
255 |     /** Helper to cast strictly to array */
256 |     private function castToArray(mixed $argument): array
257 |     {
258 |         if (is_array($argument)) {
259 |             return $argument;
260 |         }
261 |         throw new InvalidArgumentException('Cannot cast value to array. Expected array.');
262 |     }
263 | 
264 |     public function toArray(): array
265 |     {
266 |         return [
267 |             'handler' => $this->handler,
268 |             'isManual' => $this->isManual,
269 |         ];
270 |     }
271 | 
272 |     public function jsonSerialize(): array
273 |     {
274 |         return $this->toArray();
275 |     }
276 | }
277 | 
```

--------------------------------------------------------------------------------
/tests/Mocks/Clients/MockSseClient.php:
--------------------------------------------------------------------------------

```php
  1 | <?php
  2 | 
  3 | declare(strict_types=1);
  4 | 
  5 | namespace PhpMcp\Server\Tests\Mocks\Clients;
  6 | 
  7 | use Psr\Http\Message\ResponseInterface;
  8 | use React\EventLoop\Loop;
  9 | use React\Http\Browser;
 10 | use React\Promise\Deferred;
 11 | use React\Promise\PromiseInterface;
 12 | use React\Stream\ReadableStreamInterface;
 13 | 
 14 | use function React\Promise\reject;
 15 | 
 16 | class MockSseClient
 17 | {
 18 |     public Browser $browser;
 19 |     private ?ReadableStreamInterface $stream = null;
 20 |     private string $buffer = '';
 21 |     private array $receivedMessages = []; // Stores decoded JSON-RPC messages
 22 |     private array $receivedSseEvents = []; // Stores raw SSE events (type, data, id)
 23 |     public ?string $endpointUrl = null; // The /message endpoint URL provided by server
 24 |     public ?string $clientId = null; // The clientId from the /message endpoint URL
 25 |     public ?ResponseInterface $lastConnectResponse = null; // Last connect response for header testing
 26 | 
 27 |     public function __construct(int $timeout = 2)
 28 |     {
 29 |         $this->browser = (new Browser())->withTimeout($timeout);
 30 |     }
 31 | 
 32 |     public function connect(string $sseBaseUrl): PromiseInterface
 33 |     {
 34 |         return $this->browser->requestStreaming('GET', $sseBaseUrl)
 35 |             ->then(function (ResponseInterface $response) {
 36 |                 $this->lastConnectResponse = $response; // Store response for header testing
 37 |                 if ($response->getStatusCode() !== 200) {
 38 |                     $body = (string) $response->getBody();
 39 |                     throw new \RuntimeException("SSE connection failed with status {$response->getStatusCode()}: {$body}");
 40 |                 }
 41 |                 $stream = $response->getBody();
 42 |                 assert($stream instanceof ReadableStreamInterface, "SSE response body is not a readable stream");
 43 |                 $this->stream = $stream;
 44 |                 $this->stream->on('data', [$this, 'handleSseData']);
 45 |                 $this->stream->on('close', function () {
 46 |                     $this->stream = null;
 47 |                 });
 48 |                 return $this;
 49 |             });
 50 |     }
 51 | 
 52 |     public function handleSseData(string $chunk): void
 53 |     {
 54 |         $this->buffer .= $chunk;
 55 | 
 56 |         while (($eventPos = strpos($this->buffer, "\n\n")) !== false) {
 57 |             $eventBlock = substr($this->buffer, 0, $eventPos);
 58 |             $this->buffer = substr($this->buffer, $eventPos + 2);
 59 | 
 60 |             $lines = explode("\n", $eventBlock);
 61 |             $event = ['type' => 'message', 'data' => '', 'id' => null];
 62 | 
 63 |             foreach ($lines as $line) {
 64 |                 if (str_starts_with($line, "event:")) {
 65 |                     $event['type'] = trim(substr($line, strlen("event:")));
 66 |                 } elseif (str_starts_with($line, "data:")) {
 67 |                     $event['data'] .= (empty($event['data']) ? "" : "\n") . trim(substr($line, strlen("data:")));
 68 |                 } elseif (str_starts_with($line, "id:")) {
 69 |                     $event['id'] = trim(substr($line, strlen("id:")));
 70 |                 }
 71 |             }
 72 |             $this->receivedSseEvents[] = $event;
 73 | 
 74 |             if ($event['type'] === 'endpoint' && $event['data']) {
 75 |                 $this->endpointUrl = $event['data'];
 76 |                 $query = parse_url($this->endpointUrl, PHP_URL_QUERY);
 77 |                 if ($query) {
 78 |                     parse_str($query, $params);
 79 |                     $this->clientId = $params['clientId'] ?? null;
 80 |                 }
 81 |             } elseif ($event['type'] === 'message' && $event['data']) {
 82 |                 try {
 83 |                     $decodedJson = json_decode($event['data'], true, 512, JSON_THROW_ON_ERROR);
 84 |                     $this->receivedMessages[] = $decodedJson;
 85 |                 } catch (\JsonException $e) {
 86 |                 }
 87 |             }
 88 |         }
 89 |     }
 90 | 
 91 |     public function getNextMessageResponse(string $expectedRequestId, int $timeoutSecs = 2): PromiseInterface
 92 |     {
 93 |         $deferred = new Deferred();
 94 |         $startTime = microtime(true);
 95 | 
 96 |         $checkMessages = null;
 97 |         $checkMessages = function () use (&$checkMessages, $deferred, $expectedRequestId, $startTime, $timeoutSecs) {
 98 |             foreach ($this->receivedMessages as $i => $msg) {
 99 |                 if (isset($msg['id']) && $msg['id'] === $expectedRequestId) {
100 |                     unset($this->receivedMessages[$i]); // Consume message
101 |                     $this->receivedMessages = array_values($this->receivedMessages);
102 |                     $deferred->resolve($msg);
103 |                     return;
104 |                 }
105 |             }
106 | 
107 |             if (microtime(true) - $startTime > $timeoutSecs) {
108 |                 $deferred->reject(new \RuntimeException("Timeout waiting for SSE message with ID '{$expectedRequestId}'"));
109 |                 return;
110 |             }
111 | 
112 |             if ($this->stream) {
113 |                 Loop::addTimer(0.05, $checkMessages);
114 |             } else {
115 |                 $deferred->reject(new \RuntimeException("SSE Stream closed while waiting for message ID '{$expectedRequestId}'"));
116 |             }
117 |         };
118 | 
119 |         $checkMessages(); // Start checking
120 |         return $deferred->promise();
121 |     }
122 | 
123 |     public function getNextBatchMessageResponse(int $expectedItemCount, int $timeoutSecs = 2): PromiseInterface
124 |     {
125 |         $deferred = new Deferred();
126 |         $startTime = microtime(true);
127 | 
128 |         $checkMessages = null;
129 |         $checkMessages = function () use (&$checkMessages, $deferred, $expectedItemCount, $startTime, $timeoutSecs) {
130 |             foreach ($this->receivedMessages as $i => $msg) {
131 |                 if (is_array($msg) && !isset($msg['jsonrpc']) && count($msg) === $expectedItemCount) {
132 |                     $isLikelyBatchResponse = true;
133 |                     if (empty($msg) && $expectedItemCount === 0) {
134 |                     } elseif (empty($msg) && $expectedItemCount > 0) {
135 |                         $isLikelyBatchResponse = false;
136 |                     } else {
137 |                         foreach ($msg as $item) {
138 |                             if (!is_array($item) || (!isset($item['id']) && !isset($item['method']))) {
139 |                                 $isLikelyBatchResponse = false;
140 |                                 break;
141 |                             }
142 |                         }
143 |                     }
144 | 
145 |                     if ($isLikelyBatchResponse) {
146 |                         unset($this->receivedMessages[$i]);
147 |                         $this->receivedMessages = array_values($this->receivedMessages);
148 |                         $deferred->resolve($msg);
149 |                         return;
150 |                     }
151 |                 }
152 |             }
153 | 
154 |             if (microtime(true) - $startTime > $timeoutSecs) {
155 |                 $deferred->reject(new \RuntimeException("Timeout waiting for SSE Batch Response with {$expectedItemCount} items."));
156 |                 return;
157 |             }
158 | 
159 |             if ($this->stream) {
160 |                 Loop::addTimer(0.05, $checkMessages);
161 |             } else {
162 |                 $deferred->reject(new \RuntimeException("SSE Stream closed while waiting for Batch Response."));
163 |             }
164 |         };
165 | 
166 |         $checkMessages();
167 |         return $deferred->promise();
168 |     }
169 | 
170 |     public function sendHttpRequest(string $requestId, string $method, array $params = []): PromiseInterface
171 |     {
172 |         if (!$this->endpointUrl || !$this->clientId) {
173 |             return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing)."));
174 |         }
175 |         $payload = [
176 |             'jsonrpc' => '2.0',
177 |             'id' => $requestId,
178 |             'method' => $method,
179 |             'params' => $params,
180 |         ];
181 |         $body = json_encode($payload);
182 | 
183 |         return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body)
184 |             ->then(function (ResponseInterface $response) use ($requestId) {
185 |                 $bodyContent = (string) $response->getBody();
186 |                 if ($response->getStatusCode() !== 202) {
187 |                     throw new \RuntimeException("HTTP POST request failed with status {$response->getStatusCode()}: {$bodyContent}");
188 |                 }
189 |                 return $response;
190 |             });
191 |     }
192 | 
193 |     public function sendHttpBatchRequest(array $batchRequestObjects): PromiseInterface
194 |     {
195 |         if (!$this->endpointUrl || !$this->clientId) {
196 |             return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing)."));
197 |         }
198 |         $body = json_encode($batchRequestObjects);
199 | 
200 |         return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body)
201 |             ->then(function (ResponseInterface $response) {
202 |                 $bodyContent = (string) $response->getBody();
203 |                 if ($response->getStatusCode() !== 202) {
204 |                     throw new \RuntimeException("HTTP BATCH POST request failed with status {$response->getStatusCode()}: {$bodyContent}");
205 |                 }
206 |                 return $response;
207 |             });
208 |     }
209 | 
210 |     public function sendHttpNotification(string $method, array $params = []): PromiseInterface
211 |     {
212 |         if (!$this->endpointUrl || !$this->clientId) {
213 |             return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing)."));
214 |         }
215 |         $payload = [
216 |             'jsonrpc' => '2.0',
217 |             'method' => $method,
218 |             'params' => $params,
219 |         ];
220 |         $body = json_encode($payload);
221 |         return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body)
222 |             ->then(function (ResponseInterface $response) {
223 |                 $bodyContent = (string) $response->getBody();
224 |                 if ($response->getStatusCode() !== 202) {
225 |                     throw new \RuntimeException("HTTP POST notification failed with status {$response->getStatusCode()}: {$bodyContent}");
226 |                 }
227 |                 return null;
228 |             });
229 |     }
230 | 
231 |     public function close(): void
232 |     {
233 |         if ($this->stream) {
234 |             $this->stream->close();
235 |             $this->stream = null;
236 |         }
237 |     }
238 | }
239 | 
```

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

```php
  1 | <?php
  2 | 
  3 | namespace PhpMcp\Server\Tests\Unit\Session;
  4 | 
  5 | use Mockery;
  6 | use PhpMcp\Server\Contracts\SessionHandlerInterface;
  7 | use PhpMcp\Server\Contracts\SessionInterface;
  8 | use PhpMcp\Server\Session\Session;
  9 | 
 10 | const SESSION_ID_SESS = 'test-session-obj-id';
 11 | 
 12 | beforeEach(function () {
 13 |     $this->sessionHandler = Mockery::mock(SessionHandlerInterface::class);
 14 |     $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false)->byDefault();
 15 | });
 16 | 
 17 | it('implements SessionInterface', function () {
 18 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
 19 |     expect($session)->toBeInstanceOf(SessionInterface::class);
 20 | });
 21 | 
 22 | // --- Constructor and ID Generation ---
 23 | it('uses provided ID if given', function () {
 24 |     $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false);
 25 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
 26 |     expect($session->getId())->toBe(SESSION_ID_SESS);
 27 | });
 28 | 
 29 | it('generates an ID if none is provided', function () {
 30 |     $this->sessionHandler->shouldReceive('read')->with(Mockery::type('string'))->once()->andReturn(false);
 31 |     $session = new Session($this->sessionHandler);
 32 |     expect($session->getId())->toBeString()->toHaveLength(32);
 33 | });
 34 | 
 35 | it('loads data from handler on construction if session exists', function () {
 36 |     $initialData = ['foo' => 'bar', 'count' => 5, 'nested' => ['value' => true]];
 37 |     $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(json_encode($initialData));
 38 | 
 39 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
 40 |     expect($session->all())->toEqual($initialData);
 41 |     expect($session->get('foo'))->toBe('bar');
 42 | });
 43 | 
 44 | it('initializes with empty data if handler read returns false', function () {
 45 |     $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false);
 46 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
 47 |     expect($session->all())->toBeEmpty();
 48 | });
 49 | 
 50 | it('initializes with empty data if handler read returns invalid JSON', function () {
 51 |     $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn('this is not json');
 52 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
 53 |     expect($session->all())->toBeEmpty();
 54 | });
 55 | 
 56 | it('saves current data to handler', function () {
 57 |     $this->sessionHandler->shouldReceive('read')->andReturn(false);
 58 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
 59 |     $session->set('name', 'Alice');
 60 |     $session->set('level', 10);
 61 | 
 62 |     $expectedSavedData = json_encode(['name' => 'Alice', 'level' => 10]);
 63 |     $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_SESS, $expectedSavedData)->once()->andReturn(true);
 64 | 
 65 |     $session->save();
 66 | });
 67 | 
 68 | it('sets and gets a top-level attribute', function () {
 69 |     $this->sessionHandler->shouldReceive('read')->andReturn(false);
 70 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
 71 |     $session->set('name', 'Bob');
 72 |     expect($session->get('name'))->toBe('Bob');
 73 |     expect($session->has('name'))->toBeTrue();
 74 | });
 75 | 
 76 | it('gets default value if attribute does not exist', function () {
 77 |     $this->sessionHandler->shouldReceive('read')->andReturn(false);
 78 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
 79 |     expect($session->get('nonexistent', 'default_val'))->toBe('default_val');
 80 |     expect($session->has('nonexistent'))->toBeFalse();
 81 | });
 82 | 
 83 | it('sets and gets nested attributes using dot notation', function () {
 84 |     $this->sessionHandler->shouldReceive('read')->andReturn(false);
 85 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
 86 |     $session->set('user.profile.email', '[email protected]');
 87 |     $session->set('user.profile.active', true);
 88 |     $session->set('user.roles', ['admin', 'editor']);
 89 | 
 90 |     expect($session->get('user.profile'))->toEqual(['email' => '[email protected]', 'active' => true]);
 91 |     expect($session->get('user.roles'))->toEqual(['admin', 'editor']);
 92 |     expect($session->has('user.profile.email'))->toBeTrue();
 93 |     expect($session->has('user.other_profile.settings'))->toBeFalse();
 94 | });
 95 | 
 96 | it('set does not overwrite if overwrite is false and key exists', function () {
 97 |     $this->sessionHandler->shouldReceive('read')->andReturn(false);
 98 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
 99 |     $session->set('counter', 10);
100 |     $session->set('counter', 20, false);
101 |     expect($session->get('counter'))->toBe(10);
102 | 
103 |     $session->set('user.id', 1);
104 |     $session->set('user.id', 2, false);
105 |     expect($session->get('user.id'))->toBe(1);
106 | });
107 | 
108 | it('set overwrites if overwrite is true (default)', function () {
109 |     $this->sessionHandler->shouldReceive('read')->andReturn(false);
110 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
111 |     $session->set('counter', 10);
112 |     $session->set('counter', 20);
113 |     expect($session->get('counter'))->toBe(20);
114 | });
115 | 
116 | 
117 | it('forgets a top-level attribute', function () {
118 |     $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['name' => 'Alice', 'age' => 30]));
119 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
120 |     $session->forget('age');
121 |     expect($session->has('age'))->toBeFalse();
122 |     expect($session->has('name'))->toBeTrue();
123 |     expect($session->all())->toEqual(['name' => 'Alice']);
124 | });
125 | 
126 | it('forgets a nested attribute using dot notation', function () {
127 |     $initialData = ['user' => ['profile' => ['email' => '[email protected]', 'status' => 'active'], 'id' => 1]];
128 |     $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($initialData));
129 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
130 | 
131 |     $session->forget('user.profile.status');
132 |     expect($session->has('user.profile.status'))->toBeFalse();
133 |     expect($session->has('user.profile.email'))->toBeTrue();
134 |     expect($session->get('user.profile'))->toEqual(['email' => '[email protected]']);
135 | 
136 |     $session->forget('user.profile');
137 |     expect($session->has('user.profile'))->toBeFalse();
138 |     expect($session->get('user'))->toEqual(['id' => 1]);
139 | });
140 | 
141 | it('forget does nothing if key does not exist', function () {
142 |     $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['name' => 'Test']));
143 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
144 |     $session->forget('nonexistent');
145 |     $session->forget('another_nonexistent');
146 |     expect($session->all())->toEqual(['name' => 'Test']);
147 | });
148 | 
149 | it('pulls an attribute (gets and forgets)', function () {
150 |     $initialData = ['item' => 'important', 'user' => ['token' => 'abc123xyz']];
151 |     $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($initialData));
152 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
153 | 
154 |     $pulledItem = $session->pull('item', 'default');
155 |     expect($pulledItem)->toBe('important');
156 |     expect($session->has('item'))->toBeFalse();
157 | 
158 |     $pulledToken = $session->pull('user.token');
159 |     expect($pulledToken)->toBe('abc123xyz');
160 |     expect($session->has('user.token'))->toBeFalse();
161 |     expect($session->has('user'))->toBeTrue();
162 | 
163 |     $pulledNonExistent = $session->pull('nonexistent', 'fallback');
164 |     expect($pulledNonExistent)->toBe('fallback');
165 | });
166 | 
167 | it('clears all session data', function () {
168 |     $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['a' => 1, 'b' => 2]));
169 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
170 |     $session->clear();
171 |     expect($session->all())->toBeEmpty();
172 | });
173 | 
174 | it('returns all data with all()', function () {
175 |     $data = ['a' => 1, 'b' => ['c' => 3]];
176 |     $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($data));
177 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
178 |     expect($session->all())->toEqual($data);
179 | });
180 | 
181 | it('hydrates session data, merging with defaults and removing id', function () {
182 |     $this->sessionHandler->shouldReceive('read')->andReturn(false);
183 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
184 |     $newAttributes = [
185 |         'client_info' => ['name' => 'TestClient', 'version' => '1.1'],
186 |         'protocol_version' => '2024-custom',
187 |         'user_custom_key' => 'my_value',
188 |         'id' => 'should_be_ignored_on_hydrate'
189 |     ];
190 |     $session->hydrate($newAttributes);
191 | 
192 |     $allData = $session->all();
193 |     expect($allData['initialized'])->toBeFalse();
194 |     expect($allData['client_info'])->toEqual(['name' => 'TestClient', 'version' => '1.1']);
195 |     expect($allData['protocol_version'])->toBe('2024-custom');
196 |     expect($allData['message_queue'])->toEqual([]);
197 |     expect($allData['log_level'])->toBeNull();
198 |     expect($allData['user_custom_key'])->toBe('my_value');
199 |     expect($allData)->not->toHaveKey('id');
200 | });
201 | 
202 | it('queues messages correctly', function () {
203 |     $this->sessionHandler->shouldReceive('read')->andReturn(false);
204 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
205 |     expect($session->hasQueuedMessages())->toBeFalse();
206 | 
207 |     $msg1 = '{"jsonrpc":"2.0","method":"n1"}';
208 |     $msg2 = '{"jsonrpc":"2.0","method":"n2"}';
209 |     $session->queueMessage($msg1);
210 |     $session->queueMessage($msg2);
211 | 
212 |     expect($session->hasQueuedMessages())->toBeTrue();
213 |     expect($session->get('message_queue'))->toEqual([$msg1, $msg2]);
214 | });
215 | 
216 | it('dequeues messages and clears queue', function () {
217 |     $this->sessionHandler->shouldReceive('read')->andReturn(false);
218 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
219 |     $msg1 = '{"id":1}';
220 |     $msg2 = '{"id":2}';
221 |     $session->queueMessage($msg1);
222 |     $session->queueMessage($msg2);
223 | 
224 |     $dequeued = $session->dequeueMessages();
225 |     expect($dequeued)->toEqual([$msg1, $msg2]);
226 |     expect($session->hasQueuedMessages())->toBeFalse();
227 |     expect($session->get('message_queue', 'not_found'))->toEqual([]);
228 | 
229 |     expect($session->dequeueMessages())->toEqual([]);
230 | });
231 | 
232 | it('jsonSerializes to all session data', function () {
233 |     $data = ['serialize' => 'me', 'nested' => ['ok' => true]];
234 |     $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($data));
235 |     $session = new Session($this->sessionHandler, SESSION_ID_SESS);
236 |     expect(json_encode($session))->toBe(json_encode($data));
237 | });
238 | 
```

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

```php
  1 | <?php
  2 | 
  3 | namespace PhpMcp\Server\Tests\Unit\Elements;
  4 | 
  5 | use Mockery;
  6 | use PhpMcp\Schema\ResourceTemplate;
  7 | use PhpMcp\Server\Context;
  8 | use PhpMcp\Server\Contracts\SessionInterface;
  9 | use PhpMcp\Server\Elements\RegisteredResourceTemplate;
 10 | use PhpMcp\Schema\Content\TextResourceContents;
 11 | use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture;
 12 | use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture;
 13 | use Psr\Container\ContainerInterface;
 14 | use PhpMcp\Schema\Annotations;
 15 | 
 16 | beforeEach(function () {
 17 |     $this->container = Mockery::mock(ContainerInterface::class);
 18 |     $this->handlerInstance = new ResourceHandlerFixture();
 19 |     $this->container->shouldReceive('get')
 20 |         ->with(ResourceHandlerFixture::class)
 21 |         ->andReturn($this->handlerInstance)
 22 |         ->byDefault();
 23 | 
 24 |     $this->templateUri = 'item://{category}/{itemId}/details';
 25 |     $this->resourceTemplateSchema = ResourceTemplate::make(
 26 |         $this->templateUri,
 27 |         'item-details-template',
 28 |         mimeType: 'application/json'
 29 |     );
 30 | 
 31 |     $this->defaultHandlerMethod = 'getUserDocument';
 32 |     $this->matchingTemplateSchema = ResourceTemplate::make(
 33 |         'user://{userId}/doc/{documentId}',
 34 |         'user-doc-template',
 35 |         mimeType: 'application/json'
 36 |     );
 37 | 
 38 |     $this->context = new Context(Mockery::mock(SessionInterface::class));
 39 | });
 40 | 
 41 | it('constructs correctly with schema, handler, and completion providers', function () {
 42 |     $completionProviders = [
 43 |         'userId' => CompletionProviderFixture::class,
 44 |         'documentId' => 'Another\ProviderClass'
 45 |     ];
 46 | 
 47 |     $schema = ResourceTemplate::make(
 48 |         'user://{userId}/doc/{documentId}',
 49 |         'user-doc-template',
 50 |         mimeType: 'application/json'
 51 |     );
 52 | 
 53 |     $template = RegisteredResourceTemplate::make(
 54 |         schema: $schema,
 55 |         handler: [ResourceHandlerFixture::class, 'getUserDocument'],
 56 |         completionProviders: $completionProviders
 57 |     );
 58 | 
 59 |     expect($template->schema)->toBe($schema);
 60 |     expect($template->handler)->toBe([ResourceHandlerFixture::class, 'getUserDocument']);
 61 |     expect($template->isManual)->toBeFalse();
 62 |     expect($template->completionProviders)->toEqual($completionProviders);
 63 |     expect($template->completionProviders['userId'])->toBe(CompletionProviderFixture::class);
 64 |     expect($template->completionProviders['documentId'])->toBe('Another\ProviderClass');
 65 |     expect($template->completionProviders)->not->toHaveKey('nonExistentVar');
 66 | });
 67 | 
 68 | it('can be made as a manual registration', function () {
 69 |     $schema = ResourceTemplate::make(
 70 |         'user://{userId}/doc/{documentId}',
 71 |         'user-doc-template',
 72 |         mimeType: 'application/json'
 73 |     );
 74 | 
 75 |     $manualTemplate = RegisteredResourceTemplate::make(
 76 |         schema: $schema,
 77 |         handler: [ResourceHandlerFixture::class, 'getUserDocument'],
 78 |         isManual: true
 79 |     );
 80 | 
 81 |     expect($manualTemplate->isManual)->toBeTrue();
 82 | });
 83 | 
 84 | dataset('uri_template_matching_cases', [
 85 |     'simple_var'        => ['user://{userId}', 'user://12345', ['userId' => '12345']],
 86 |     'simple_var_alpha'  => ['user://{userId}', 'user://abc-def', ['userId' => 'abc-def']],
 87 |     'no_match_missing_var_part' => ['user://{userId}', 'user://', null],
 88 |     'no_match_prefix'   => ['user://{userId}', 'users://12345', null],
 89 |     'multi_var'         => ['item://{category}/{itemId}/details', 'item://books/978-abc/details', ['category' => 'books', 'itemId' => '978-abc']],
 90 |     'multi_var_empty_segment_fail' => ['item://{category}/{itemId}/details', 'item://books//details', null], // [^/]+ fails on empty segment
 91 |     'multi_var_wrong_literal_end' => ['item://{category}/{itemId}/details', 'item://books/978-abc/summary', null],
 92 |     'multi_var_no_suffix_literal' => ['item://{category}/{itemId}', 'item://tools/hammer', ['category' => 'tools', 'itemId' => 'hammer']],
 93 |     'multi_var_extra_segment_fail' => ['item://{category}/{itemId}', 'item://tools/hammer/extra', null],
 94 |     'mixed_literals_vars' => ['user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.jpg', ['userId' => 'kp', 'picId' => 'main']],
 95 |     'mixed_wrong_extension' => ['user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.png', null],
 96 |     'mixed_wrong_literal_prefix' => ['user://{userId}/profile/img_{picId}.jpg', 'user://kp/profile/pic_main.jpg', null],
 97 |     'escapable_chars_in_literal' => ['search://{query}/results.json?page={pageNo}', 'search://term.with.dots/results.json?page=2', ['query' => 'term.with.dots', 'pageNo' => '2']],
 98 | ]);
 99 | 
100 | it('matches URIs against template and extracts variables correctly', function (string $templateString, string $uriToTest, ?array $expectedVariables) {
101 |     $schema = ResourceTemplate::make($templateString, 'test-match');
102 |     $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getUserDocument']);
103 | 
104 |     if ($expectedVariables !== null) {
105 |         expect($template->matches($uriToTest))->toBeTrue();
106 |         $reflection = new \ReflectionClass($template);
107 |         $prop = $reflection->getProperty('uriVariables');
108 |         $prop->setAccessible(true);
109 |         expect($prop->getValue($template))->toEqual($expectedVariables);
110 |     } else {
111 |         expect($template->matches($uriToTest))->toBeFalse();
112 |     }
113 | })->with('uri_template_matching_cases');
114 | 
115 | it('gets variable names from compiled template', function () {
116 |     $schema = ResourceTemplate::make('foo://{varA}/bar/{varB_ext}.{format}', 'vars-test');
117 |     $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getUserDocument']);
118 |     expect($template->getVariableNames())->toEqualCanonicalizing(['varA', 'varB_ext', 'format']);
119 | });
120 | 
121 | it('reads resource using handler with extracted URI variables', function () {
122 |     $uriTemplate = 'item://{category}/{itemId}?format={format}';
123 |     $uri = 'item://electronics/tv-123?format=json_pretty';
124 |     $schema = ResourceTemplate::make($uriTemplate, 'item-details-template');
125 |     $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getTemplatedContent']);
126 | 
127 |     expect($template->matches($uri))->toBeTrue();
128 | 
129 |     $resultContents = $template->read($this->container, $uri, $this->context);
130 | 
131 |     expect($resultContents)->toBeArray()->toHaveCount(1);
132 | 
133 |     $content = $resultContents[0];
134 |     expect($content)->toBeInstanceOf(TextResourceContents::class);
135 |     expect($content->uri)->toBe($uri);
136 |     expect($content->mimeType)->toBe('application/json');
137 | 
138 |     $decodedText = json_decode($content->text, true);
139 |     expect($decodedText['message'])->toBe("Content for item tv-123 in category electronics, format json_pretty.");
140 |     expect($decodedText['category_received'])->toBe('electronics');
141 |     expect($decodedText['itemId_received'])->toBe('tv-123');
142 |     expect($decodedText['format_received'])->toBe('json_pretty');
143 | });
144 | 
145 | it('uses mimeType from schema if handler result does not specify one', function () {
146 |     $uriTemplate = 'item://{category}/{itemId}?format={format}';
147 |     $uri = 'item://books/bestseller?format=json_pretty';
148 |     $schema = ResourceTemplate::make($uriTemplate, 'test-mime', mimeType: 'application/vnd.custom-template-xml');
149 |     $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'getTemplatedContent']);
150 |     expect($template->matches($uri))->toBeTrue();
151 | 
152 |     $resultContents = $template->read($this->container, $uri, $this->context);
153 |     expect($resultContents[0]->mimeType)->toBe('application/vnd.custom-template-xml');
154 | });
155 | 
156 | it('formats a simple string result from handler correctly for template', function () {
157 |     $uri = 'item://tools/hammer';
158 |     $schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom');
159 |     $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'returnStringText']);
160 |     expect($template->matches($uri))->toBeTrue();
161 | 
162 |     $mockHandler = Mockery::mock(ResourceHandlerFixture::class);
163 |     $mockHandler->shouldReceive('returnStringText')->with($uri)->once()->andReturn('Simple content from template handler');
164 |     $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($mockHandler);
165 | 
166 |     $resultContents = $template->read($this->container, $uri, $this->context);
167 |     expect($resultContents[0])->toBeInstanceOf(TextResourceContents::class)
168 |         ->and($resultContents[0]->text)->toBe('Simple content from template handler')
169 |         ->and($resultContents[0]->mimeType)->toBe('text/x-custom'); // From schema
170 | });
171 | 
172 | it('propagates exceptions from handler during read', function () {
173 |     $uri = 'item://tools/hammer';
174 |     $schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom');
175 |     $template = RegisteredResourceTemplate::make($schema, [ResourceHandlerFixture::class, 'handlerThrowsException']);
176 |     expect($template->matches($uri))->toBeTrue();
177 |     $template->read($this->container, $uri, $this->context);
178 | })->throws(\DomainException::class, "Cannot read resource");
179 | 
180 | it('can be serialized to array and deserialized', function () {
181 |     $schema = ResourceTemplate::make(
182 |         'obj://{type}/{id}',
183 |         'my-template',
184 |         mimeType: 'application/template+json',
185 |         annotations: Annotations::make(priority: 0.7)
186 |     );
187 | 
188 |     $providers = ['type' => CompletionProviderFixture::class];
189 |     $serializedProviders = ['type' => serialize(CompletionProviderFixture::class)];
190 | 
191 |     $original = RegisteredResourceTemplate::make(
192 |         $schema,
193 |         [ResourceHandlerFixture::class, 'getUserDocument'],
194 |         true,
195 |         $providers
196 |     );
197 | 
198 |     $array = $original->toArray();
199 | 
200 |     expect($array['schema']['uriTemplate'])->toBe('obj://{type}/{id}');
201 |     expect($array['schema']['name'])->toBe('my-template');
202 |     expect($array['schema']['mimeType'])->toBe('application/template+json');
203 |     expect($array['schema']['annotations']['priority'])->toBe(0.7);
204 |     expect($array['handler'])->toBe([ResourceHandlerFixture::class, 'getUserDocument']);
205 |     expect($array['isManual'])->toBeTrue();
206 |     expect($array['completionProviders'])->toEqual($serializedProviders);
207 | 
208 |     $rehydrated = RegisteredResourceTemplate::fromArray($array);
209 |     expect($rehydrated)->toBeInstanceOf(RegisteredResourceTemplate::class);
210 |     expect($rehydrated->schema->uriTemplate)->toEqual($original->schema->uriTemplate);
211 |     expect($rehydrated->schema->name)->toEqual($original->schema->name);
212 |     expect($rehydrated->isManual)->toBeTrue();
213 |     expect($rehydrated->completionProviders)->toEqual($providers);
214 | });
215 | 
216 | it('fromArray returns false on failure', function () {
217 |     $badData = ['schema' => ['uriTemplate' => 'fail']];
218 |     expect(RegisteredResourceTemplate::fromArray($badData))->toBeFalse();
219 | });
220 | 
```

--------------------------------------------------------------------------------
/tests/Mocks/Clients/MockStreamHttpClient.php:
--------------------------------------------------------------------------------

```php
  1 | <?php
  2 | 
  3 | declare(strict_types=1);
  4 | 
  5 | namespace PhpMcp\Server\Tests\Mocks\Clients;
  6 | 
  7 | use Psr\Http\Message\ResponseInterface;
  8 | use React\EventLoop\Loop;
  9 | use React\Http\Browser;
 10 | use React\Promise\Deferred;
 11 | use React\Promise\PromiseInterface;
 12 | use React\Stream\ReadableStreamInterface;
 13 | 
 14 | use function React\Promise\reject;
 15 | 
 16 | class MockStreamHttpClient
 17 | {
 18 |     public Browser $browser;
 19 |     public string $baseMcpUrl;
 20 |     public ?string $sessionId = null;
 21 | 
 22 |     private ?ReadableStreamInterface $mainSseGetStream = null;
 23 |     private string $mainSseGetBuffer = '';
 24 |     private array $mainSseReceivedNotifications = [];
 25 | 
 26 |     public function __construct(string $host, int $port, string $mcpPath, int $timeout = 2)
 27 |     {
 28 |         $this->browser = (new Browser())->withTimeout($timeout);
 29 |         $this->baseMcpUrl = "http://{$host}:{$port}/{$mcpPath}";
 30 |     }
 31 | 
 32 |     public function connectMainSseStream(): PromiseInterface
 33 |     {
 34 |         if (!$this->sessionId) {
 35 |             return reject(new \LogicException("Cannot connect main SSE stream without a session ID. Initialize first."));
 36 |         }
 37 | 
 38 |         return $this->browser->requestStreaming('GET', $this->baseMcpUrl, [
 39 |             'Accept' => 'text/event-stream',
 40 |             'Mcp-Session-Id' => $this->sessionId
 41 |         ])
 42 |             ->then(function (ResponseInterface $response) {
 43 |                 if ($response->getStatusCode() !== 200) {
 44 |                     $body = (string) $response->getBody();
 45 |                     throw new \RuntimeException("Main SSE GET connection failed with status {$response->getStatusCode()}: {$body}");
 46 |                 }
 47 |                 $stream = $response->getBody();
 48 |                 assert($stream instanceof ReadableStreamInterface);
 49 |                 $this->mainSseGetStream = $stream;
 50 | 
 51 |                 $this->mainSseGetStream->on('data', function ($chunk) {
 52 |                     $this->mainSseGetBuffer .= $chunk;
 53 |                     $this->processBufferForNotifications($this->mainSseGetBuffer, $this->mainSseReceivedNotifications);
 54 |                 });
 55 |                 return $this;
 56 |             });
 57 |     }
 58 | 
 59 |     private function processBufferForNotifications(string &$buffer, array &$targetArray): void
 60 |     {
 61 |         while (($eventPos = strpos($buffer, "\n\n")) !== false) {
 62 |             $eventBlock = substr($buffer, 0, $eventPos);
 63 |             $buffer = substr($buffer, $eventPos + 2);
 64 |             $lines = explode("\n", $eventBlock);
 65 |             $eventData = '';
 66 |             foreach ($lines as $line) {
 67 |                 if (str_starts_with($line, "data:")) {
 68 |                     $eventData .= (empty($eventData) ? "" : "\n") . trim(substr($line, strlen("data:")));
 69 |                 }
 70 |             }
 71 |             if (!empty($eventData)) {
 72 |                 try {
 73 |                     $decodedJson = json_decode($eventData, true, 512, JSON_THROW_ON_ERROR);
 74 |                     if (isset($decodedJson['method']) && str_starts_with($decodedJson['method'], 'notifications/')) {
 75 |                         $targetArray[] = $decodedJson;
 76 |                     }
 77 |                 } catch (\JsonException $e) { /* ignore non-json data lines or log */
 78 |                 }
 79 |             }
 80 |         }
 81 |     }
 82 | 
 83 | 
 84 |     public function sendInitializeRequest(array $params, string $id = 'init-stream-1'): PromiseInterface
 85 |     {
 86 |         $payload = ['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => $params, 'id' => $id];
 87 |         $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream'];
 88 |         $body = json_encode($payload);
 89 | 
 90 |         return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body)
 91 |             ->then(function (ResponseInterface $response) use ($id) {
 92 |                 $statusCode = $response->getStatusCode();
 93 | 
 94 |                 if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) {
 95 |                     throw new \RuntimeException("Initialize POST failed or did not return SSE stream. Status: {$statusCode}");
 96 |                 }
 97 | 
 98 |                 $this->sessionId = $response->getHeaderLine('Mcp-Session-Id');
 99 | 
100 |                 $stream = $response->getBody();
101 |                 assert($stream instanceof ReadableStreamInterface);
102 |                 return $this->collectSingleSseResponse($stream, $id, "Initialize");
103 |             });
104 |     }
105 | 
106 |     public function sendRequest(string $method, array $params, string $id, array $additionalHeaders = []): PromiseInterface
107 |     {
108 |         $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params, 'id' => $id];
109 |         $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream'];
110 |         if ($this->sessionId) {
111 |             $headers['Mcp-Session-Id'] = $this->sessionId;
112 |         }
113 |         $headers += $additionalHeaders;
114 | 
115 |         $body = json_encode($payload);
116 | 
117 |         return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body)
118 |             ->then(function (ResponseInterface $response) use ($id, $method) {
119 |                 $statusCode = $response->getStatusCode();
120 | 
121 |                 if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) {
122 |                     $bodyContent = (string) $response->getBody();
123 |                     throw new \RuntimeException("Request '{$method}' (ID: {$id}) POST failed or did not return SSE stream. Status: {$statusCode}, Body: {$bodyContent}");
124 |                 }
125 | 
126 |                 $stream = $response->getBody();
127 |                 assert($stream instanceof ReadableStreamInterface);
128 |                 return $this->collectSingleSseResponse($stream, $id, $method);
129 |             });
130 |     }
131 | 
132 |     public function sendBatchRequest(array $batchPayload): PromiseInterface
133 |     {
134 |         if (!$this->sessionId) {
135 |             return reject(new \LogicException("Session ID not set. Initialize first for batch request."));
136 |         }
137 | 
138 |         $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream', 'Mcp-Session-Id' => $this->sessionId];
139 |         $body = json_encode($batchPayload);
140 | 
141 |         return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body)
142 |             ->then(function (ResponseInterface $response) {
143 |                 $statusCode = $response->getStatusCode();
144 | 
145 |                 if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) {
146 |                     throw new \RuntimeException("Batch POST failed or did not return SSE stream. Status: {$statusCode}");
147 |                 }
148 | 
149 |                 $stream = $response->getBody();
150 |                 assert($stream instanceof ReadableStreamInterface);
151 |                 return $this->collectSingleSseResponse($stream, null, "Batch", true);
152 |             });
153 |     }
154 | 
155 |     private function collectSingleSseResponse(ReadableStreamInterface $stream, ?string $expectedRequestId, string $contextHint, bool $expectBatchArray = false): PromiseInterface
156 |     {
157 |         $deferred = new Deferred();
158 |         $buffer = '';
159 |         $streamClosed = false;
160 | 
161 |         $dataListener = function ($chunk) use (&$buffer, $deferred, $expectedRequestId, $expectBatchArray, $contextHint, &$streamClosed, &$dataListener, $stream) {
162 |             if ($streamClosed) return;
163 |             $buffer .= $chunk;
164 | 
165 |             if (str_contains($buffer, "event: message\n")) {
166 |                 if (preg_match('/data: (.*)\n\n/s', $buffer, $matches)) {
167 |                     $jsonData = trim($matches[1]);
168 | 
169 |                     try {
170 |                         $decoded = json_decode($jsonData, true, 512, JSON_THROW_ON_ERROR);
171 |                         $isValid = false;
172 |                         if ($expectBatchArray) {
173 |                             $isValid = is_array($decoded) && !isset($decoded['jsonrpc']);
174 |                         } else {
175 |                             $isValid = isset($decoded['id']) && $decoded['id'] === $expectedRequestId;
176 |                         }
177 | 
178 |                         if ($isValid) {
179 |                             $deferred->resolve($decoded);
180 |                             $stream->removeListener('data', $dataListener);
181 |                             $stream->close();
182 |                             return;
183 |                         }
184 |                     } catch (\JsonException $e) {
185 |                         $deferred->reject(new \RuntimeException("SSE JSON decode failed for {$contextHint}: {$jsonData}", 0, $e));
186 |                         $stream->removeListener('data', $dataListener);
187 |                         $stream->close();
188 |                         return;
189 |                     }
190 |                 }
191 |             }
192 |         };
193 | 
194 |         $stream->on('data', $dataListener);
195 |         $stream->on('close', function () use ($deferred, $contextHint, &$streamClosed) {
196 |             $streamClosed = true;
197 |             $deferred->reject(new \RuntimeException("SSE stream for {$contextHint} closed before expected response was received."));
198 |         });
199 |         $stream->on('error', function ($err) use ($deferred, $contextHint, &$streamClosed) {
200 |             $streamClosed = true;
201 |             $deferred->reject(new \RuntimeException("SSE stream error for {$contextHint}.", 0, $err instanceof \Throwable ? $err : null));
202 |         });
203 | 
204 |         return timeout($deferred->promise(), 2, Loop::get())
205 |             ->finally(function () use ($stream, $dataListener) {
206 |                 if ($stream->isReadable()) {
207 |                     $stream->removeListener('data', $dataListener);
208 |                 }
209 |             });
210 |     }
211 | 
212 |     public function sendHttpNotification(string $method, array $params = []): PromiseInterface
213 |     {
214 |         if (!$this->sessionId) {
215 |             return reject(new \LogicException("Session ID not set for notification. Initialize first."));
216 |         }
217 |         $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params];
218 |         $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json', 'Mcp-Session-Id' => $this->sessionId];
219 |         $body = json_encode($payload);
220 | 
221 |         return $this->browser->post($this->baseMcpUrl, $headers, $body)
222 |             ->then(function (ResponseInterface $response) {
223 |                 $statusCode = $response->getStatusCode();
224 | 
225 |                 if ($statusCode !== 202) {
226 |                     throw new \RuntimeException("POST Notification failed with status {$statusCode}: " . (string)$response->getBody());
227 |                 }
228 | 
229 |                 return ['statusCode' => $statusCode, 'body' => null];
230 |             });
231 |     }
232 | 
233 |     public function sendDeleteRequest(): PromiseInterface
234 |     {
235 |         if (!$this->sessionId) {
236 |             return reject(new \LogicException("Session ID not set for DELETE request. Initialize first."));
237 |         }
238 | 
239 |         $headers = ['Mcp-Session-Id' => $this->sessionId];
240 | 
241 |         return $this->browser->request('DELETE', $this->baseMcpUrl, $headers)
242 |             ->then(function (ResponseInterface $response) {
243 |                 $statusCode = $response->getStatusCode();
244 |                 return ['statusCode' => $statusCode, 'body' => (string)$response->getBody()];
245 |             });
246 |     }
247 | 
248 |     public function closeMainSseStream(): void
249 |     {
250 |         if ($this->mainSseGetStream) {
251 |             $this->mainSseGetStream->close();
252 |             $this->mainSseGetStream = null;
253 |         }
254 |     }
255 | }
256 | 
```

--------------------------------------------------------------------------------
/tests/Unit/ServerTest.php:
--------------------------------------------------------------------------------

```php
  1 | <?php
  2 | 
  3 | namespace PhpMcp\Server\Tests\Unit;
  4 | 
  5 | use LogicException;
  6 | use Mockery;
  7 | use Mockery\MockInterface;
  8 | use PhpMcp\Server\Configuration;
  9 | use PhpMcp\Server\Contracts\LoggerAwareInterface;
 10 | use PhpMcp\Server\Contracts\LoopAwareInterface;
 11 | use PhpMcp\Server\Contracts\ServerTransportInterface;
 12 | use PhpMcp\Server\Exception\DiscoveryException;
 13 | use PhpMcp\Schema\Implementation;
 14 | use PhpMcp\Schema\ServerCapabilities;
 15 | use PhpMcp\Server\Protocol;
 16 | use PhpMcp\Server\Registry;
 17 | use PhpMcp\Server\Server;
 18 | use PhpMcp\Server\Session\ArraySessionHandler;
 19 | use PhpMcp\Server\Session\SessionManager;
 20 | use PhpMcp\Server\Utils\Discoverer;
 21 | use Psr\Container\ContainerInterface;
 22 | use Psr\Log\LoggerInterface;
 23 | use Psr\SimpleCache\CacheInterface;
 24 | use React\EventLoop\Loop;
 25 | use React\EventLoop\LoopInterface;
 26 | 
 27 | beforeEach(function () {
 28 |     /** @var MockInterface&LoggerInterface $logger */
 29 |     $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing();
 30 |     /** @var MockInterface&LoopInterface $loop */
 31 |     $this->loop = Mockery::mock(LoopInterface::class)->shouldIgnoreMissing();
 32 |     /** @var MockInterface&CacheInterface $cache */
 33 |     $this->cache = Mockery::mock(CacheInterface::class);
 34 |     /** @var MockInterface&ContainerInterface $container */
 35 |     $this->container = Mockery::mock(ContainerInterface::class);
 36 | 
 37 |     $this->configuration = new Configuration(
 38 |         serverInfo: Implementation::make('TestServerInstance', '1.0'),
 39 |         capabilities: ServerCapabilities::make(),
 40 |         logger: $this->logger,
 41 |         loop: $this->loop,
 42 |         cache: $this->cache,
 43 |         container: $this->container
 44 |     );
 45 | 
 46 |     /** @var MockInterface&Registry $registry */
 47 |     $this->registry = Mockery::mock(Registry::class);
 48 |     /** @var MockInterface&Protocol $protocol */
 49 |     $this->protocol = Mockery::mock(Protocol::class);
 50 |     /** @var MockInterface&Discoverer $discoverer */
 51 |     $this->discoverer = Mockery::mock(Discoverer::class);
 52 | 
 53 |     $this->sessionManager = new SessionManager(new ArraySessionHandler(), $this->logger, $this->loop);
 54 | 
 55 |     $this->server = new Server(
 56 |         $this->configuration,
 57 |         $this->registry,
 58 |         $this->protocol,
 59 |         $this->sessionManager
 60 |     );
 61 | 
 62 |     $this->registry->allows('hasElements')->withNoArgs()->andReturn(false)->byDefault();
 63 |     $this->registry->allows('clear')->withAnyArgs()->byDefault();
 64 |     $this->registry->allows('save')->withAnyArgs()->andReturn(true)->byDefault();
 65 | });
 66 | 
 67 | afterEach(function () {
 68 |     $this->sessionManager->stopGcTimer();
 69 | });
 70 | 
 71 | it('provides getters for core components', function () {
 72 |     expect($this->server->getConfiguration())->toBe($this->configuration);
 73 |     expect($this->server->getRegistry())->toBe($this->registry);
 74 |     expect($this->server->getProtocol())->toBe($this->protocol);
 75 |     expect($this->server->getSessionManager())->toBe($this->sessionManager);
 76 | });
 77 | 
 78 | it('provides a static make method returning ServerBuilder', function () {
 79 |     expect(Server::make())->toBeInstanceOf(\PhpMcp\Server\ServerBuilder::class);
 80 | });
 81 | 
 82 | it('skips discovery if already run and not forced', function () {
 83 |     $reflector = new \ReflectionClass($this->server);
 84 |     $prop = $reflector->getProperty('discoveryRan');
 85 |     $prop->setAccessible(true);
 86 |     $prop->setValue($this->server, true);
 87 | 
 88 |     $this->registry->shouldNotReceive('clear');
 89 |     $this->discoverer->shouldNotReceive('discover');
 90 |     $this->registry->shouldNotReceive('save');
 91 | 
 92 |     $this->server->discover(sys_get_temp_dir(), discoverer: $this->discoverer);
 93 |     $this->logger->shouldHaveReceived('debug')->with('Discovery skipped: Already run or loaded from cache.');
 94 | });
 95 | 
 96 | it('forces discovery even if already run, calling injected discoverer', function () {
 97 |     $reflector = new \ReflectionClass($this->server);
 98 |     $prop = $reflector->getProperty('discoveryRan');
 99 |     $prop->setAccessible(true);
100 |     $prop->setValue($this->server, true);
101 | 
102 |     $basePath = realpath(sys_get_temp_dir());
103 |     $scanDirs = ['.', 'src'];
104 | 
105 | 
106 |     $this->registry->shouldReceive('clear')->once();
107 |     $this->discoverer->shouldReceive('discover')
108 |         ->with($basePath, $scanDirs, Mockery::type('array'))
109 |         ->once();
110 |     $this->registry->shouldReceive('save')->once()->andReturn(true);
111 | 
112 |     $this->server->discover($basePath, $scanDirs, [], force: true, discoverer: $this->discoverer);
113 | 
114 |     expect($prop->getValue($this->server))->toBeTrue();
115 | });
116 | 
117 | it('calls registry clear and discoverer, then saves to cache by default', function () {
118 |     $basePath = realpath(sys_get_temp_dir());
119 |     $scanDirs = ['app', 'lib'];
120 |     $userExcludeDirs = ['specific_exclude'];
121 |     $finalExcludeDirs = array_unique(array_merge(
122 |         ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules', '.git', '.svn'],
123 |         $userExcludeDirs
124 |     ));
125 | 
126 | 
127 |     $this->registry->shouldReceive('clear')->once();
128 |     $this->discoverer->shouldReceive('discover')
129 |         ->with($basePath, $scanDirs, Mockery::on(function ($arg) use ($finalExcludeDirs) {
130 |             expect($arg)->toBeArray();
131 |             expect($arg)->toEqualCanonicalizing($finalExcludeDirs);
132 |             return true;
133 |         }))
134 |         ->once();
135 |     $this->registry->shouldReceive('save')->once()->andReturn(true);
136 | 
137 |     $this->server->discover($basePath, $scanDirs, $userExcludeDirs, discoverer: $this->discoverer);
138 | 
139 |     $reflector = new \ReflectionClass($this->server);
140 |     $prop = $reflector->getProperty('discoveryRan');
141 |     $prop->setAccessible(true);
142 |     expect($prop->getValue($this->server))->toBeTrue();
143 | });
144 | 
145 | it('does not save to cache if saveToCache is false', function () {
146 |     $basePath = realpath(sys_get_temp_dir());
147 | 
148 |     $this->registry->shouldReceive('clear')->once();
149 |     $this->discoverer->shouldReceive('discover')->once();
150 |     $this->registry->shouldNotReceive('save');
151 | 
152 |     $this->server->discover($basePath, saveToCache: false, discoverer: $this->discoverer);
153 | });
154 | 
155 | it('throws InvalidArgumentException for bad base path in discover', function () {
156 |     $this->discoverer->shouldNotReceive('discover');
157 |     $this->server->discover('/non/existent/path/for/sure/I/hope', discoverer: $this->discoverer);
158 | })->throws(\InvalidArgumentException::class, 'Invalid discovery base path');
159 | 
160 | it('throws DiscoveryException if Discoverer fails during discovery', function () {
161 |     $basePath = realpath(sys_get_temp_dir());
162 | 
163 |     $this->registry->shouldReceive('clear')->once();
164 |     $this->discoverer->shouldReceive('discover')->once()->andThrow(new \RuntimeException('Filesystem error'));
165 |     $this->registry->shouldNotReceive('save');
166 | 
167 |     $this->server->discover($basePath, discoverer: $this->discoverer);
168 | })->throws(DiscoveryException::class, 'Element discovery failed: Filesystem error');
169 | 
170 | it('resets discoveryRan flag on Discoverer failure', function () {
171 |     $basePath = realpath(sys_get_temp_dir());
172 |     $this->registry->shouldReceive('clear')->once();
173 |     $this->discoverer->shouldReceive('discover')->once()->andThrow(new \RuntimeException('Failure'));
174 | 
175 |     try {
176 |         $this->server->discover($basePath, discoverer: $this->discoverer);
177 |     } catch (DiscoveryException $e) {
178 |         // Expected
179 |     }
180 | 
181 |     $reflector = new \ReflectionClass($this->server);
182 |     $prop = $reflector->getProperty('discoveryRan');
183 |     $prop->setAccessible(true);
184 |     expect($prop->getValue($this->server))->toBeFalse();
185 | });
186 | 
187 | 
188 | // --- Listening Tests ---
189 | it('throws LogicException if listen is called when already listening', function () {
190 |     $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
191 |     $this->protocol->shouldReceive('bindTransport')->with($transport)->once();
192 | 
193 |     $this->server->listen($transport, false);
194 |     $this->server->listen($transport, false);
195 | })->throws(LogicException::class, 'Server is already listening');
196 | 
197 | it('warns if no elements and discovery not run when listen is called', function () {
198 |     $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
199 |     $this->protocol->shouldReceive('bindTransport')->with($transport)->once();
200 | 
201 |     $this->registry->shouldReceive('hasElements')->andReturn(false);
202 | 
203 |     $this->logger->shouldReceive('warning')
204 |         ->once()
205 |         ->with(Mockery::pattern('/Starting listener, but no MCP elements are registered and discovery has not been run/'));
206 | 
207 |     $this->server->listen($transport, false);
208 | });
209 | 
210 | it('injects logger and loop into aware transports during listen', function () {
211 |     $transport = Mockery::mock(ServerTransportInterface::class, LoggerAwareInterface::class, LoopAwareInterface::class);
212 |     $transport->shouldReceive('setLogger')->with($this->logger)->once();
213 |     $transport->shouldReceive('setLoop')->with($this->loop)->once();
214 |     $transport->shouldReceive('on', 'once', 'listen', 'emit', 'close', 'removeAllListeners')->withAnyArgs();
215 |     $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->withAnyArgs();
216 | 
217 |     $this->server->listen($transport);
218 | });
219 | 
220 | it('binds protocol, starts transport listener, and runs loop by default', function () {
221 |     $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
222 |     $transport->shouldReceive('listen')->once();
223 |     $this->protocol->shouldReceive('bindTransport')->with($transport)->once();
224 |     $this->loop->shouldReceive('run')->once();
225 |     $this->protocol->shouldReceive('unbindTransport')->once();
226 | 
227 |     $this->server->listen($transport);
228 |     expect(getPrivateProperty($this->server, 'isListening'))->toBeFalse();
229 | });
230 | 
231 | it('does not run loop if runLoop is false in listen', function () {
232 |     $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
233 |     $this->protocol->shouldReceive('bindTransport')->with($transport)->once();
234 | 
235 |     $this->loop->shouldNotReceive('run');
236 | 
237 |     $this->server->listen($transport, runLoop: false);
238 |     expect(getPrivateProperty($this->server, 'isListening'))->toBeTrue();
239 | 
240 |     $this->protocol->shouldReceive('unbindTransport');
241 |     $transport->shouldReceive('removeAllListeners');
242 |     $transport->shouldReceive('close');
243 |     $this->server->endListen($transport);
244 | });
245 | 
246 | it('calls endListen if transport listen throws immediately', function () {
247 |     $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing();
248 |     $transport->shouldReceive('listen')->once()->andThrow(new \RuntimeException("Port in use"));
249 |     $this->protocol->shouldReceive('bindTransport')->once();
250 |     $this->protocol->shouldReceive('unbindTransport')->once();
251 | 
252 |     $this->loop->shouldNotReceive('run');
253 | 
254 |     try {
255 |         $this->server->listen($transport);
256 |     } catch (\RuntimeException $e) {
257 |         expect($e->getMessage())->toBe("Port in use");
258 |     }
259 |     expect(getPrivateProperty($this->server, 'isListening'))->toBeFalse();
260 | });
261 | 
262 | it('endListen unbinds protocol and closes transport if listening', function () {
263 |     $transport = Mockery::mock(ServerTransportInterface::class);
264 |     $reflector = new \ReflectionClass($this->server);
265 |     $prop = $reflector->getProperty('isListening');
266 |     $prop->setAccessible(true);
267 |     $prop->setValue($this->server, true);
268 | 
269 |     $this->protocol->shouldReceive('unbindTransport')->once();
270 |     $transport->shouldReceive('removeAllListeners')->with('close')->once();
271 |     $transport->shouldReceive('close')->once();
272 | 
273 |     $this->server->endListen($transport);
274 |     expect($prop->getValue($this->server))->toBeFalse();
275 | });
276 | 
```

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

```php
  1 | <?php
  2 | 
  3 | declare(strict_types=1);
  4 | 
  5 | namespace PhpMcp\Server\Elements;
  6 | 
  7 | use PhpMcp\Schema\Content\BlobResourceContents;
  8 | use PhpMcp\Schema\Content\EmbeddedResource;
  9 | use PhpMcp\Schema\Content\ResourceContents;
 10 | use PhpMcp\Schema\Content\TextResourceContents;
 11 | use PhpMcp\Schema\ResourceTemplate;
 12 | use PhpMcp\Schema\Result\CompletionCompleteResult;
 13 | use PhpMcp\Server\Context;
 14 | use PhpMcp\Server\Contracts\SessionInterface;
 15 | use Psr\Container\ContainerInterface;
 16 | use Throwable;
 17 | 
 18 | class RegisteredResourceTemplate extends RegisteredElement
 19 | {
 20 |     protected array $variableNames;
 21 |     protected array $uriVariables;
 22 |     protected string $uriTemplateRegex;
 23 | 
 24 |     public function __construct(
 25 |         public readonly ResourceTemplate $schema,
 26 |         callable|array|string $handler,
 27 |         bool $isManual = false,
 28 |         public readonly array $completionProviders = []
 29 |     ) {
 30 |         parent::__construct($handler, $isManual);
 31 | 
 32 |         $this->compileTemplate();
 33 |     }
 34 | 
 35 |     public static function make(ResourceTemplate $schema, callable|array|string $handler, bool $isManual = false, array $completionProviders = []): self
 36 |     {
 37 |         return new self($schema, $handler, $isManual, $completionProviders);
 38 |     }
 39 | 
 40 |     /**
 41 |      * Gets the resource template.
 42 |      *
 43 |      * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
 44 |      */
 45 |     public function read(ContainerInterface $container, string $uri, Context $context): array
 46 |     {
 47 |         $arguments = array_merge($this->uriVariables, ['uri' => $uri]);
 48 | 
 49 |         $result = $this->handle($container, $arguments, $context);
 50 | 
 51 |         return $this->formatResult($result, $uri, $this->schema->mimeType);
 52 |     }
 53 | 
 54 |     public function complete(ContainerInterface $container, string $argument, string $value, SessionInterface $session): CompletionCompleteResult
 55 |     {
 56 |         $providerClassOrInstance = $this->completionProviders[$argument] ?? null;
 57 |         if ($providerClassOrInstance === null) {
 58 |             return new CompletionCompleteResult([]);
 59 |         }
 60 | 
 61 |         if (is_string($providerClassOrInstance)) {
 62 |             if (! class_exists($providerClassOrInstance)) {
 63 |                 throw new \RuntimeException("Completion provider class '{$providerClassOrInstance}' does not exist.");
 64 |             }
 65 | 
 66 |             $provider = $container->get($providerClassOrInstance);
 67 |         } else {
 68 |             $provider = $providerClassOrInstance;
 69 |         }
 70 | 
 71 |         $completions = $provider->getCompletions($value, $session);
 72 | 
 73 |         $total = count($completions);
 74 |         $hasMore = $total > 100;
 75 | 
 76 |         $pagedCompletions = array_slice($completions, 0, 100);
 77 | 
 78 |         return new CompletionCompleteResult($pagedCompletions, $total, $hasMore);
 79 |     }
 80 | 
 81 | 
 82 |     public function getVariableNames(): array
 83 |     {
 84 |         return $this->variableNames;
 85 |     }
 86 | 
 87 |     public function matches(string $uri): bool
 88 |     {
 89 |         if (preg_match($this->uriTemplateRegex, $uri, $matches)) {
 90 |             $variables = [];
 91 |             foreach ($this->variableNames as $varName) {
 92 |                 if (isset($matches[$varName])) {
 93 |                     $variables[$varName] = $matches[$varName];
 94 |                 }
 95 |             }
 96 | 
 97 |             $this->uriVariables = $variables;
 98 | 
 99 |             return true;
100 |         }
101 | 
102 |         return false;
103 |     }
104 | 
105 |     private function compileTemplate(): void
106 |     {
107 |         $this->variableNames = [];
108 |         $regexParts = [];
109 | 
110 |         $segments = preg_split('/(\{\w+\})/', $this->schema->uriTemplate, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
111 | 
112 |         foreach ($segments as $segment) {
113 |             if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) {
114 |                 $varName = $matches[1];
115 |                 $this->variableNames[] = $varName;
116 |                 $regexParts[] = '(?P<' . $varName . '>[^/]+)';
117 |             } else {
118 |                 $regexParts[] = preg_quote($segment, '#');
119 |             }
120 |         }
121 | 
122 |         $this->uriTemplateRegex = '#^' . implode('', $regexParts) . '$#';
123 |     }
124 | 
125 |     /**
126 |      * Formats the raw result of a resource read operation into MCP ResourceContent items.
127 |      *
128 |      * @param  mixed  $readResult  The raw result from the resource handler method.
129 |      * @param  string  $uri  The URI of the resource that was read.
130 |      * @param  ?string  $defaultMimeType  The default MIME type from the ResourceDefinition.
131 |      * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects.
132 |      *
133 |      * @throws \RuntimeException If the result cannot be formatted.
134 |      *
135 |      * Supported result types:
136 |      * - ResourceContent: Used as-is
137 |      * - EmbeddedResource: Resource is extracted from the EmbeddedResource
138 |      * - string: Converted to text content with guessed or provided MIME type
139 |      * - stream resource: Read and converted to blob with provided MIME type
140 |      * - array with 'blob' key: Used as blob content
141 |      * - array with 'text' key: Used as text content
142 |      * - SplFileInfo: Read and converted to blob
143 |      * - array: Converted to JSON if MIME type is application/json or contains 'json'
144 |      *          For other MIME types, will try to convert to JSON with a warning
145 |      */
146 |     protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array
147 |     {
148 |         if ($readResult instanceof ResourceContents) {
149 |             return [$readResult];
150 |         }
151 | 
152 |         if ($readResult instanceof EmbeddedResource) {
153 |             return [$readResult->resource];
154 |         }
155 | 
156 |         if (is_array($readResult)) {
157 |             if (empty($readResult)) {
158 |                 return [TextResourceContents::make($uri, 'application/json', '[]')];
159 |             }
160 | 
161 |             $allAreResourceContents = true;
162 |             $hasResourceContents = false;
163 |             $allAreEmbeddedResource = true;
164 |             $hasEmbeddedResource = false;
165 | 
166 |             foreach ($readResult as $item) {
167 |                 if ($item instanceof ResourceContents) {
168 |                     $hasResourceContents = true;
169 |                     $allAreEmbeddedResource = false;
170 |                 } elseif ($item instanceof EmbeddedResource) {
171 |                     $hasEmbeddedResource = true;
172 |                     $allAreResourceContents = false;
173 |                 } else {
174 |                     $allAreResourceContents = false;
175 |                     $allAreEmbeddedResource = false;
176 |                 }
177 |             }
178 | 
179 |             if ($allAreResourceContents && $hasResourceContents) {
180 |                 return $readResult;
181 |             }
182 | 
183 |             if ($allAreEmbeddedResource && $hasEmbeddedResource) {
184 |                 return array_map(fn($item) => $item->resource, $readResult);
185 |             }
186 | 
187 |             if ($hasResourceContents || $hasEmbeddedResource) {
188 |                 $result = [];
189 |                 foreach ($readResult as $item) {
190 |                     if ($item instanceof ResourceContents) {
191 |                         $result[] = $item;
192 |                     } elseif ($item instanceof EmbeddedResource) {
193 |                         $result[] = $item->resource;
194 |                     } else {
195 |                         $result = array_merge($result, $this->formatResult($item, $uri, $mimeType));
196 |                     }
197 |                 }
198 |                 return $result;
199 |             }
200 |         }
201 | 
202 |         if (is_string($readResult)) {
203 |             $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult);
204 | 
205 |             return [TextResourceContents::make($uri, $mimeType, $readResult)];
206 |         }
207 | 
208 |         if (is_resource($readResult) && get_resource_type($readResult) === 'stream') {
209 |             $result = BlobResourceContents::fromStream(
210 |                 $uri,
211 |                 $readResult,
212 |                 $mimeType ?? 'application/octet-stream'
213 |             );
214 | 
215 |             @fclose($readResult);
216 | 
217 |             return [$result];
218 |         }
219 | 
220 |         if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) {
221 |             $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream';
222 | 
223 |             return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])];
224 |         }
225 | 
226 |         if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) {
227 |             $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain';
228 | 
229 |             return [TextResourceContents::make($uri, $mimeType, $readResult['text'])];
230 |         }
231 | 
232 |         if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) {
233 |             if ($mimeType && str_contains(strtolower($mimeType), 'text')) {
234 |                 return [TextResourceContents::make($uri, $mimeType, file_get_contents($readResult->getPathname()))];
235 |             }
236 | 
237 |             return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)];
238 |         }
239 | 
240 |         if (is_array($readResult)) {
241 |             if ($mimeType && (str_contains(strtolower($mimeType), 'json') ||
242 |                 $mimeType === 'application/json')) {
243 |                 try {
244 |                     $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
245 | 
246 |                     return [TextResourceContents::make($uri, $mimeType, $jsonString)];
247 |                 } catch (\JsonException $e) {
248 |                     throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
249 |                 }
250 |             }
251 | 
252 |             try {
253 |                 $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
254 |                 $mimeType = $mimeType ?? 'application/json';
255 | 
256 |                 return [TextResourceContents::make($uri, $mimeType, $jsonString)];
257 |             } catch (\JsonException $e) {
258 |                 throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}");
259 |             }
260 |         }
261 | 
262 |         throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult));
263 |     }
264 | 
265 |     /** Guesses MIME type from string content (very basic) */
266 |     private function guessMimeTypeFromString(string $content): string
267 |     {
268 |         $trimmed = ltrim($content);
269 | 
270 |         if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) {
271 |             if (str_contains($trimmed, '<html')) {
272 |                 return 'text/html';
273 |             }
274 |             if (str_contains($trimmed, '<?xml')) {
275 |                 return 'application/xml';
276 |             }
277 | 
278 |             return 'text/plain';
279 |         }
280 | 
281 |         if (str_starts_with($trimmed, '{') && str_ends_with(rtrim($content), '}')) {
282 |             return 'application/json';
283 |         }
284 | 
285 |         if (str_starts_with($trimmed, '[') && str_ends_with(rtrim($content), ']')) {
286 |             return 'application/json';
287 |         }
288 | 
289 |         return 'text/plain';
290 |     }
291 | 
292 |     public function toArray(): array
293 |     {
294 |         $completionProviders = [];
295 |         foreach ($this->completionProviders as $argument => $provider) {
296 |             $completionProviders[$argument] = serialize($provider);
297 |         }
298 | 
299 |         return [
300 |             'schema' => $this->schema->toArray(),
301 |             'completionProviders' => $completionProviders,
302 |             ...parent::toArray(),
303 |         ];
304 |     }
305 | 
306 |     public static function fromArray(array $data): self|false
307 |     {
308 |         try {
309 |             if (! isset($data['schema']) || ! isset($data['handler'])) {
310 |                 return false;
311 |             }
312 | 
313 |             $completionProviders = [];
314 |             foreach ($data['completionProviders'] ?? [] as $argument => $provider) {
315 |                 $completionProviders[$argument] = unserialize($provider);
316 |             }
317 | 
318 |             return new self(
319 |                 ResourceTemplate::fromArray($data['schema']),
320 |                 $data['handler'],
321 |                 $data['isManual'] ?? false,
322 |                 $completionProviders,
323 |             );
324 |         } catch (Throwable $e) {
325 |             return false;
326 |         }
327 |     }
328 | }
329 | 
```

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

```php
  1 | <?php
  2 | 
  3 | namespace PhpMcp\Server\Tests\Unit\Elements;
  4 | 
  5 | use Mockery;
  6 | use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
  7 | use PhpMcp\Schema\Resource as ResourceSchema;
  8 | use PhpMcp\Server\Context;
  9 | use PhpMcp\Server\Contracts\SessionInterface;
 10 | use PhpMcp\Server\Elements\RegisteredResource;
 11 | use PhpMcp\Schema\Content\TextResourceContents;
 12 | use PhpMcp\Schema\Content\BlobResourceContents;
 13 | use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture;
 14 | use Psr\Container\ContainerInterface;
 15 | use PhpMcp\Server\Exception\McpServerException;
 16 | 
 17 | uses(MockeryPHPUnitIntegration::class);
 18 | 
 19 | beforeEach(function () {
 20 |     $this->container = Mockery::mock(ContainerInterface::class);
 21 |     $this->handlerInstance = new ResourceHandlerFixture();
 22 |     $this->container->shouldReceive('get')
 23 |         ->with(ResourceHandlerFixture::class)
 24 |         ->andReturn($this->handlerInstance)
 25 |         ->byDefault();
 26 | 
 27 |     $this->testUri = 'test://resource/item.txt';
 28 |     $this->resourceSchema = ResourceSchema::make($this->testUri, 'test-resource', mimeType: 'text/plain');
 29 |     $this->registeredResource = RegisteredResource::make(
 30 |         $this->resourceSchema,
 31 |         [ResourceHandlerFixture::class, 'returnStringText']
 32 |     );
 33 |     $this->context = new Context(Mockery::mock(SessionInterface::class));
 34 | });
 35 | 
 36 | afterEach(function () {
 37 |     if (ResourceHandlerFixture::$unlinkableSplFile && file_exists(ResourceHandlerFixture::$unlinkableSplFile)) {
 38 |         @unlink(ResourceHandlerFixture::$unlinkableSplFile);
 39 |         ResourceHandlerFixture::$unlinkableSplFile = null;
 40 |     }
 41 | });
 42 | 
 43 | it('constructs correctly and exposes schema', function () {
 44 |     expect($this->registeredResource->schema)->toBe($this->resourceSchema);
 45 |     expect($this->registeredResource->handler)->toBe([ResourceHandlerFixture::class, 'returnStringText']);
 46 |     expect($this->registeredResource->isManual)->toBeFalse();
 47 | });
 48 | 
 49 | it('can be made as a manual registration', function () {
 50 |     $manualResource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnStringText'], true);
 51 |     expect($manualResource->isManual)->toBeTrue();
 52 | });
 53 | 
 54 | it('passes URI to handler if handler method expects it', function () {
 55 |     $resource = RegisteredResource::make(
 56 |         ResourceSchema::make($this->testUri, 'needs-uri'),
 57 |         [ResourceHandlerFixture::class, 'resourceHandlerNeedsUri']
 58 |     );
 59 | 
 60 |     $handlerMock = Mockery::mock(ResourceHandlerFixture::class);
 61 |     $handlerMock->shouldReceive('resourceHandlerNeedsUri')
 62 |         ->with($this->testUri)
 63 |         ->once()
 64 |         ->andReturn("Confirmed URI: {$this->testUri}");
 65 |     $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock);
 66 | 
 67 |     $result = $resource->read($this->container, $this->testUri, $this->context);
 68 |     expect($result[0]->text)->toBe("Confirmed URI: {$this->testUri}");
 69 | });
 70 | 
 71 | it('does not require handler method to accept URI', function () {
 72 |     $resource = RegisteredResource::make(
 73 |         ResourceSchema::make($this->testUri, 'no-uri-param'),
 74 |         [ResourceHandlerFixture::class, 'resourceHandlerDoesNotNeedUri']
 75 |     );
 76 |     $handlerMock = Mockery::mock(ResourceHandlerFixture::class);
 77 |     $handlerMock->shouldReceive('resourceHandlerDoesNotNeedUri')->once()->andReturn("Success no URI");
 78 |     $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock);
 79 | 
 80 |     $result = $resource->read($this->container, $this->testUri, $this->context);
 81 |     expect($result[0]->text)->toBe("Success no URI");
 82 | });
 83 | 
 84 | 
 85 | dataset('resource_handler_return_types', [
 86 |     'string_text'        => ['returnStringText', 'text/plain', fn($text, $uri) => expect($text)->toBe("Plain string content for {$uri}"), null],
 87 |     'string_json_guess'  => ['returnStringJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_json'])->toBe($uri), null],
 88 |     'string_html_guess'  => ['returnStringHtml', 'text/html', fn($text, $uri) => expect($text)->toContain("<title>{$uri}</title>"), null],
 89 |     'array_json_schema_mime' => ['returnArrayJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_array'])->toBe($uri), null], // schema has text/plain, overridden by array + JSON content
 90 |     'empty_array'        => ['returnEmptyArray', 'application/json', fn($text) => expect($text)->toBe('[]'), null],
 91 |     'stream_octet'       => ['returnStream', 'application/octet-stream', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Streamed content for {$uri}")],
 92 |     'array_for_blob'     => ['returnArrayForBlobSchema', 'application/x-custom-blob-array', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Blob for {$uri} via array")],
 93 |     'array_for_text'     => ['returnArrayForTextSchema', 'text/vnd.custom-array-text', fn($text, $uri) => expect($text)->toBe("Text from array for {$uri} via array"), null],
 94 |     'direct_TextResourceContents' => ['returnTextResourceContents', 'text/special-contents', fn($text) => expect($text)->toBe('Direct TextResourceContents'), null],
 95 |     'direct_BlobResourceContents' => ['returnBlobResourceContents', 'application/custom-blob-contents', null, fn($blob) => expect(base64_decode($blob ?? ''))->toBe('blobbycontents')],
 96 |     'direct_EmbeddedResource' => ['returnEmbeddedResource', 'application/vnd.custom-embedded', fn($text) => expect($text)->toBe('Direct EmbeddedResource content'), null],
 97 | ]);
 98 | 
 99 | it('formats various handler return types correctly', function (string $handlerMethod, string $expectedMime, ?callable $textAssertion, ?callable $blobAssertion) {
100 |     $schema = ResourceSchema::make($this->testUri, 'format-test');
101 |     $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, $handlerMethod]);
102 | 
103 |     $resultContents = $resource->read($this->container, $this->testUri, $this->context);
104 | 
105 |     expect($resultContents)->toBeArray()->toHaveCount(1);
106 |     $content = $resultContents[0];
107 | 
108 |     expect($content->uri)->toBe($this->testUri);
109 |     expect($content->mimeType)->toBe($expectedMime);
110 | 
111 |     if ($textAssertion) {
112 |         expect($content)->toBeInstanceOf(TextResourceContents::class);
113 |         $textAssertion($content->text, $this->testUri);
114 |     }
115 |     if ($blobAssertion) {
116 |         expect($content)->toBeInstanceOf(BlobResourceContents::class);
117 |         $blobAssertion($content->blob, $this->testUri);
118 |     }
119 | })->with('resource_handler_return_types');
120 | 
121 | it('formats SplFileInfo based on schema MIME type (text)', function () {
122 |     $schema = ResourceSchema::make($this->testUri, 'spl-text', mimeType: 'text/markdown');
123 |     $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, 'returnSplFileInfo']);
124 |     $result = $resource->read($this->container, $this->testUri, $this->context);
125 | 
126 |     expect($result[0])->toBeInstanceOf(TextResourceContents::class);
127 |     expect($result[0]->mimeType)->toBe('text/markdown');
128 |     expect($result[0]->text)->toBe("Content from SplFileInfo for {$this->testUri}");
129 | });
130 | 
131 | it('formats SplFileInfo based on schema MIME type (blob if not text like)', function () {
132 |     $schema = ResourceSchema::make($this->testUri, 'spl-blob', mimeType: 'image/png');
133 |     $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, 'returnSplFileInfo']);
134 |     $result = $resource->read($this->container, $this->testUri, $this->context);
135 | 
136 |     expect($result[0])->toBeInstanceOf(BlobResourceContents::class);
137 |     expect($result[0]->mimeType)->toBe('image/png');
138 |     expect(base64_decode($result[0]->blob ?? ''))->toBe("Content from SplFileInfo for {$this->testUri}");
139 | });
140 | 
141 | it('formats array of ResourceContents as is', function () {
142 |     $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnArrayOfResourceContents']);
143 |     $results = $resource->read($this->container, $this->testUri, $this->context);
144 |     expect($results)->toHaveCount(2);
145 |     expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe('Part 1 of many RC');
146 |     expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('pngdata'));
147 | });
148 | 
149 | it('formats array of EmbeddedResources by extracting their inner resource', function () {
150 |     $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnArrayOfEmbeddedResources']);
151 |     $results = $resource->read($this->container, $this->testUri, $this->context);
152 |     expect($results)->toHaveCount(2);
153 |     expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe('<doc1/>');
154 |     expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('fontdata'));
155 | });
156 | 
157 | it('formats mixed array with ResourceContent/EmbeddedResource by processing each item', function () {
158 |     $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnMixedArrayWithResourceTypes']);
159 |     $results = $resource->read($this->container, $this->testUri, $this->context);
160 | 
161 |     expect($results)->toBeArray()->toHaveCount(4);
162 |     expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe("A raw string piece");
163 |     expect($results[1])->toBeInstanceOf(TextResourceContents::class)->text->toBe("**Markdown!**");
164 |     expect($results[2])->toBeInstanceOf(TextResourceContents::class);
165 |     expect(json_decode($results[2]->text, true))->toEqual(['nested_array_data' => 'value', 'for_uri' => $this->testUri]);
166 |     expect($results[3])->toBeInstanceOf(TextResourceContents::class)->text->toBe("col1,col2");
167 | });
168 | 
169 | 
170 | it('propagates McpServerException from handler during read', function () {
171 |     $resource = RegisteredResource::make(
172 |         $this->resourceSchema,
173 |         [ResourceHandlerFixture::class, 'resourceHandlerNeedsUri']
174 |     );
175 |     $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn(
176 |         Mockery::mock(ResourceHandlerFixture::class, function (Mockery\MockInterface $mock) {
177 |             $mock->shouldReceive('resourceHandlerNeedsUri')->andThrow(McpServerException::invalidParams("Test error"));
178 |         })
179 |     );
180 |     $resource->read($this->container, $this->testUri, $this->context);
181 | })->throws(McpServerException::class, "Test error");
182 | 
183 | it('propagates other exceptions from handler during read', function () {
184 |     $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'handlerThrowsException']);
185 |     $resource->read($this->container, $this->testUri, $this->context);
186 | })->throws(\DomainException::class, "Cannot read resource");
187 | 
188 | it('throws RuntimeException for unformattable handler result', function () {
189 |     $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnUnformattableType']);
190 |     $resource->read($this->container, $this->testUri, $this->context);
191 | })->throws(\RuntimeException::class, "Cannot format resource read result for URI");
192 | 
193 | 
194 | it('can be serialized to array and deserialized', function () {
195 |     $original = RegisteredResource::make(
196 |         ResourceSchema::make(
197 |             'uri://test',
198 |             'my-resource',
199 |             'desc',
200 |             'app/foo',
201 |         ),
202 |         [ResourceHandlerFixture::class, 'getStaticText'],
203 |         true
204 |     );
205 | 
206 |     $array = $original->toArray();
207 | 
208 |     expect($array['schema']['uri'])->toBe('uri://test');
209 |     expect($array['schema']['name'])->toBe('my-resource');
210 |     expect($array['schema']['description'])->toBe('desc');
211 |     expect($array['schema']['mimeType'])->toBe('app/foo');
212 |     expect($array['handler'])->toBe([ResourceHandlerFixture::class, 'getStaticText']);
213 |     expect($array['isManual'])->toBeTrue();
214 | 
215 |     $rehydrated = RegisteredResource::fromArray($array);
216 |     expect($rehydrated)->toBeInstanceOf(RegisteredResource::class);
217 |     expect($rehydrated->schema->uri)->toEqual($original->schema->uri);
218 |     expect($rehydrated->schema->name)->toEqual($original->schema->name);
219 |     expect($rehydrated->isManual)->toBeTrue();
220 | });
221 | 
222 | it('fromArray returns false on failure', function () {
223 |     $badData = ['schema' => ['uri' => 'fail']];
224 |     expect(RegisteredResource::fromArray($badData))->toBeFalse();
225 | });
226 | 
```

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

```php
  1 | <?php
  2 | 
  3 | namespace PhpMcp\Server\Tests\Unit\Session;
  4 | 
  5 | use Mockery;
  6 | use Mockery\MockInterface;
  7 | use PhpMcp\Server\Session\CacheSessionHandler;
  8 | use PhpMcp\Server\Contracts\SessionHandlerInterface;
  9 | use Psr\SimpleCache\CacheInterface;
 10 | use PhpMcp\Server\Tests\Mocks\Clock\FixedClock;
 11 | 
 12 | const SESSION_ID_CACHE_1 = 'cache-session-id-1';
 13 | const SESSION_ID_CACHE_2 = 'cache-session-id-2';
 14 | const SESSION_ID_CACHE_3 = 'cache-session-id-3';
 15 | const SESSION_DATA_CACHE_1 = '{"id":"cs1","data":{"a":1,"b":"foo"}}';
 16 | const SESSION_DATA_CACHE_2 = '{"id":"cs2","data":{"x":true,"y":null}}';
 17 | const SESSION_DATA_CACHE_3 = '{"id":"cs3","data":"simple string data"}';
 18 | const DEFAULT_TTL_CACHE = 3600;
 19 | const SESSION_INDEX_KEY_CACHE = 'mcp_session_index';
 20 | 
 21 | beforeEach(function () {
 22 |     $this->fixedClock = new FixedClock();
 23 |     /** @var MockInterface&CacheInterface $cache */
 24 |     $this->cache = Mockery::mock(CacheInterface::class);
 25 | 
 26 |     $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault();
 27 |     $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true)->byDefault();
 28 | 
 29 |     $this->handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
 30 | });
 31 | 
 32 | it('implements SessionHandlerInterface', function () {
 33 |     expect($this->handler)->toBeInstanceOf(SessionHandlerInterface::class);
 34 | });
 35 | 
 36 | it('constructs with default TTL and SystemClock if no clock provided', function () {
 37 |     $cacheMock = Mockery::mock(CacheInterface::class);
 38 |     $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault();
 39 |     $handler = new CacheSessionHandler($cacheMock);
 40 | 
 41 |     expect($handler->ttl)->toBe(DEFAULT_TTL_CACHE);
 42 |     $reflection = new \ReflectionClass($handler);
 43 |     $clockProp = $reflection->getProperty('clock');
 44 |     $clockProp->setAccessible(true);
 45 |     expect($clockProp->getValue($handler))->toBeInstanceOf(\PhpMcp\Server\Defaults\SystemClock::class);
 46 | });
 47 | 
 48 | it('constructs with a custom TTL and injected clock', function () {
 49 |     $customTtl = 7200;
 50 |     $clock = new FixedClock();
 51 |     $cacheMock = Mockery::mock(CacheInterface::class);
 52 |     $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault();
 53 |     $handler = new CacheSessionHandler($cacheMock, $customTtl, $clock);
 54 |     expect($handler->ttl)->toBe($customTtl);
 55 | 
 56 |     $reflection = new \ReflectionClass($handler);
 57 |     $clockProp = $reflection->getProperty('clock');
 58 |     $clockProp->setAccessible(true);
 59 |     expect($clockProp->getValue($handler))->toBe($clock);
 60 | });
 61 | 
 62 | it('loads session index from cache on construction', function () {
 63 |     $initialTimestamp = $this->fixedClock->now()->modify('-100 seconds')->getTimestamp();
 64 |     $initialIndex = [SESSION_ID_CACHE_1 => $initialTimestamp];
 65 | 
 66 |     $cacheMock = Mockery::mock(CacheInterface::class);
 67 |     $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn($initialIndex);
 68 | 
 69 |     new CacheSessionHandler($cacheMock, DEFAULT_TTL_CACHE, $this->fixedClock);
 70 | });
 71 | 
 72 | it('reads session data from cache', function () {
 73 |     $sessionIndex = [SESSION_ID_CACHE_1 => $this->fixedClock->now()->modify('-100 seconds')->getTimestamp()];
 74 |     $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn($sessionIndex);
 75 |     $this->cache->shouldReceive('get')->with(SESSION_ID_CACHE_1, false)->once()->andReturn(SESSION_DATA_CACHE_1);
 76 | 
 77 |     $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
 78 |     $readData = $handler->read(SESSION_ID_CACHE_1);
 79 |     expect($readData)->toBe(SESSION_DATA_CACHE_1);
 80 | });
 81 | 
 82 | it('returns false when reading non-existent session (cache get returns default)', function () {
 83 |     $this->cache->shouldReceive('get')->with('non-existent-id', false)->once()->andReturn(false);
 84 |     $readData = $this->handler->read('non-existent-id');
 85 |     expect($readData)->toBeFalse();
 86 | });
 87 | 
 88 | it('writes session data to cache with correct key and TTL, and updates session index', function () {
 89 |     $expectedTimestamp = $this->fixedClock->now()->getTimestamp(); // 15:00:00
 90 | 
 91 |     $this->cache->shouldReceive('set')
 92 |         ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_1 => $expectedTimestamp])
 93 |         ->once()->andReturn(true);
 94 |     $this->cache->shouldReceive('set')
 95 |         ->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1)
 96 |         ->once()->andReturn(true);
 97 | 
 98 |     $writeResult = $this->handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1);
 99 |     expect($writeResult)->toBeTrue();
100 | });
101 | 
102 | it('updates timestamp in session index for existing session on write', function () {
103 |     $initialWriteTime = $this->fixedClock->now()->modify('-60 seconds')->getTimestamp(); // 14:59:00
104 |     $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([SESSION_ID_CACHE_1 => $initialWriteTime]);
105 |     $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
106 | 
107 |     $this->fixedClock->addSeconds(90);
108 |     $expectedNewTimestamp = $this->fixedClock->now()->getTimestamp();
109 | 
110 |     $this->cache->shouldReceive('set')
111 |         ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_1 => $expectedNewTimestamp])
112 |         ->once()->andReturn(true);
113 |     $this->cache->shouldReceive('set')
114 |         ->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1)
115 |         ->once()->andReturn(true);
116 | 
117 |     $handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1);
118 | });
119 | 
120 | it('returns false if cache set for session data fails', function () {
121 |     $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true);
122 |     $this->cache->shouldReceive('set')->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1)
123 |         ->once()->andReturn(false);
124 | 
125 |     $writeResult = $this->handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1);
126 |     expect($writeResult)->toBeFalse();
127 | });
128 | 
129 | it('destroys session by removing from cache and updating index', function () {
130 |     $initialTimestamp = $this->fixedClock->now()->getTimestamp();
131 |     $initialIndex = [SESSION_ID_CACHE_1 => $initialTimestamp, SESSION_ID_CACHE_2 => $initialTimestamp];
132 |     $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
133 |     $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
134 | 
135 |     $this->cache->shouldReceive('set')
136 |         ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_2 => $initialTimestamp])
137 |         ->once()->andReturn(true);
138 |     $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(true);
139 | 
140 |     $handler->destroy(SESSION_ID_CACHE_1);
141 | });
142 | 
143 | it('destroy returns true if session ID not in index (cache delete still called)', function () {
144 |     $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([]); // Empty index
145 |     $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE);
146 | 
147 |     $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true); // Index remains empty
148 |     $this->cache->shouldReceive('delete')->with('non-existent-id')->once()->andReturn(true); // Cache delete for data
149 | 
150 |     $destroyResult = $handler->destroy('non-existent-id');
151 |     expect($destroyResult)->toBeTrue();
152 | });
153 | 
154 | it('destroy returns false if cache delete for session data fails', function () {
155 |     $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([SESSION_ID_CACHE_1 => time()]);
156 |     $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE);
157 | 
158 |     $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true); // Index update
159 |     $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(false); // Data delete fails
160 | 
161 |     $destroyResult = $handler->destroy(SESSION_ID_CACHE_1);
162 |     expect($destroyResult)->toBeFalse();
163 | });
164 | 
165 | it('garbage collects only sessions older than maxLifetime from cache and index', function () {
166 |     $maxLifetime = 120;
167 | 
168 |     $initialIndex = [
169 |         SESSION_ID_CACHE_1 => $this->fixedClock->now()->modify('-60 seconds')->getTimestamp(),
170 |         SESSION_ID_CACHE_2 => $this->fixedClock->now()->modify("-{$maxLifetime} seconds -10 seconds")->getTimestamp(),
171 |         SESSION_ID_CACHE_3 => $this->fixedClock->now()->modify('-1000 seconds')->getTimestamp(),
172 |     ];
173 |     $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
174 |     $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
175 | 
176 |     $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_2)->once()->andReturn(true);
177 |     $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_3)->once()->andReturn(true);
178 |     $this->cache->shouldNotReceive('delete')->with(SESSION_ID_CACHE_1);
179 | 
180 |     $expectedFinalIndex = [SESSION_ID_CACHE_1 => $initialIndex[SESSION_ID_CACHE_1]];
181 |     $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $expectedFinalIndex)->once()->andReturn(true);
182 | 
183 |     $deletedSessionIds = $handler->gc($maxLifetime);
184 | 
185 |     expect($deletedSessionIds)->toBeArray()->toHaveCount(2)
186 |         ->and($deletedSessionIds)->toContain(SESSION_ID_CACHE_2)
187 |         ->and($deletedSessionIds)->toContain(SESSION_ID_CACHE_3);
188 | });
189 | 
190 | it('garbage collection respects maxLifetime precisely for cache handler', function () {
191 |     $maxLifetime = 60;
192 | 
193 |     $sessionTimestamp = $this->fixedClock->now()->modify("-{$maxLifetime} seconds")->getTimestamp();
194 |     $initialIndex = [SESSION_ID_CACHE_1 => $sessionTimestamp];
195 |     $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
196 |     $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
197 | 
198 |     $this->cache->shouldNotReceive('delete');
199 |     $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $initialIndex)->once()->andReturn(true);
200 |     $deleted = $handler->gc($maxLifetime);
201 |     expect($deleted)->toBeEmpty();
202 | 
203 |     $this->fixedClock->addSeconds(1);
204 |     $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
205 |     $handlerAfterTimeAdvance = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
206 | 
207 |     $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(true);
208 |     $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true);
209 |     $deleted2 = $handlerAfterTimeAdvance->gc($maxLifetime);
210 |     expect($deleted2)->toEqual([SESSION_ID_CACHE_1]);
211 | });
212 | 
213 | 
214 | it('garbage collection handles an empty session index', function () {
215 |     $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([]);
216 |     $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE);
217 | 
218 |     $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true);
219 |     $this->cache->shouldNotReceive('delete');
220 | 
221 |     $deletedSessions = $handler->gc(DEFAULT_TTL_CACHE);
222 |     expect($deletedSessions)->toBeArray()->toBeEmpty();
223 | });
224 | 
225 | it('garbage collection continues updating index even if a cache delete fails', function () {
226 |     $maxLifetime = 60;
227 | 
228 |     $initialIndex = [
229 |         'expired_deleted_ok' => $this->fixedClock->now()->modify("-70 seconds")->getTimestamp(),
230 |         'expired_delete_fails' => $this->fixedClock->now()->modify("-80 seconds")->getTimestamp(),
231 |         'survivor' => $this->fixedClock->now()->modify('-30 seconds')->getTimestamp(),
232 |     ];
233 |     $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex);
234 |     $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock);
235 | 
236 |     $this->cache->shouldReceive('delete')->with('expired_deleted_ok')->once()->andReturn(true);
237 |     $this->cache->shouldReceive('delete')->with('expired_delete_fails')->once()->andReturn(false);
238 | 
239 |     $expectedFinalIndex = ['survivor' => $initialIndex['survivor']];
240 |     $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $expectedFinalIndex)->once()->andReturn(true);
241 | 
242 |     $deletedSessionIds = $handler->gc($maxLifetime);
243 |     expect($deletedSessionIds)->toHaveCount(2)->toContain('expired_deleted_ok')->toContain('expired_delete_fails');
244 | });
245 | 
```
Page 3/7FirstPrevNextLast