#
tokens: 40691/50000 5/154 files (page 6/7)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 6 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/RegistryTest.php:
--------------------------------------------------------------------------------

```php
  1 | <?php
  2 | 
  3 | namespace PhpMcp\Server\Tests\Unit;
  4 | 
  5 | use Mockery;
  6 | use Mockery\MockInterface;
  7 | use PhpMcp\Schema\Prompt;
  8 | use PhpMcp\Schema\Resource;
  9 | use PhpMcp\Schema\ResourceTemplate;
 10 | use PhpMcp\Schema\Tool;
 11 | use PhpMcp\Server\Elements\RegisteredPrompt;
 12 | use PhpMcp\Server\Elements\RegisteredResource;
 13 | use PhpMcp\Server\Elements\RegisteredResourceTemplate;
 14 | use PhpMcp\Server\Elements\RegisteredTool;
 15 | use PhpMcp\Server\Registry;
 16 | use Psr\Log\LoggerInterface;
 17 | use Psr\SimpleCache\CacheInterface;
 18 | use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException;
 19 | 
 20 | const DISCOVERED_CACHE_KEY_REG = 'mcp_server_discovered_elements';
 21 | 
 22 | function createTestToolSchema(string $name = 'test-tool'): Tool
 23 | {
 24 |     return Tool::make(name: $name, inputSchema: ['type' => 'object'], description: 'Desc ' . $name);
 25 | }
 26 | 
 27 | function createTestResourceSchema(string $uri = 'test://res', string $name = 'test-res'): Resource
 28 | {
 29 |     return Resource::make(uri: $uri, name: $name, description: 'Desc ' . $name, mimeType: 'text/plain');
 30 | }
 31 | 
 32 | function createTestPromptSchema(string $name = 'test-prompt'): Prompt
 33 | {
 34 |     return Prompt::make(name: $name, description: 'Desc ' . $name, arguments: []);
 35 | }
 36 | 
 37 | function createTestTemplateSchema(string $uriTemplate = 'tmpl://{id}', string $name = 'test-tmpl'): ResourceTemplate
 38 | {
 39 |     return ResourceTemplate::make(uriTemplate: $uriTemplate, name: $name, description: 'Desc ' . $name, mimeType: 'application/json');
 40 | }
 41 | 
 42 | beforeEach(function () {
 43 |     /** @var MockInterface&LoggerInterface $logger */
 44 |     $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing();
 45 |     /** @var MockInterface&CacheInterface $cache */
 46 |     $this->cache = Mockery::mock(CacheInterface::class);
 47 | 
 48 |     // Default cache behavior: miss on get, success on set/delete
 49 |     $this->cache->allows('get')->with(DISCOVERED_CACHE_KEY_REG)->andReturn(null)->byDefault();
 50 |     $this->cache->allows('set')->with(DISCOVERED_CACHE_KEY_REG, Mockery::any())->andReturn(true)->byDefault();
 51 |     $this->cache->allows('delete')->with(DISCOVERED_CACHE_KEY_REG)->andReturn(true)->byDefault();
 52 | 
 53 |     $this->registry = new Registry($this->logger, $this->cache);
 54 |     $this->registryNoCache = new Registry($this->logger, null);
 55 | });
 56 | 
 57 | function getRegistryProperty(Registry $reg, string $propName)
 58 | {
 59 |     $reflector = new \ReflectionClass($reg);
 60 |     $prop = $reflector->getProperty($propName);
 61 |     $prop->setAccessible(true);
 62 |     return $prop->getValue($reg);
 63 | }
 64 | 
 65 | it('registers manual tool correctly', function () {
 66 |     $toolSchema = createTestToolSchema('manual-tool-1');
 67 |     $this->registry->registerTool($toolSchema, ['HandlerClass', 'method'], true);
 68 | 
 69 |     $registeredTool = $this->registry->getTool('manual-tool-1');
 70 |     expect($registeredTool)->toBeInstanceOf(RegisteredTool::class)
 71 |         ->and($registeredTool->schema)->toBe($toolSchema)
 72 |         ->and($registeredTool->isManual)->toBeTrue();
 73 |     expect($this->registry->getTools())->toHaveKey('manual-tool-1');
 74 | });
 75 | 
 76 | it('registers discovered tool correctly', function () {
 77 |     $toolSchema = createTestToolSchema('discovered-tool-1');
 78 |     $this->registry->registerTool($toolSchema, ['HandlerClass', 'method'], false);
 79 | 
 80 |     $registeredTool = $this->registry->getTool('discovered-tool-1');
 81 |     expect($registeredTool)->toBeInstanceOf(RegisteredTool::class)
 82 |         ->and($registeredTool->schema)->toBe($toolSchema)
 83 |         ->and($registeredTool->isManual)->toBeFalse();
 84 | });
 85 | 
 86 | it('registers manual resource correctly', function () {
 87 |     $resourceSchema = createTestResourceSchema('manual://res/1');
 88 |     $this->registry->registerResource($resourceSchema, ['HandlerClass', 'method'], true);
 89 | 
 90 |     $registeredResource = $this->registry->getResource('manual://res/1');
 91 |     expect($registeredResource)->toBeInstanceOf(RegisteredResource::class)
 92 |         ->and($registeredResource->schema)->toBe($resourceSchema)
 93 |         ->and($registeredResource->isManual)->toBeTrue();
 94 |     expect($this->registry->getResources())->toHaveKey('manual://res/1');
 95 | });
 96 | 
 97 | it('registers discovered resource correctly', function () {
 98 |     $resourceSchema = createTestResourceSchema('discovered://res/1');
 99 |     $this->registry->registerResource($resourceSchema, ['HandlerClass', 'method'], false);
100 | 
101 |     $registeredResource = $this->registry->getResource('discovered://res/1');
102 |     expect($registeredResource)->toBeInstanceOf(RegisteredResource::class)
103 |         ->and($registeredResource->schema)->toBe($resourceSchema)
104 |         ->and($registeredResource->isManual)->toBeFalse();
105 | });
106 | 
107 | it('registers manual prompt correctly', function () {
108 |     $promptSchema = createTestPromptSchema('manual-prompt-1');
109 |     $this->registry->registerPrompt($promptSchema, ['HandlerClass', 'method'], [], true);
110 | 
111 |     $registeredPrompt = $this->registry->getPrompt('manual-prompt-1');
112 |     expect($registeredPrompt)->toBeInstanceOf(RegisteredPrompt::class)
113 |         ->and($registeredPrompt->schema)->toBe($promptSchema)
114 |         ->and($registeredPrompt->isManual)->toBeTrue();
115 |     expect($this->registry->getPrompts())->toHaveKey('manual-prompt-1');
116 | });
117 | 
118 | it('registers discovered prompt correctly', function () {
119 |     $promptSchema = createTestPromptSchema('discovered-prompt-1');
120 |     $this->registry->registerPrompt($promptSchema, ['HandlerClass', 'method'], [], false);
121 | 
122 |     $registeredPrompt = $this->registry->getPrompt('discovered-prompt-1');
123 |     expect($registeredPrompt)->toBeInstanceOf(RegisteredPrompt::class)
124 |         ->and($registeredPrompt->schema)->toBe($promptSchema)
125 |         ->and($registeredPrompt->isManual)->toBeFalse();
126 | });
127 | 
128 | it('registers manual resource template correctly', function () {
129 |     $templateSchema = createTestTemplateSchema('manual://tmpl/{id}');
130 |     $this->registry->registerResourceTemplate($templateSchema, ['HandlerClass', 'method'], [], true);
131 | 
132 |     $registeredTemplate = $this->registry->getResourceTemplate('manual://tmpl/{id}');
133 |     expect($registeredTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class)
134 |         ->and($registeredTemplate->schema)->toBe($templateSchema)
135 |         ->and($registeredTemplate->isManual)->toBeTrue();
136 |     expect($this->registry->getResourceTemplates())->toHaveKey('manual://tmpl/{id}');
137 | });
138 | 
139 | it('registers discovered resource template correctly', function () {
140 |     $templateSchema = createTestTemplateSchema('discovered://tmpl/{id}');
141 |     $this->registry->registerResourceTemplate($templateSchema, ['HandlerClass', 'method'], [], false);
142 | 
143 |     $registeredTemplate = $this->registry->getResourceTemplate('discovered://tmpl/{id}');
144 |     expect($registeredTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class)
145 |         ->and($registeredTemplate->schema)->toBe($templateSchema)
146 |         ->and($registeredTemplate->isManual)->toBeFalse();
147 | });
148 | 
149 | test('getResource finds exact URI match before template match', function () {
150 |     $exactResourceSchema = createTestResourceSchema('test://item/exact');
151 |     $templateSchema = createTestTemplateSchema('test://item/{itemId}');
152 | 
153 |     $this->registry->registerResource($exactResourceSchema, ['H', 'm']);
154 |     $this->registry->registerResourceTemplate($templateSchema, ['H', 'm']);
155 | 
156 |     $found = $this->registry->getResource('test://item/exact');
157 |     expect($found)->toBeInstanceOf(RegisteredResource::class)
158 |         ->and($found->schema->uri)->toBe('test://item/exact');
159 | });
160 | 
161 | test('getResource finds template match if no exact URI match', function () {
162 |     $templateSchema = createTestTemplateSchema('test://item/{itemId}');
163 |     $this->registry->registerResourceTemplate($templateSchema, ['H', 'm']);
164 | 
165 |     $found = $this->registry->getResource('test://item/123');
166 |     expect($found)->toBeInstanceOf(RegisteredResourceTemplate::class)
167 |         ->and($found->schema->uriTemplate)->toBe('test://item/{itemId}');
168 | });
169 | 
170 | test('getResource returns null if no match and templates excluded', function () {
171 |     $templateSchema = createTestTemplateSchema('test://item/{itemId}');
172 |     $this->registry->registerResourceTemplate($templateSchema, ['H', 'm']);
173 | 
174 |     $found = $this->registry->getResource('test://item/123', false);
175 |     expect($found)->toBeNull();
176 | });
177 | 
178 | test('getResource returns null if no match at all', function () {
179 |     $found = $this->registry->getResource('nonexistent://uri');
180 |     expect($found)->toBeNull();
181 | });
182 | 
183 | it('hasElements returns true if any manual elements exist', function () {
184 |     expect($this->registry->hasElements())->toBeFalse();
185 |     $this->registry->registerTool(createTestToolSchema('manual-only'), ['H', 'm'], true);
186 |     expect($this->registry->hasElements())->toBeTrue();
187 | });
188 | 
189 | it('hasElements returns true if any discovered elements exist', function () {
190 |     expect($this->registry->hasElements())->toBeFalse();
191 |     $this->registry->registerTool(createTestToolSchema('discovered-only'), ['H', 'm'], false);
192 |     expect($this->registry->hasElements())->toBeTrue();
193 | });
194 | 
195 | it('overrides existing discovered element with manual registration', function (string $type) {
196 |     $nameOrUri = $type === 'resource' ? 'conflict://res' : 'conflict-element';
197 |     $templateUri = 'conflict://tmpl/{id}';
198 | 
199 |     $discoveredSchema = match ($type) {
200 |         'tool' => createTestToolSchema($nameOrUri),
201 |         'resource' => createTestResourceSchema($nameOrUri),
202 |         'prompt' => createTestPromptSchema($nameOrUri),
203 |         'template' => createTestTemplateSchema($templateUri),
204 |     };
205 |     $manualSchema = clone $discoveredSchema;
206 | 
207 |     match ($type) {
208 |         'tool' => $this->registry->registerTool($discoveredSchema, ['H', 'm'], false),
209 |         'resource' => $this->registry->registerResource($discoveredSchema, ['H', 'm'], false),
210 |         'prompt' => $this->registry->registerPrompt($discoveredSchema, ['H', 'm'], [], false),
211 |         'template' => $this->registry->registerResourceTemplate($discoveredSchema, ['H', 'm'], [], false),
212 |     };
213 | 
214 |     match ($type) {
215 |         'tool' => $this->registry->registerTool($manualSchema, ['H', 'm'], true),
216 |         'resource' => $this->registry->registerResource($manualSchema, ['H', 'm'], true),
217 |         'prompt' => $this->registry->registerPrompt($manualSchema, ['H', 'm'], [], true),
218 |         'template' => $this->registry->registerResourceTemplate($manualSchema, ['H', 'm'], [], true),
219 |     };
220 | 
221 |     $registeredElement = match ($type) {
222 |         'tool' => $this->registry->getTool($nameOrUri),
223 |         'resource' => $this->registry->getResource($nameOrUri),
224 |         'prompt' => $this->registry->getPrompt($nameOrUri),
225 |         'template' => $this->registry->getResourceTemplate($templateUri),
226 |     };
227 | 
228 |     expect($registeredElement->schema)->toBe($manualSchema);
229 |     expect($registeredElement->isManual)->toBeTrue();
230 | })->with(['tool', 'resource', 'prompt', 'template']);
231 | 
232 | it('does not override existing manual element with discovered registration', function (string $type) {
233 |     $nameOrUri = $type === 'resource' ? 'manual-priority://res' : 'manual-priority-element';
234 |     $templateUri = 'manual-priority://tmpl/{id}';
235 | 
236 |     $manualSchema = match ($type) {
237 |         'tool' => createTestToolSchema($nameOrUri),
238 |         'resource' => createTestResourceSchema($nameOrUri),
239 |         'prompt' => createTestPromptSchema($nameOrUri),
240 |         'template' => createTestTemplateSchema($templateUri),
241 |     };
242 |     $discoveredSchema = clone $manualSchema;
243 | 
244 |     match ($type) {
245 |         'tool' => $this->registry->registerTool($manualSchema, ['H', 'm'], true),
246 |         'resource' => $this->registry->registerResource($manualSchema, ['H', 'm'], true),
247 |         'prompt' => $this->registry->registerPrompt($manualSchema, ['H', 'm'], [], true),
248 |         'template' => $this->registry->registerResourceTemplate($manualSchema, ['H', 'm'], [], true),
249 |     };
250 | 
251 |     match ($type) {
252 |         'tool' => $this->registry->registerTool($discoveredSchema, ['H', 'm'], false),
253 |         'resource' => $this->registry->registerResource($discoveredSchema, ['H', 'm'], false),
254 |         'prompt' => $this->registry->registerPrompt($discoveredSchema, ['H', 'm'], [], false),
255 |         'template' => $this->registry->registerResourceTemplate($discoveredSchema, ['H', 'm'], [], false),
256 |     };
257 | 
258 |     $registeredElement = match ($type) {
259 |         'tool' => $this->registry->getTool($nameOrUri),
260 |         'resource' => $this->registry->getResource($nameOrUri),
261 |         'prompt' => $this->registry->getPrompt($nameOrUri),
262 |         'template' => $this->registry->getResourceTemplate($templateUri),
263 |     };
264 | 
265 |     expect($registeredElement->schema)->toBe($manualSchema);
266 |     expect($registeredElement->isManual)->toBeTrue();
267 | })->with(['tool', 'resource', 'prompt', 'template']);
268 | 
269 | 
270 | it('loads discovered elements from cache correctly on construction', function () {
271 |     $toolSchema1 = createTestToolSchema('cached-tool-1');
272 |     $resourceSchema1 = createTestResourceSchema('cached://res/1');
273 |     $cachedData = [
274 |         'tools' => [$toolSchema1->name => json_encode(RegisteredTool::make($toolSchema1, ['H', 'm']))],
275 |         'resources' => [$resourceSchema1->uri => json_encode(RegisteredResource::make($resourceSchema1, ['H', 'm']))],
276 |         'prompts' => [],
277 |         'resourceTemplates' => [],
278 |     ];
279 |     $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn($cachedData);
280 | 
281 |     $registry = new Registry($this->logger, $this->cache);
282 | 
283 |     expect($registry->getTool('cached-tool-1'))->toBeInstanceOf(RegisteredTool::class)
284 |         ->and($registry->getTool('cached-tool-1')->isManual)->toBeFalse();
285 |     expect($registry->getResource('cached://res/1'))->toBeInstanceOf(RegisteredResource::class)
286 |         ->and($registry->getResource('cached://res/1')->isManual)->toBeFalse();
287 |     expect($registry->hasElements())->toBeTrue();
288 | });
289 | 
290 | it('skips loading cached element if manual one with same key is registered later', function () {
291 |     $conflictName = 'conflict-tool';
292 |     $cachedToolSchema = createTestToolSchema($conflictName);
293 |     $manualToolSchema = createTestToolSchema($conflictName); // Different instance
294 | 
295 |     $cachedData = ['tools' => [$conflictName => json_encode(RegisteredTool::make($cachedToolSchema, ['H', 'm']))]];
296 |     $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn($cachedData);
297 | 
298 |     $registry = new Registry($this->logger, $this->cache);
299 | 
300 |     expect($registry->getTool($conflictName)->schema->name)->toBe($cachedToolSchema->name);
301 |     expect($registry->getTool($conflictName)->isManual)->toBeFalse();
302 | 
303 |     $registry->registerTool($manualToolSchema, ['H', 'm'], true);
304 | 
305 |     expect($registry->getTool($conflictName)->schema->name)->toBe($manualToolSchema->name);
306 |     expect($registry->getTool($conflictName)->isManual)->toBeTrue();
307 | });
308 | 
309 | 
310 | it('saves only non-manual elements to cache', function () {
311 |     $manualToolSchema = createTestToolSchema('manual-save');
312 |     $discoveredToolSchema = createTestToolSchema('discovered-save');
313 |     $expectedRegisteredDiscoveredTool = RegisteredTool::make($discoveredToolSchema, ['H', 'm'], false);
314 | 
315 |     $this->registry->registerTool($manualToolSchema, ['H', 'm'], true);
316 |     $this->registry->registerTool($discoveredToolSchema, ['H', 'm'], false);
317 | 
318 |     $expectedCachedData = [
319 |         'tools' => ['discovered-save' => json_encode($expectedRegisteredDiscoveredTool)],
320 |         'resources' => [],
321 |         'prompts' => [],
322 |         'resourceTemplates' => [],
323 |     ];
324 | 
325 |     $this->cache->shouldReceive('set')->once()
326 |         ->with(DISCOVERED_CACHE_KEY_REG, $expectedCachedData)
327 |         ->andReturn(true);
328 | 
329 |     $result = $this->registry->save();
330 |     expect($result)->toBeTrue();
331 | });
332 | 
333 | it('does not attempt to save to cache if cache is null', function () {
334 |     $this->registryNoCache->registerTool(createTestToolSchema('discovered-no-cache'), ['H', 'm'], false);
335 |     $result = $this->registryNoCache->save();
336 |     expect($result)->toBeFalse();
337 | });
338 | 
339 | it('handles invalid (non-array) data from cache gracefully during load', function () {
340 |     $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn('this is not an array');
341 |     $this->logger->shouldReceive('warning')->with(Mockery::pattern('/Invalid or missing data found in registry cache/'), Mockery::any())->once();
342 | 
343 |     $registry = new Registry($this->logger, $this->cache);
344 | 
345 |     expect($registry->hasElements())->toBeFalse();
346 | });
347 | 
348 | it('handles cache unserialization errors gracefully during load', function () {
349 |     $badSerializedData = ['tools' => ['bad-tool' => 'not a serialized object']];
350 |     $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn($badSerializedData);
351 | 
352 |     $registry = new Registry($this->logger, $this->cache);
353 | 
354 |     expect($registry->hasElements())->toBeFalse();
355 | });
356 | 
357 | it('handles cache general exceptions during load gracefully', function () {
358 |     $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new \RuntimeException('Cache unavailable'));
359 | 
360 |     $registry = new Registry($this->logger, $this->cache);
361 | 
362 |     expect($registry->hasElements())->toBeFalse();
363 | });
364 | 
365 | it('handles cache InvalidArgumentException during load gracefully', function () {
366 |     $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new class() extends \Exception implements CacheInvalidArgumentException {});
367 | 
368 |     $registry = new Registry($this->logger, $this->cache);
369 |     expect($registry->hasElements())->toBeFalse();
370 | });
371 | 
372 | 
373 | it('clears non-manual elements and deletes cache file', function () {
374 |     $this->registry->registerTool(createTestToolSchema('manual-clear'), ['H', 'm'], true);
375 |     $this->registry->registerTool(createTestToolSchema('discovered-clear'), ['H', 'm'], false);
376 | 
377 |     $this->cache->shouldReceive('delete')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn(true);
378 | 
379 |     $this->registry->clear();
380 | 
381 |     expect($this->registry->getTool('manual-clear'))->not->toBeNull();
382 |     expect($this->registry->getTool('discovered-clear'))->toBeNull();
383 | });
384 | 
385 | 
386 | it('handles cache exceptions during clear gracefully', function () {
387 |     $this->registry->registerTool(createTestToolSchema('discovered-clear'), ['H', 'm'], false);
388 |     $this->cache->shouldReceive('delete')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new \RuntimeException("Cache delete failed"));
389 | 
390 |     $this->registry->clear();
391 | 
392 |     expect($this->registry->getTool('discovered-clear'))->toBeNull();
393 | });
394 | 
395 | it('emits list_changed event when a new tool is registered', function () {
396 |     $emitted = null;
397 |     $this->registry->on('list_changed', function ($listType) use (&$emitted) {
398 |         $emitted = $listType;
399 |     });
400 | 
401 |     $this->registry->registerTool(createTestToolSchema('notifying-tool'), ['H', 'm']);
402 |     expect($emitted)->toBe('tools');
403 | });
404 | 
405 | it('emits list_changed event when a new resource is registered', function () {
406 |     $emitted = null;
407 |     $this->registry->on('list_changed', function ($listType) use (&$emitted) {
408 |         $emitted = $listType;
409 |     });
410 | 
411 |     $this->registry->registerResource(createTestResourceSchema('notify://res'), ['H', 'm']);
412 |     expect($emitted)->toBe('resources');
413 | });
414 | 
415 | it('does not emit list_changed event if notifications are disabled', function () {
416 |     $this->registry->disableNotifications();
417 |     $emitted = false;
418 |     $this->registry->on('list_changed', function () use (&$emitted) {
419 |         $emitted = true;
420 |     });
421 | 
422 |     $this->registry->registerTool(createTestToolSchema('silent-tool'), ['H', 'm']);
423 |     expect($emitted)->toBeFalse();
424 | 
425 |     $this->registry->enableNotifications();
426 | });
427 | 
428 | it('computes different hashes for different collections', function () {
429 |     $method = new \ReflectionMethod(Registry::class, 'computeHash');
430 |     $method->setAccessible(true);
431 | 
432 |     $hash1 = $method->invoke($this->registry, ['a' => 1, 'b' => 2]);
433 |     $hash2 = $method->invoke($this->registry, ['b' => 2, 'a' => 1]);
434 |     $hash3 = $method->invoke($this->registry, ['a' => 1, 'c' => 3]);
435 | 
436 |     expect($hash1)->toBeString()->not->toBeEmpty();
437 |     expect($hash2)->toBe($hash1);
438 |     expect($hash3)->not->toBe($hash1);
439 |     expect($method->invoke($this->registry, []))->toBe('');
440 | });
441 | 
442 | it('recomputes and emits list_changed only when content actually changes', function () {
443 |     $tool1 = createTestToolSchema('tool1');
444 |     $tool2 = createTestToolSchema('tool2');
445 |     $callCount = 0;
446 | 
447 |     $this->registry->on('list_changed', function ($listType) use (&$callCount) {
448 |         if ($listType === 'tools') {
449 |             $callCount++;
450 |         }
451 |     });
452 | 
453 |     $this->registry->registerTool($tool1, ['H', 'm1']);
454 |     expect($callCount)->toBe(1);
455 | 
456 |     $this->registry->registerTool($tool1, ['H', 'm1']);
457 |     expect($callCount)->toBe(1);
458 | 
459 |     $this->registry->registerTool($tool2, ['H', 'm2']);
460 |     expect($callCount)->toBe(2);
461 | });
462 | 
463 | it('registers tool with closure handler correctly', function () {
464 |     $toolSchema = createTestToolSchema('closure-tool');
465 |     $closure = function (string $input): string {
466 |         return "processed: $input";
467 |     };
468 | 
469 |     $this->registry->registerTool($toolSchema, $closure, true);
470 | 
471 |     $registeredTool = $this->registry->getTool('closure-tool');
472 |     expect($registeredTool)->toBeInstanceOf(RegisteredTool::class)
473 |         ->and($registeredTool->schema)->toBe($toolSchema)
474 |         ->and($registeredTool->isManual)->toBeTrue()
475 |         ->and($registeredTool->handler)->toBe($closure);
476 | });
477 | 
478 | it('registers resource with closure handler correctly', function () {
479 |     $resourceSchema = createTestResourceSchema('closure://res');
480 |     $closure = function (string $uri): array {
481 |         return [new \PhpMcp\Schema\Content\TextContent("Resource: $uri")];
482 |     };
483 | 
484 |     $this->registry->registerResource($resourceSchema, $closure, true);
485 | 
486 |     $registeredResource = $this->registry->getResource('closure://res');
487 |     expect($registeredResource)->toBeInstanceOf(RegisteredResource::class)
488 |         ->and($registeredResource->schema)->toBe($resourceSchema)
489 |         ->and($registeredResource->isManual)->toBeTrue()
490 |         ->and($registeredResource->handler)->toBe($closure);
491 | });
492 | 
493 | it('registers prompt with closure handler correctly', function () {
494 |     $promptSchema = createTestPromptSchema('closure-prompt');
495 |     $closure = function (string $topic): array {
496 |         return [
497 |             \PhpMcp\Schema\Content\PromptMessage::make(
498 |                 \PhpMcp\Schema\Enum\Role::User,
499 |                 new \PhpMcp\Schema\Content\TextContent("Tell me about $topic")
500 |             )
501 |         ];
502 |     };
503 | 
504 |     $this->registry->registerPrompt($promptSchema, $closure, [], true);
505 | 
506 |     $registeredPrompt = $this->registry->getPrompt('closure-prompt');
507 |     expect($registeredPrompt)->toBeInstanceOf(RegisteredPrompt::class)
508 |         ->and($registeredPrompt->schema)->toBe($promptSchema)
509 |         ->and($registeredPrompt->isManual)->toBeTrue()
510 |         ->and($registeredPrompt->handler)->toBe($closure);
511 | });
512 | 
513 | it('registers resource template with closure handler correctly', function () {
514 |     $templateSchema = createTestTemplateSchema('closure://item/{id}');
515 |     $closure = function (string $uri, string $id): array {
516 |         return [new \PhpMcp\Schema\Content\TextContent("Item $id from $uri")];
517 |     };
518 | 
519 |     $this->registry->registerResourceTemplate($templateSchema, $closure, [], true);
520 | 
521 |     $registeredTemplate = $this->registry->getResourceTemplate('closure://item/{id}');
522 |     expect($registeredTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class)
523 |         ->and($registeredTemplate->schema)->toBe($templateSchema)
524 |         ->and($registeredTemplate->isManual)->toBeTrue()
525 |         ->and($registeredTemplate->handler)->toBe($closure);
526 | });
527 | 
528 | it('does not save closure handlers to cache', function () {
529 |     $closure = function (): string {
530 |         return 'test';
531 |     };
532 |     $arrayHandler = ['TestClass', 'testMethod'];
533 | 
534 |     $closureTool = createTestToolSchema('closure-tool');
535 |     $arrayTool = createTestToolSchema('array-tool');
536 | 
537 |     $this->registry->registerTool($closureTool, $closure, true);
538 |     $this->registry->registerTool($arrayTool, $arrayHandler, false);
539 | 
540 |     $expectedCachedData = [
541 |         'tools' => ['array-tool' => json_encode(RegisteredTool::make($arrayTool, $arrayHandler, false))],
542 |         'resources' => [],
543 |         'prompts' => [],
544 |         'resourceTemplates' => [],
545 |     ];
546 | 
547 |     $this->cache->shouldReceive('set')->once()
548 |         ->with(DISCOVERED_CACHE_KEY_REG, $expectedCachedData)
549 |         ->andReturn(true);
550 | 
551 |     $result = $this->registry->save();
552 |     expect($result)->toBeTrue();
553 | });
554 | 
555 | it('handles static method handlers correctly', function () {
556 |     $toolSchema = createTestToolSchema('static-tool');
557 |     $staticHandler = [TestStaticHandler::class, 'handle'];
558 | 
559 |     $this->registry->registerTool($toolSchema, $staticHandler, true);
560 | 
561 |     $registeredTool = $this->registry->getTool('static-tool');
562 |     expect($registeredTool)->toBeInstanceOf(RegisteredTool::class)
563 |         ->and($registeredTool->handler)->toBe($staticHandler);
564 | });
565 | 
566 | it('handles invokable class string handlers correctly', function () {
567 |     $toolSchema = createTestToolSchema('invokable-tool');
568 |     $invokableHandler = TestInvokableHandler::class;
569 | 
570 |     $this->registry->registerTool($toolSchema, $invokableHandler, true);
571 | 
572 |     $registeredTool = $this->registry->getTool('invokable-tool');
573 |     expect($registeredTool)->toBeInstanceOf(RegisteredTool::class)
574 |         ->and($registeredTool->handler)->toBe($invokableHandler);
575 | });
576 | 
577 | // Test helper classes
578 | class TestStaticHandler
579 | {
580 |     public static function handle(): string
581 |     {
582 |         return 'static result';
583 |     }
584 | }
585 | 
586 | class TestInvokableHandler
587 | {
588 |     public function __invoke(): string
589 |     {
590 |         return 'invokable result';
591 |     }
592 | }
593 | 
```

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

```markdown
  1 | # Changelog
  2 | 
  3 | All notable changes to `php-mcp/server` will be documented in this file.
  4 | 
  5 | ## v3.3.0 - 2025-07-12
  6 | 
  7 | ### What's Changed
  8 | 
  9 | * Feat: Add stateless mode for StreamableHttpServerTransport by @CodeWithKyrian in https://github.com/php-mcp/server/pull/48
 10 | * Fix: Make PCNTL extension optional for StdioServerTransport by @CodeWithKyrian in https://github.com/php-mcp/server/pull/49
 11 | 
 12 | **Full Changelog**: https://github.com/php-mcp/server/compare/3.2.2...3.3.0
 13 | 
 14 | ## v3.2.2 - 2025-07-09
 15 | 
 16 | ### What's Changed
 17 | 
 18 | * Fix Architecture graph by @szepeviktor in https://github.com/php-mcp/server/pull/42
 19 | * Fix: Correctly handle invokable class tool handlers by @CodeWithKyrian in https://github.com/php-mcp/server/pull/47
 20 | 
 21 | ### New Contributors
 22 | 
 23 | * @szepeviktor made their first contribution in https://github.com/php-mcp/server/pull/42
 24 | 
 25 | **Full Changelog**: https://github.com/php-mcp/server/compare/3.2.1...3.2.2
 26 | 
 27 | ## v3.2.1 - 2025-06-30
 28 | 
 29 | ### What's Changed
 30 | 
 31 | * feat:  use callable instead of Closure|array|string for handler type by @CodeWithKyrian in https://github.com/php-mcp/server/pull/41
 32 | 
 33 | **Full Changelog**: https://github.com/php-mcp/server/compare/3.2.0...3.2.1
 34 | 
 35 | ## v3.2.0 - 2025-06-30
 36 | 
 37 | ### What's Changed
 38 | 
 39 | * fix: resolve cache session handler index inconsistencies by @CodeWithKyrian in https://github.com/php-mcp/server/pull/36
 40 | * feat: Add comprehensive callable handler support for closures, static methods, and invokable classes by @CodeWithKyrian in https://github.com/php-mcp/server/pull/38
 41 | * feat: Enhanced Completion Providers with Values and Enum Support by @CodeWithKyrian in https://github.com/php-mcp/server/pull/40
 42 | 
 43 | ### Upgrade Guide
 44 | 
 45 | If you're using the `CompletionProvider` attribute with the named `providerClass` parameter, consider updating to the new `provider` parameter for consistency:
 46 | 
 47 | ```php
 48 | // Before (still works)
 49 | #[CompletionProvider(providerClass: UserProvider::class)]
 50 | 
 51 | // After (recommended)
 52 | #[CompletionProvider(provider: UserProvider::class)]
 53 | 
 54 | 
 55 | 
 56 | 
 57 | ```
 58 | The old `providerClass` parameter continues to work for backward compatibility, but may be dropped in a future major version release.
 59 | 
 60 | **Full Changelog**: https://github.com/php-mcp/server/compare/3.1.1...3.2.0
 61 | 
 62 | ## v3.1.1 - 2025-06-26
 63 | 
 64 | ### What's Changed
 65 | 
 66 | * Fix: implement proper MCP protocol version negotiation by @CodeWithKyrian in https://github.com/php-mcp/server/pull/35
 67 | 
 68 | **Full Changelog**: https://github.com/php-mcp/server/compare/3.1.0...3.1.1
 69 | 
 70 | ## v3.1.0 - 2025-06-25
 71 | 
 72 | ### What's Changed
 73 | 
 74 | * Refactor: expose session garbage collection method for integration by @CodeWithKyrian in https://github.com/php-mcp/server/pull/31
 75 | * feat: add instructions in server initialization result by @CodeWithKyrian in https://github.com/php-mcp/server/pull/32
 76 | * fix(cache): handle missing session in index for CacheSessionHandler by @CodeWithKyrian in https://github.com/php-mcp/server/pull/33
 77 | 
 78 | **Full Changelog**: https://github.com/php-mcp/server/compare/3.0.2...3.1.0
 79 | 
 80 | ## v3.0.2 - 2025-06-25
 81 | 
 82 | ### What's Changed
 83 | 
 84 | * fix: Registry cache clearing bug preventing effective caching by @CodeWithKyrian in https://github.com/php-mcp/server/pull/29
 85 | * Fix ServerBuilder error handling for manual element registration by @CodeWithKyrian in https://github.com/php-mcp/server/pull/30
 86 | 
 87 | **Full Changelog**: https://github.com/php-mcp/server/compare/3.0.1...3.0.2
 88 | 
 89 | ## v3.0.1 - 2025-06-24
 90 | 
 91 | ### What's Changed
 92 | 
 93 | * Fix validation failure for MCP tools without parameters by @CodeWithKyrian in https://github.com/php-mcp/server/pull/28
 94 | 
 95 | **Full Changelog**: https://github.com/php-mcp/server/compare/3.0.0...3.0.1
 96 | 
 97 | ## v3.0.0 - 2025-06-21
 98 | 
 99 | This release brings support for the latest MCP protocol version along with enhanced schema generation, new transport capabilities, and streamlined APIs.
100 | 
101 | ### ✨ New Features
102 | 
103 | * **StreamableHttpServerTransport**: New transport with resumability, event sourcing, and JSON response mode for production deployments
104 | * **Smart Schema Generation**: Automatic JSON schema generation from method signatures with optional `#[Schema]` attribute enhancements
105 | * **Completion Providers**: `#[CompletionProvider]` attribute for auto-completion in resource templates and prompts
106 | * **Batch Request Processing**: Full support for JSON-RPC 2.0 batch requests
107 | * **Enhanced Session Management**: Multiple session backends (array, cache, custom) with persistence and garbage collection
108 | 
109 | ### 🔥 Breaking Changes
110 | 
111 | * **Schema Package Integration**: Now uses `php-mcp/schema` package for all DTOs, requests, responses, and content types
112 | * **Session Management**: `ClientStateManager` replaced with `SessionManager` and `Session` classes
113 | * **Component Reorganization**: `Support\*` classes moved to `Utils\*` namespace
114 | * **Request Processing**: `RequestHandler` renamed to `Dispatcher`
115 | 
116 | *Note: Most of these changes are internal and won't affect your existing MCP element definitions and handlers.*
117 | 
118 | ### 🔧 Enhanced Features
119 | 
120 | * **Improved Schema System**: The `#[Schema]` attribute can now be used at both method-level and parameter-level (previously parameter-level only)
121 | * **Better Error Handling**: Enhanced JSON-RPC error responses with proper status codes
122 | * **PSR-20 Clock Interface**: Time management with `SystemClock` implementation
123 | * **Event Store Interface**: Pluggable event storage for resumable connections
124 | 
125 | ### 📦 Dependencies
126 | 
127 | * Now requires `php-mcp/schema` ^1.0
128 | * Enhanced PSR compliance (PSR-3, PSR-11, PSR-16, PSR-20)
129 | 
130 | ### 🚧 Migration Guide
131 | 
132 | #### Capabilities Configuration
133 | 
134 | **Before:**
135 | 
136 | ```php
137 | ->withCapabilities(Capabilities::forServer(
138 |     resourcesEnabled: true,
139 |     promptsEnabled: true,
140 |     toolsEnabled: true,
141 |     resourceSubscribe: true
142 | ))
143 | 
144 | 
145 | 
146 | 
147 | 
148 | 
149 | 
150 | 
151 | 
152 | ```
153 | **After:**
154 | 
155 | ```php
156 | ->withCapabilities(ServerCapabilities::make(
157 |     resources: true,
158 |     prompts: true,
159 |     tools: true,
160 |     resourcesSubscribe: true
161 | ))
162 | 
163 | 
164 | 
165 | 
166 | 
167 | 
168 | 
169 | 
170 | 
171 | ```
172 | #### Transport Upgrade (Optional)
173 | 
174 | For production HTTP deployments, consider upgrading to the new `StreamableHttpServerTransport`:
175 | 
176 | **Before:**
177 | 
178 | ```php
179 | $transport = new HttpServerTransport(host: '127.0.0.1', port: 8080);
180 | 
181 | 
182 | 
183 | 
184 | 
185 | 
186 | 
187 | 
188 | 
189 | ```
190 | **After:**
191 | 
192 | ```php
193 | $transport = new StreamableHttpServerTransport(host: '127.0.0.1',  port: 8080);
194 | 
195 | 
196 | 
197 | 
198 | 
199 | 
200 | 
201 | 
202 | 
203 | ```
204 | ### 📚 Documentation
205 | 
206 | * Complete README rewrite with comprehensive examples and deployment guides
207 | * New production deployment section covering VPS, Docker, and SSL setup
208 | * Enhanced schema generation documentation
209 | * Migration guide for v2.x users
210 | 
211 | **Full Changelog**: https://github.com/php-mcp/server/compare/2.3.1...3.0.0
212 | 
213 | ## v2.3.1 - 2025-06-13
214 | 
215 | ### What's Changed
216 | 
217 | * Streamline Registry Notifications and Add Discovery Suppression Support by @CodeWithKyrian in https://github.com/php-mcp/server/pull/22
218 | 
219 | **Full Changelog**: https://github.com/php-mcp/server/compare/2.3.0...2.3.1
220 | 
221 | ## v2.3.0 - 2025-06-12
222 | 
223 | ### What's Changed
224 | 
225 | * Fix: Require react/promise ^3.0 for Promise API Compatibility by @CodeWithKyrian in https://github.com/php-mcp/server/pull/18
226 | * Fix: Correct object serialization in FileCache using serialize/unserialize by @CodeWithKyrian in https://github.com/php-mcp/server/pull/19
227 | * check the the header X-Forwarded-Proto for scheme by @bangnokia in https://github.com/php-mcp/server/pull/14
228 | * Feat: Improve HttpServerTransport Extensibility via Protected Methods by @CodeWithKyrian in https://github.com/php-mcp/server/pull/20
229 | 
230 | ### New Contributors
231 | 
232 | * @bangnokia made their first contribution in https://github.com/php-mcp/server/pull/14
233 | 
234 | **Full Changelog**: https://github.com/php-mcp/server/compare/2.2.1...2.3.0
235 | 
236 | ## v2.2.1 - 2025-06-07
237 | 
238 | ### What's Changed
239 | 
240 | * Fix tool name generation for invokable classes with MCP attributes by @CodeWithKyrian in https://github.com/php-mcp/server/pull/13
241 | 
242 | **Full Changelog**: https://github.com/php-mcp/server/compare/2.2.0...2.2.1
243 | 
244 | ## v2.2.0 - 2025-06-03
245 | 
246 | ### What's Changed
247 | 
248 | * feat(pagination): Added configuration for a server-wide pagination limit, enabling more controlled data retrieval for list-based MCP operations. This limit is utilized by the `RequestProcessor`.
249 | * feat(handlers): Introduced `HandlerResolver` to provide more robust validation and resolution mechanisms for MCP element handlers, improving the reliability of element registration and invocation.
250 | * refactor(server): Modified the server listening mechanism to allow initialization and transport binding without an immediately blocking event loop. This enhances flexibility for embedding the server or managing its lifecycle in diverse application environments.
251 | * refactor(core): Performed general cleanup and enhancements to the internal architecture and dependencies, contributing to improved code maintainability and overall system stability.
252 | 
253 | **Full Changelog**: https://github.com/php-mcp/server/compare/2.1.0...2.2.0
254 | 
255 | ## v2.1.0 - 2025-05-17
256 | 
257 | ### What's Changed
258 | 
259 | * feat(schema): add Schema attributes and enhance DocBlock array type parsing by @CodeWithKyrian in https://github.com/php-mcp/server/pull/8
260 | 
261 | **Full Changelog**: https://github.com/php-mcp/server/compare/2.0.1...2.1.0
262 | 
263 | ## PHP MCP Server v2.0.1 (HotFix) - 2025-05-11
264 | 
265 | ### What's Changed
266 | 
267 | * Fix: Ensure react/http is a runtime dependency for HttpServerTransport by @CodeWithKyrian in https://github.com/php-mcp/server/pull/7
268 | 
269 | **Full Changelog**: https://github.com/php-mcp/server/compare/2.0.0...2.0.1
270 | 
271 | ## PHP MCP Server v2.0.0 - 2025-05-11
272 | 
273 | This release marks a significant architectural refactoring of the package, aimed at improving modularity, testability, flexibility, and aligning its structure more closely with the `php-mcp/client` library. The core functionality remains, but the way servers are configured, run, and integrated has fundamentally changed.
274 | 
275 | ### What's Changed
276 | 
277 | #### Core Architecture Overhaul
278 | 
279 | * **Decoupled Design:** The server core logic is now separated from the transport (network/IO) layer.
280 |   
281 |   * **`ServerBuilder`:** A new fluent builder (`Server::make()`) is the primary way to configure server identity, dependencies (Logger, Cache, Container, Loop), capabilities, and manually registered elements.
282 |   * **`Server` Object:** The main `Server` class, created by the builder, now holds the configured core components (`Registry`, `Processor`, `ClientStateManager`, `Configuration`) but is transport-agnostic itself.
283 |   * **`ServerTransportInterface`:** A new event-driven interface defines the contract for server-side transports (Stdio, Http). Transports are now responsible solely for listening and raw data transfer, emitting events for lifecycle and messages.
284 |   * **`Protocol`:** A new internal class acts as a bridge, listening to events from a bound `ServerTransportInterface` and coordinating interactions with the `Processor` and `ClientStateManager`.
285 |   
286 | * **Explicit Server Execution:**
287 |   
288 |   * The old `$server->run(?string)` method is **removed**.
289 |   * **`$server->listen(ServerTransportInterface $transport)`:** Introduced as the primary way to start a *standalone* server. It binds the `Protocol` to the provided transport, starts the listener, and runs the event loop (making it a blocking call).
290 |   
291 | 
292 | #### Discovery and Caching Refinements
293 | 
294 | * **Explicit Discovery:** Attribute discovery is no longer triggered automatically during `build()`. You must now explicitly call `$server->discover(basePath: ..., scanDirs: ...)` *after* building the server instance if you want to find elements via attributes.
295 |   
296 | * **Caching Behavior:**
297 |   
298 |   * Only *discovered* elements are eligible for caching. Manually registered elements (via `ServerBuilder->with*` methods) are **never cached**.
299 |   * The `Registry` attempts to load discovered elements from cache upon instantiation (during `ServerBuilder::build()`).
300 |   * Calling `$server->discover()` will first clear any previously discovered/cached elements from the registry before scanning. It then saves the *newly discovered* results to the cache if enabled (`saveToCache: true`).
301 |   * `Registry` cache methods renamed for clarity: `saveDiscoveredElementsToCache()` and `clearDiscoveredElements()`.
302 |   * `Registry::isLoaded()` renamed to `discoveryRanOrCached()` for better clarity.
303 |   
304 | * **Manual vs. Discovered Precedence:** If an element is registered both manually and found via discovery/cache with the same identifier (name/URI), the **manually registered version always takes precedence**.
305 |   
306 | 
307 | #### Dependency Injection and Configuration
308 | 
309 | * **`ConfigurationRepositoryInterface` Removed:** This interface and its default implementation (`ArrayConfigurationRepository`) have been removed.
310 | * **`Configuration` Value Object:** A new `PhpMcp\Server\Configuration` readonly value object bundles core dependencies (Logger, Loop, Cache, Container, Server Info, Capabilities, TTLs) assembled by the `ServerBuilder`.
311 | * **Simplified Dependencies:** Core components (`Registry`, `Processor`, `ClientStateManager`, `DocBlockParser`, `Discoverer`) now have simpler constructors, accepting direct dependencies.
312 | * **PSR-11 Container Role:** The container provided via `ServerBuilder->withContainer()` (or the default `BasicContainer`) is now primarily used by the `Processor` to resolve *user-defined handler classes* and their dependencies.
313 | * **Improved `BasicContainer`:** The default DI container (`PhpMcp\Server\Defaults\BasicContainer`) now supports simple constructor auto-wiring.
314 | * **`ClientStateManager` Default Cache:** If no `CacheInterface` is provided to the `ClientStateManager`, it now defaults to an in-memory `PhpMcp\Server\Defaults\ArrayCache`.
315 | 
316 | #### Schema Generation and Validation
317 | 
318 | * **Removed Optimistic String Format Inference:** The `SchemaGenerator` no longer automatically infers JSON Schema `format` keywords (like "date-time", "email") for string parameters. This makes default schemas less strict, avoiding validation issues for users with simpler string formats. Specific format validation should now be handled within tool/resource methods or via future explicit schema annotation features.
319 | * **Improved Tool Call Validation Error Messages:** When `tools/call` parameters fail schema validation, the JSON-RPC error response now includes a more informative summary message detailing the specific validation failures, in addition to the structured error data.
320 | 
321 | #### Transports
322 | 
323 | * **New Implementations:** Introduced `PhpMcp\Server\Transports\StdioServerTransport` and `PhpMcp\Server\Transports\HttpServerTransport`, both implementing `ServerTransportInterface`.
324 |   
325 |   * `StdioServerTransport` constructor now accepts custom input/output stream resources, improving testability and flexibility (defaults to `STDIN`/`STDOUT`).
326 |   * `HttpServerTransport` constructor now accepts an array of request interceptor callables for custom request pre-processing (e.g., authentication), and also takes `host`, `port`, `mcpPathPrefix`, and `sslContext` for server configuration.
327 |   
328 | * **Windows `stdio` Limitation:** `StdioServerTransport` now throws a `TransportException` if instantiated with default `STDIN`/`STDOUT` on Windows, due to PHP's limitations with non-blocking pipes, guiding users to `WSL` or `HttpServerTransport`.
329 |   
330 | * **Aware Interfaces:** Transports can implement `LoggerAwareInterface` and `LoopAwareInterface` to receive the configured Logger and Loop instances when `$server->listen()` is called.
331 |   
332 | * **Removed:** The old `StdioTransportHandler`, `HttpTransportHandler`, and `ReactPhpHttpTransportHandler` classes.
333 |   
334 | 
335 | #### Capabilities Configuration
336 | 
337 | * **`Model\Capabilities` Class:** Introduced a new `PhpMcp\Server\Model\Capabilities` value object (created via `Capabilities::forServer(...)`) to explicitly configure and represent server capabilities.
338 | 
339 | #### Exception Handling
340 | 
341 | * **`McpServerException`:** Renamed the base exception from `McpException` to `PhpMcp\Server\Exception\McpServerException`.
342 | * **New Exception Types:** Added more specific exceptions: `ConfigurationException`, `DiscoveryException`, `DefinitionException`, `TransportException`, `ProtocolException`.
343 | 
344 | #### Fixes
345 | 
346 | * Fixed `StdioServerTransport` not cleanly exiting on `Ctrl+C` due to event loop handling.
347 | * Fixed `TypeError` in `JsonRpc\Response` for parse errors with `null` ID.
348 | * Corrected discovery caching logic for explicit `discover()` calls.
349 | * Improved `HttpServerTransport` robustness for initial SSE event delivery and POST body handling.
350 | * Ensured manual registrations correctly take precedence over discovered/cached elements with the same identifier.
351 | 
352 | #### Internal Changes
353 | 
354 | * Introduced `LoggerAwareInterface` and `LoopAwareInterface` for dependency injection into transports.
355 | * Refined internal event handling between transport implementations and the `Protocol`.
356 | * Renamed `TransportState` to `ClientStateManager` and introduced a `ClientState` Value Object.
357 | 
358 | #### Documentation and Examples
359 | 
360 | * Significantly revised `README.md` to reflect the new architecture, API, discovery flow, transport usage, and configuration.
361 | * Added new and updated examples for standalone `stdio` and `http` servers, demonstrating discovery, manual registration, custom dependency injection, complex schemas, and environment variable usage.
362 | 
363 | ### Breaking Changes
364 | 
365 | This is a major refactoring with significant breaking changes:
366 | 
367 | 1. **`Server->run()` Method Removed:** Replace calls to `$server->run('stdio')` with:
368 |    
369 |     ```php
370 |     $transport = new StdioServerTransport();
371 |    // Optionally call $server->discover(...) first
372 |    $server->listen($transport);
373 |    
374 |    
375 |    
376 |    
377 |    
378 |    
379 |    
380 |    
381 |    
382 |    
383 |    
384 |    
385 |    
386 |    
387 |    
388 |    
389 |     ```
390 |    The `http` and `reactphp` options for `run()` were already invalid and are fully removed.
391 |    
392 | 2. **Configuration (`ConfigurationRepositoryInterface` Removed):** Configuration is now handled via the `Configuration` VO assembled by `ServerBuilder`. Remove any usage of the old `ConfigurationRepositoryInterface`. Core settings like server name/version are set via `withServerInfo`, capabilities via `withCapabilities`.
393 |    
394 | 3. **Dependency Injection:**
395 |    
396 |    * If using `ServerBuilder->withContainer()` with a custom PSR-11 container, that container is now only responsible for resolving *your application's handler classes* and their dependencies.
397 |    * Core server dependencies (Logger, Cache, Loop) **must** be provided explicitly to the `ServerBuilder` using `withLogger()`, `withCache()`, `withLoop()` or rely on the builder's defaults.
398 |    
399 | 4. **Transport Handlers Replaced:**
400 |    
401 |    * `StdioTransportHandler`, `HttpTransportHandler`, `ReactPhpHttpTransportHandler` are **removed**.
402 |    * Use `new StdioServerTransport()` or `new HttpServerTransport(...)` and pass them to `$server->listen()`.
403 |    * Constructor signatures and interaction patterns have changed.
404 |    
405 | 5. **`Registry` Cache Methods Renamed:** `saveElementsToCache` is now `saveDiscoveredElementsToCache`, and `clearCache` is now `clearDiscoveredElements`. Their behavior is also changed to only affect discovered elements.
406 |    
407 | 6. **Core Component Constructors:** The constructors for `Registry`, `Processor`, `ClientStateManager` (previously `TransportState`), `Discoverer`, `DocBlockParser` have changed. Update any direct instantiations (though typically these are managed internally).
408 |    
409 | 7. **Exception Renaming:** `McpException` is now `McpServerException`. Update `catch` blocks accordingly.
410 |    
411 | 8. **Default Null Logger:** Logging is effectively disabled by default. Provide a logger via `ServerBuilder->withLogger()` to enable it.
412 |    
413 | 9. **Schema Generation:** Automatic string `format` inference (e.g., "date-time") removed from `SchemaGenerator`. String parameters are now plain strings in the schema unless a more advanced format definition mechanism is used in the future.
414 |    
415 | 
416 | ### Deprecations
417 | 
418 | * (None introduced in this refactoring, as major breaking changes were made directly).
419 | 
420 | **Full Changelog**: https://github.com/php-mcp/server/compare/1.1.0...2.0.0
421 | 
422 | ## PHP MCP Server v1.1.0 - 2025-05-01
423 | 
424 | ### Added
425 | 
426 | * **Manual Element Registration:** Added fluent methods `withTool()`, `withResource()`, `withPrompt()`, and `withResourceTemplate()` to the `Server` class. This allows programmatic registration of MCP elements as an alternative or supplement to attribute discovery. Both `[ClassName::class, 'methodName']` array handlers and invokable class string handlers are supported.
427 | * **Invokable Class Attribute Discovery:** The server's discovery mechanism now supports placing `#[Mcp*]` attributes directly on invokable PHP class definitions (classes with a public `__invoke` method). The `__invoke` method will be used as the handler.
428 | * **Discovery Path Configuration:** Added `withBasePath()`, `withScanDirectories()`, and `withExcludeDirectories()` methods to the `Server` class for finer control over which directories are scanned during attribute discovery.
429 | 
430 | ### Changed
431 | 
432 | * **Dependency Injection:** Refactored internal dependency management. Core server components (`Processor`, `Registry`, `ClientStateManager`, etc.) now resolve `LoggerInterface`, `CacheInterface`, and `ConfigurationRepositoryInterface` Just-In-Time from the provided PSR-11 container. See **Breaking Changes** for implications.
433 | * **Default Logging Behavior:** Logging is now **disabled by default**. To enable logging, provide a `LoggerInterface` implementation via `withLogger()` (when using the default container) or by registering it within your custom PSR-11 container.
434 | * **Transport Handler Constructors:** Transport Handlers (e.g., `StdioTransportHandler`, `HttpTransportHandler`) now primarily accept the `Server` instance in their constructor, simplifying their instantiation.
435 | 
436 | ### Fixed
437 | 
438 | * Prevented potential "Constant STDERR not defined" errors in non-CLI environments by changing the default logger behavior (see Changed section).
439 | 
440 | ### Updated
441 | 
442 | * Extensively updated `README.md` to document manual registration, invokable class discovery, the dependency injection overhaul, discovery path configuration, transport handler changes, and the new default logging behavior.
443 | 
444 | ### Breaking Changes
445 | 
446 | * **Dependency Injection Responsibility:** Due to the DI refactoring, if you provide a custom PSR-11 container using `withContainer()`, you **MUST** ensure that your container is configured to provide implementations for `LoggerInterface`, `CacheInterface`, and `ConfigurationRepositoryInterface`. The server relies on being able to fetch these from the container.
447 | * **`withLogger/Cache/Config` Behavior with Custom Container:** When a custom container is provided via `withContainer()`, calls to `->withLogger()`, `->withCache()`, or `->withConfig()` on the `Server` instance will **not** override the services resolved from *your* container during runtime. Configuration for these core services must be done directly within your custom container setup.
448 | * **Transport Handler Constructor Signatures:** The constructor signatures for `StdioTransportHandler`, `HttpTransportHandler`, and `ReactPhpHttpTransportHandler` have changed. They now primarily require the `Server` instance. Update any direct instantiations of these handlers accordingly.
449 | 
450 | **Full Changelog**: https://github.com/php-mcp/server/compare/1.0.0...1.1.0
451 | 
452 | ## Release v1.0.0 - Initial Release
453 | 
454 | 🚀 **Initial release of PHP MCP SERVER!**
455 | 
456 | This release introduces the core implementation of the Model Context Protocol (MCP) server for PHP applications. The goal is to provide a robust, flexible, and developer-friendly way to expose parts of your PHP application as MCP Tools, Resources, and Prompts, enabling standardized communication with AI assistants like Claude, Cursor, and others.
457 | 
458 | ### ✨ Key Features:
459 | 
460 | * **Attribute-Based Definitions:** Easily define MCP Tools (`#[McpTool]`), Resources (`#[McpResource]`, `#[McpResourceTemplate]`), and Prompts (`#[McpPrompt]`) using PHP 8 attributes directly on your methods.
461 |   
462 | * **Automatic Metadata Inference:** Leverages method signatures (parameters, type hints) and DocBlocks (`@param`, `@return`, summaries) to automatically generate MCP schemas and descriptions, minimizing boilerplate.
463 |   
464 | * **PSR Compliance:** Integrates seamlessly with standard PHP interfaces:
465 |   
466 |   * `PSR-3` (LoggerInterface) for flexible logging.
467 |   * `PSR-11` (ContainerInterface) for dependency injection and class resolution.
468 |   * `PSR-16` (SimpleCacheInterface) for caching discovered elements and transport state.
469 |   
470 | * **Automatic Discovery:** Scans configured directories to find and register your annotated MCP elements.
471 |   
472 | * **Flexible Configuration:** Uses a configuration repository (`ConfigurationRepositoryInterface`) for fine-grained control over server behaviour, capabilities, and caching.
473 |   
474 | * **Multiple Transports:**
475 |   
476 |   * Built-in support for the `stdio` transport, ideal for command-line driven clients.
477 |   * Includes `HttpTransportHandler` components for building standard `http` (HTTP+SSE) transports (requires integration into an HTTP server).
478 |   * Provides `ReactPhpHttpTransportHandler` for seamless integration with asynchronous ReactPHP applications.
479 |   
480 | * **Protocol Support:** Implements the `2024-11-05` version of the Model Context Protocol.
481 |   
482 | * **Framework Agnostic:** Designed to work in vanilla PHP projects or integrated into any framework.
483 |   
484 | 
485 | ### 🚀 Getting Started
486 | 
487 | Please refer to the [README.md](README.md) for detailed installation instructions, usage examples, and core concepts. Sample implementations for `stdio` and `reactphp` are available in the `samples/` directory.
488 | 
489 | ### ⚠️ Important Notes
490 | 
491 | * When implementing the `http` transport using `HttpTransportHandler`, be aware of the critical server environment requirements detailed in the README regarding concurrent request handling for SSE. Standard synchronous PHP servers (like `php artisan serve` or basic Apache/Nginx setups) are generally **not suitable** without proper configuration for concurrency (e.g., PHP-FPM with multiple workers, Octane, Swoole, ReactPHP, RoadRunner, FrankenPHP).
492 | 
493 | ### Future Plans
494 | 
495 | While this package focuses on the server implementation, future projects within the `php-mcp` organization may include client libraries and other utilities related to MCP in PHP.
496 | 
```

--------------------------------------------------------------------------------
/src/Transports/StreamableHttpServerTransport.php:
--------------------------------------------------------------------------------

```php
  1 | <?php
  2 | 
  3 | declare(strict_types=1);
  4 | 
  5 | namespace PhpMcp\Server\Transports;
  6 | 
  7 | use Evenement\EventEmitterTrait;
  8 | use PhpMcp\Server\Contracts\EventStoreInterface;
  9 | use PhpMcp\Server\Contracts\LoggerAwareInterface;
 10 | use PhpMcp\Server\Contracts\LoopAwareInterface;
 11 | use PhpMcp\Server\Contracts\ServerTransportInterface;
 12 | use PhpMcp\Server\Exception\McpServerException;
 13 | use PhpMcp\Server\Exception\TransportException;
 14 | use PhpMcp\Schema\JsonRpc\Message;
 15 | use PhpMcp\Schema\JsonRpc\BatchRequest;
 16 | use PhpMcp\Schema\JsonRpc\BatchResponse;
 17 | use PhpMcp\Schema\JsonRpc\Error;
 18 | use PhpMcp\Schema\JsonRpc\Parser;
 19 | use PhpMcp\Schema\JsonRpc\Request;
 20 | use PhpMcp\Schema\JsonRpc\Response;
 21 | use Psr\Http\Message\ServerRequestInterface;
 22 | use Psr\Log\LoggerInterface;
 23 | use Psr\Log\NullLogger;
 24 | use React\EventLoop\Loop;
 25 | use React\EventLoop\LoopInterface;
 26 | use React\Http\HttpServer;
 27 | use React\Http\Message\Response as HttpResponse;
 28 | use React\Promise\Deferred;
 29 | use React\Promise\PromiseInterface;
 30 | use React\Socket\SocketServer;
 31 | use React\Stream\ThroughStream;
 32 | use Throwable;
 33 | 
 34 | use function React\Promise\resolve;
 35 | use function React\Promise\reject;
 36 | 
 37 | class StreamableHttpServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface
 38 | {
 39 |     use EventEmitterTrait;
 40 | 
 41 |     protected LoggerInterface $logger;
 42 |     protected LoopInterface $loop;
 43 | 
 44 |     private ?SocketServer $socket = null;
 45 |     private ?HttpServer $http = null;
 46 |     private bool $listening = false;
 47 |     private bool $closing = false;
 48 | 
 49 |     private ?EventStoreInterface $eventStore;
 50 | 
 51 |     /**
 52 |      * Stores Deferred objects for POST requests awaiting a direct JSON response.
 53 |      * Keyed by a unique pendingRequestId.
 54 |      * @var array<string, Deferred>
 55 |      */
 56 |     private array $pendingRequests = [];
 57 | 
 58 |     /**
 59 |      * Stores active SSE streams.
 60 |      * Key: streamId
 61 |      * Value: ['stream' => ThroughStream, 'sessionId' => string, 'context' => array]
 62 |      * @var array<string, array{stream: ThroughStream, sessionId: string, context: array}>
 63 |      */
 64 |     private array $activeSseStreams = [];
 65 | 
 66 |     private ?ThroughStream $getStream = null;
 67 | 
 68 |     /**
 69 |      * @param bool $enableJsonResponse If true, the server will return JSON responses instead of starting an SSE stream.
 70 |      * @param bool $stateless If true, the server will not emit client_connected events.
 71 |      * @param EventStoreInterface $eventStore If provided, the server will replay events to the client.
 72 |      * @param array<callable(\Psr\Http\Message\ServerRequestInterface, callable): (\Psr\Http\Message\ResponseInterface|\React\Promise\PromiseInterface)> $middlewares Middlewares to be applied to the HTTP server.
 73 |      * This can be useful for simple request/response scenarios without streaming.
 74 |      */
 75 |     public function __construct(
 76 |         private readonly string $host = '127.0.0.1',
 77 |         private readonly int $port = 8080,
 78 |         private string $mcpPath = '/mcp',
 79 |         private ?array $sslContext = null,
 80 |         private readonly bool $enableJsonResponse = true,
 81 |         private readonly bool $stateless = false,
 82 |         ?EventStoreInterface $eventStore = null,
 83 |         private array $middlewares = []
 84 |     ) {
 85 |         $this->logger = new NullLogger();
 86 |         $this->loop = Loop::get();
 87 |         $this->mcpPath = '/' . trim($mcpPath, '/');
 88 |         $this->eventStore = $eventStore;
 89 | 
 90 |         foreach ($this->middlewares as $mw) {
 91 |             if (!is_callable($mw)) {
 92 |                 throw new \InvalidArgumentException('All provided middlewares must be callable.');
 93 |             }
 94 |         }
 95 |     }
 96 | 
 97 |     protected function generateId(): string
 98 |     {
 99 |         return bin2hex(random_bytes(16)); // 32 hex characters
100 |     }
101 | 
102 |     public function setLogger(LoggerInterface $logger): void
103 |     {
104 |         $this->logger = $logger;
105 |     }
106 | 
107 |     public function setLoop(LoopInterface $loop): void
108 |     {
109 |         $this->loop = $loop;
110 |     }
111 | 
112 |     public function listen(): void
113 |     {
114 |         if ($this->listening) {
115 |             throw new TransportException('StreamableHttp transport is already listening.');
116 |         }
117 | 
118 |         if ($this->closing) {
119 |             throw new TransportException('Cannot listen, transport is closing/closed.');
120 |         }
121 | 
122 |         $listenAddress = "{$this->host}:{$this->port}";
123 |         $protocol = $this->sslContext ? 'https' : 'http';
124 | 
125 |         try {
126 |             $this->socket = new SocketServer(
127 |                 $listenAddress,
128 |                 $this->sslContext ?? [],
129 |                 $this->loop
130 |             );
131 | 
132 |             $handlers = array_merge($this->middlewares, [$this->createRequestHandler()]);
133 |             $this->http = new HttpServer($this->loop, ...$handlers);
134 |             $this->http->listen($this->socket);
135 | 
136 |             $this->socket->on('error', function (Throwable $error) {
137 |                 $this->logger->error('Socket server error (StreamableHttp).', ['error' => $error->getMessage()]);
138 |                 $this->emit('error', [new TransportException("Socket server error: {$error->getMessage()}", 0, $error)]);
139 |                 $this->close();
140 |             });
141 | 
142 |             $this->logger->info("Server is up and listening on {$protocol}://{$listenAddress} 🚀");
143 |             $this->logger->info("MCP Endpoint: {$protocol}://{$listenAddress}{$this->mcpPath}");
144 | 
145 |             $this->listening = true;
146 |             $this->closing = false;
147 |             $this->emit('ready');
148 |         } catch (Throwable $e) {
149 |             $this->logger->error("Failed to start StreamableHttp listener on {$listenAddress}", ['exception' => $e]);
150 |             throw new TransportException("Failed to start StreamableHttp listener on {$listenAddress}: {$e->getMessage()}", 0, $e);
151 |         }
152 |     }
153 | 
154 |     private function createRequestHandler(): callable
155 |     {
156 |         return function (ServerRequestInterface $request) {
157 |             $path = $request->getUri()->getPath();
158 |             $method = $request->getMethod();
159 | 
160 |             $this->logger->debug("Request received", ['method' => $method, 'path' => $path, 'target' => $this->mcpPath]);
161 | 
162 |             if ($path !== $this->mcpPath) {
163 |                 $error = Error::forInvalidRequest("Not found: {$path}");
164 |                 return new HttpResponse(404, ['Content-Type' => 'application/json'], json_encode($error));
165 |             }
166 | 
167 |             $corsHeaders = [
168 |                 'Access-Control-Allow-Origin' => '*',
169 |                 'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS',
170 |                 'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization',
171 |             ];
172 | 
173 |             if ($method === 'OPTIONS') {
174 |                 return new HttpResponse(204, $corsHeaders);
175 |             }
176 | 
177 |             $addCors = function (HttpResponse $r) use ($corsHeaders) {
178 |                 foreach ($corsHeaders as $key => $value) {
179 |                     $r = $r->withAddedHeader($key, $value);
180 |                 }
181 |                 return $r;
182 |             };
183 | 
184 |             try {
185 |                 return match ($method) {
186 |                     'GET' => $this->handleGetRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))),
187 |                     'POST' => $this->handlePostRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))),
188 |                     'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))),
189 |                     default => $addCors($this->handleUnsupportedRequest($request)),
190 |                 };
191 |             } catch (Throwable $e) {
192 |                 return $addCors($this->handleRequestError($e, $request));
193 |             }
194 |         };
195 |     }
196 | 
197 |     private function handleGetRequest(ServerRequestInterface $request): PromiseInterface
198 |     {
199 |         if ($this->stateless) {
200 |             $error = Error::forInvalidRequest("GET requests (SSE streaming) are not supported in stateless mode.");
201 |             return resolve(new HttpResponse(405, ['Content-Type' => 'application/json'], json_encode($error)));
202 |         }
203 | 
204 |         $acceptHeader = $request->getHeaderLine('Accept');
205 |         if (!str_contains($acceptHeader, 'text/event-stream')) {
206 |             $error = Error::forInvalidRequest("Not Acceptable: Client must accept text/event-stream for GET requests.");
207 |             return resolve(new HttpResponse(406, ['Content-Type' => 'application/json'], json_encode($error)));
208 |         }
209 | 
210 |         $sessionId = $request->getHeaderLine('Mcp-Session-Id');
211 |         if (empty($sessionId)) {
212 |             $this->logger->warning("GET request without Mcp-Session-Id.");
213 |             $error = Error::forInvalidRequest("Mcp-Session-Id header required for GET requests.");
214 |             return resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
215 |         }
216 | 
217 |         $this->getStream = new ThroughStream();
218 | 
219 |         $this->getStream->on('close', function () use ($sessionId) {
220 |             $this->logger->debug("GET SSE stream closed.", ['sessionId' => $sessionId]);
221 |             $this->getStream = null;
222 |         });
223 | 
224 |         $this->getStream->on('error', function (Throwable $e) use ($sessionId) {
225 |             $this->logger->error("GET SSE stream error.", ['sessionId' => $sessionId, 'error' => $e->getMessage()]);
226 |             $this->getStream = null;
227 |         });
228 | 
229 |         $headers = [
230 |             'Content-Type' => 'text/event-stream',
231 |             'Cache-Control' => 'no-cache',
232 |             'Connection' => 'keep-alive',
233 |             'X-Accel-Buffering' => 'no',
234 |         ];
235 | 
236 |         $response = new HttpResponse(200, $headers, $this->getStream);
237 | 
238 |         if ($this->eventStore) {
239 |             $lastEventId = $request->getHeaderLine('Last-Event-ID');
240 |             $this->replayEvents($lastEventId, $this->getStream, $sessionId);
241 |         }
242 | 
243 |         return resolve($response);
244 |     }
245 | 
246 |     private function handlePostRequest(ServerRequestInterface $request): PromiseInterface
247 |     {
248 |         $deferred = new Deferred();
249 | 
250 |         $acceptHeader = $request->getHeaderLine('Accept');
251 |         if (!str_contains($acceptHeader, 'application/json') && !str_contains($acceptHeader, 'text/event-stream')) {
252 |             $error = Error::forInvalidRequest("Not Acceptable: Client must accept both application/json or text/event-stream");
253 |             $deferred->resolve(new HttpResponse(406, ['Content-Type' => 'application/json'], json_encode($error)));
254 |             return $deferred->promise();
255 |         }
256 | 
257 |         if (!str_contains($request->getHeaderLine('Content-Type'), 'application/json')) {
258 |             $error = Error::forInvalidRequest("Unsupported Media Type: Content-Type must be application/json");
259 |             $deferred->resolve(new HttpResponse(415, ['Content-Type' => 'application/json'], json_encode($error)));
260 |             return $deferred->promise();
261 |         }
262 | 
263 |         $body = $request->getBody()->getContents();
264 | 
265 |         if (empty($body)) {
266 |             $this->logger->warning("Received empty POST body");
267 |             $error = Error::forInvalidRequest("Empty request body.");
268 |             $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
269 |             return $deferred->promise();
270 |         }
271 | 
272 |         try {
273 |             $message = Parser::parse($body);
274 |         } catch (Throwable $e) {
275 |             $this->logger->error("Failed to parse MCP message from POST body", ['error' => $e->getMessage()]);
276 |             $error = Error::forParseError("Invalid JSON: " . $e->getMessage());
277 |             $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
278 |             return $deferred->promise();
279 |         }
280 | 
281 |         $isInitializeRequest = ($message instanceof Request && $message->method === 'initialize');
282 |         $sessionId = null;
283 | 
284 |         if ($this->stateless) {
285 |             $sessionId = $this->generateId();
286 |             $this->emit('client_connected', [$sessionId]);
287 |         } else {
288 |             if ($isInitializeRequest) {
289 |                 if ($request->hasHeader('Mcp-Session-Id')) {
290 |                     $this->logger->warning("Client sent Mcp-Session-Id with InitializeRequest. Ignoring.", ['clientSentId' => $request->getHeaderLine('Mcp-Session-Id')]);
291 |                     $error = Error::forInvalidRequest("Invalid request: Session already initialized. Mcp-Session-Id header not allowed with InitializeRequest.", $message->getId());
292 |                     $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
293 |                     return $deferred->promise();
294 |                 }
295 | 
296 |                 $sessionId = $this->generateId();
297 |                 $this->emit('client_connected', [$sessionId]);
298 |             } else {
299 |                 $sessionId = $request->getHeaderLine('Mcp-Session-Id');
300 | 
301 |                 if (empty($sessionId)) {
302 |                     $this->logger->warning("POST request without Mcp-Session-Id.");
303 |                     $error = Error::forInvalidRequest("Mcp-Session-Id header required for POST requests.", $message->getId());
304 |                     $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
305 |                     return $deferred->promise();
306 |                 }
307 |             }
308 |         }
309 | 
310 |         $context = [
311 |             'is_initialize_request' => $isInitializeRequest,
312 |         ];
313 | 
314 |         $nRequests = match (true) {
315 |             $message instanceof Request => 1,
316 |             $message instanceof BatchRequest => $message->nRequests(),
317 |             default => 0,
318 |         };
319 | 
320 |         if ($nRequests === 0) {
321 |             $deferred->resolve(new HttpResponse(202));
322 |             $context['type'] = 'post_202_sent';
323 |         } else {
324 |             if ($this->enableJsonResponse) {
325 |                 $pendingRequestId = $this->generateId();
326 |                 $this->pendingRequests[$pendingRequestId] = $deferred;
327 | 
328 |                 $timeoutTimer = $this->loop->addTimer(30, function () use ($pendingRequestId, $sessionId) {
329 |                     if (isset($this->pendingRequests[$pendingRequestId])) {
330 |                         $deferred = $this->pendingRequests[$pendingRequestId];
331 |                         unset($this->pendingRequests[$pendingRequestId]);
332 |                         $this->logger->warning("Timeout waiting for direct JSON response processing.", ['pending_request_id' => $pendingRequestId, 'session_id' => $sessionId]);
333 |                         $errorResponse = McpServerException::internalError("Request processing timed out.")->toJsonRpcError($pendingRequestId);
334 |                         $deferred->resolve(new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($errorResponse->toArray())));
335 |                     }
336 |                 });
337 | 
338 |                 $this->pendingRequests[$pendingRequestId]->promise()->finally(function () use ($timeoutTimer) {
339 |                     $this->loop->cancelTimer($timeoutTimer);
340 |                 });
341 | 
342 |                 $context['type'] = 'post_json';
343 |                 $context['pending_request_id'] = $pendingRequestId;
344 |             } else {
345 |                 $streamId = $this->generateId();
346 |                 $sseStream = new ThroughStream();
347 |                 $this->activeSseStreams[$streamId] = [
348 |                     'stream' => $sseStream,
349 |                     'sessionId' => $sessionId,
350 |                     'context' => ['nRequests' => $nRequests, 'nResponses' => 0]
351 |                 ];
352 | 
353 |                 $sseStream->on('close', function () use ($streamId) {
354 |                     $this->logger->info("POST SSE stream closed by client/server.", ['streamId' => $streamId, 'sessionId' => $this->activeSseStreams[$streamId]['sessionId']]);
355 |                     unset($this->activeSseStreams[$streamId]);
356 |                 });
357 |                 $sseStream->on('error', function (Throwable $e) use ($streamId) {
358 |                     $this->logger->error("POST SSE stream error.", ['streamId' => $streamId, 'sessionId' => $this->activeSseStreams[$streamId]['sessionId'], 'error' => $e->getMessage()]);
359 |                     unset($this->activeSseStreams[$streamId]);
360 |                 });
361 | 
362 |                 $headers = [
363 |                     'Content-Type' => 'text/event-stream',
364 |                     'Cache-Control' => 'no-cache',
365 |                     'Connection' => 'keep-alive',
366 |                     'X-Accel-Buffering' => 'no',
367 |                 ];
368 | 
369 |                 if (!empty($sessionId) && !$this->stateless) {
370 |                     $headers['Mcp-Session-Id'] = $sessionId;
371 |                 }
372 | 
373 |                 $deferred->resolve(new HttpResponse(200, $headers, $sseStream));
374 |                 $context['type'] = 'post_sse';
375 |                 $context['streamId'] = $streamId;
376 |                 $context['nRequests'] = $nRequests;
377 |             }
378 |         }
379 | 
380 |         $context['stateless'] = $this->stateless;
381 |         $context['request'] = $request;
382 | 
383 |         $this->loop->futureTick(function () use ($message, $sessionId, $context) {
384 |             $this->emit('message', [$message, $sessionId, $context]);
385 |         });
386 | 
387 |         return $deferred->promise();
388 |     }
389 | 
390 |     private function handleDeleteRequest(ServerRequestInterface $request): PromiseInterface
391 |     {
392 |         if ($this->stateless) {
393 |             return resolve(new HttpResponse(204));
394 |         }
395 | 
396 |         $sessionId = $request->getHeaderLine('Mcp-Session-Id');
397 |         if (empty($sessionId)) {
398 |             $this->logger->warning("DELETE request without Mcp-Session-Id.");
399 |             $error = Error::forInvalidRequest("Mcp-Session-Id header required for DELETE.");
400 |             return resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error)));
401 |         }
402 | 
403 |         $streamsToClose = [];
404 |         foreach ($this->activeSseStreams as $streamId => $streamInfo) {
405 |             if ($streamInfo['sessionId'] === $sessionId) {
406 |                 $streamsToClose[] = $streamId;
407 |             }
408 |         }
409 | 
410 |         foreach ($streamsToClose as $streamId) {
411 |             $this->activeSseStreams[$streamId]['stream']->end();
412 |             unset($this->activeSseStreams[$streamId]);
413 |         }
414 | 
415 |         if ($this->getStream !== null) {
416 |             $this->getStream->end();
417 |             $this->getStream = null;
418 |         }
419 | 
420 |         $this->emit('client_disconnected', [$sessionId, 'Session terminated by DELETE request']);
421 | 
422 |         return resolve(new HttpResponse(204));
423 |     }
424 | 
425 |     private function handleUnsupportedRequest(ServerRequestInterface $request): HttpResponse
426 |     {
427 |         $error = Error::forInvalidRequest("Method not allowed: {$request->getMethod()}");
428 |         $headers = [
429 |             'Content-Type' => 'application/json',
430 |             'Allow' => 'GET, POST, DELETE, OPTIONS',
431 |         ];
432 |         return new HttpResponse(405, $headers, json_encode($error));
433 |     }
434 | 
435 |     private function handleRequestError(Throwable $e, ServerRequestInterface $request): HttpResponse
436 |     {
437 |         $this->logger->error("Error processing HTTP request", [
438 |             'method' => $request->getMethod(),
439 |             'path' => $request->getUri()->getPath(),
440 |             'exception' => $e->getMessage()
441 |         ]);
442 | 
443 |         if ($e instanceof TransportException) {
444 |             $error = Error::forInternalError("Transport Error: " . $e->getMessage());
445 |             return new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($error));
446 |         }
447 | 
448 |         $error = Error::forInternalError("Internal Server Error during HTTP request processing.");
449 |         return new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($error));
450 |     }
451 | 
452 |     public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface
453 |     {
454 |         if ($this->closing) {
455 |             return reject(new TransportException('Transport is closing.'));
456 |         }
457 | 
458 |         $isInitializeResponse = ($context['is_initialize_request'] ?? false) && ($message instanceof Response);
459 | 
460 |         switch ($context['type'] ?? null) {
461 |             case 'post_202_sent':
462 |                 return resolve(null);
463 | 
464 |             case 'post_sse':
465 |                 $streamId = $context['streamId'];
466 |                 if (!isset($this->activeSseStreams[$streamId])) {
467 |                     $this->logger->error("SSE stream for POST not found.", ['streamId' => $streamId, 'sessionId' => $sessionId]);
468 |                     return reject(new TransportException("SSE stream {$streamId} not found for POST response."));
469 |                 }
470 | 
471 |                 $stream = $this->activeSseStreams[$streamId]['stream'];
472 |                 if (!$stream->isWritable()) {
473 |                     $this->logger->warning("SSE stream for POST is not writable.", ['streamId' => $streamId, 'sessionId' => $sessionId]);
474 |                     return reject(new TransportException("SSE stream {$streamId} for POST is not writable."));
475 |                 }
476 | 
477 |                 $sentCountThisCall = 0;
478 | 
479 |                 if ($message instanceof Response || $message instanceof Error) {
480 |                     $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
481 |                     $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null;
482 |                     $this->sendSseEventToStream($stream, $json, $eventId);
483 |                     $sentCountThisCall = 1;
484 |                 } elseif ($message instanceof BatchResponse) {
485 |                     foreach ($message->getAll() as $singleResponse) {
486 |                         $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
487 |                         $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null;
488 |                         $this->sendSseEventToStream($stream, $json, $eventId);
489 |                         $sentCountThisCall++;
490 |                     }
491 |                 }
492 | 
493 |                 if (isset($this->activeSseStreams[$streamId]['context'])) {
494 |                     $this->activeSseStreams[$streamId]['context']['nResponses'] += $sentCountThisCall;
495 |                     if ($this->activeSseStreams[$streamId]['context']['nResponses'] >= $this->activeSseStreams[$streamId]['context']['nRequests']) {
496 |                         $this->logger->info("All expected responses sent for POST SSE stream. Closing.", ['streamId' => $streamId, 'sessionId' => $sessionId]);
497 |                         $stream->end(); // Will trigger 'close' event.
498 | 
499 |                         if ($context['stateless'] ?? false) {
500 |                             $this->loop->futureTick(function () use ($sessionId) {
501 |                                 $this->emit('client_disconnected', [$sessionId, 'Stateless request completed']);
502 |                             });
503 |                         }
504 |                     }
505 |                 }
506 | 
507 |                 return resolve(null);
508 | 
509 |             case 'post_json':
510 |                 $pendingRequestId = $context['pending_request_id'];
511 |                 if (!isset($this->pendingRequests[$pendingRequestId])) {
512 |                     $this->logger->error("Pending direct JSON request not found.", ['pending_request_id' => $pendingRequestId, 'session_id' => $sessionId]);
513 |                     return reject(new TransportException("Pending request {$pendingRequestId} not found."));
514 |                 }
515 | 
516 |                 $deferred = $this->pendingRequests[$pendingRequestId];
517 |                 unset($this->pendingRequests[$pendingRequestId]);
518 | 
519 |                 $responseBody = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
520 |                 $headers = ['Content-Type' => 'application/json'];
521 |                 if ($isInitializeResponse && !$this->stateless) {
522 |                     $headers['Mcp-Session-Id'] = $sessionId;
523 |                 }
524 | 
525 |                 $statusCode = $context['status_code'] ?? 200;
526 |                 $deferred->resolve(new HttpResponse($statusCode, $headers, $responseBody . "\n"));
527 | 
528 |                 if ($context['stateless'] ?? false) {
529 |                     $this->loop->futureTick(function () use ($sessionId) {
530 |                         $this->emit('client_disconnected', [$sessionId, 'Stateless request completed']);
531 |                     });
532 |                 }
533 | 
534 |                 return resolve(null);
535 | 
536 |             default:
537 |                 if ($this->getStream === null) {
538 |                     $this->logger->error("GET SSE stream not found.", ['sessionId' => $sessionId]);
539 |                     return reject(new TransportException("GET SSE stream not found."));
540 |                 }
541 | 
542 |                 if (!$this->getStream->isWritable()) {
543 |                     $this->logger->warning("GET SSE stream is not writable.", ['sessionId' => $sessionId]);
544 |                     return reject(new TransportException("GET SSE stream not writable."));
545 |                 }
546 |                 if ($message instanceof Response || $message instanceof Error) {
547 |                     $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
548 |                     $eventId = $this->eventStore ? $this->eventStore->storeEvent('GET_STREAM', $json) : null;
549 |                     $this->sendSseEventToStream($this->getStream, $json, $eventId);
550 |                 } elseif ($message instanceof BatchResponse) {
551 |                     foreach ($message->getAll() as $singleResponse) {
552 |                         $json = json_encode($singleResponse, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
553 |                         $eventId = $this->eventStore ? $this->eventStore->storeEvent('GET_STREAM', $json) : null;
554 |                         $this->sendSseEventToStream($this->getStream, $json, $eventId);
555 |                     }
556 |                 }
557 |                 return resolve(null);
558 |         }
559 |     }
560 | 
561 |     private function replayEvents(string $lastEventId, ThroughStream $sseStream, string $sessionId): void
562 |     {
563 |         if (empty($lastEventId)) {
564 |             return;
565 |         }
566 | 
567 |         try {
568 |             $this->eventStore->replayEventsAfter(
569 |                 $lastEventId,
570 |                 function (string $replayedEventId, string $json) use ($sseStream) {
571 |                     $this->logger->debug("Replaying event", ['replayedEventId' => $replayedEventId]);
572 |                     $this->sendSseEventToStream($sseStream, $json, $replayedEventId);
573 |                 }
574 |             );
575 |         } catch (Throwable $e) {
576 |             $this->logger->error("Error during event replay.", ['sessionId' => $sessionId, 'exception' => $e]);
577 |         }
578 |     }
579 | 
580 |     private function sendSseEventToStream(ThroughStream $stream, string $data, ?string $eventId = null): bool
581 |     {
582 |         if (! $stream->isWritable()) {
583 |             return false;
584 |         }
585 | 
586 |         $frame = "event: message\n";
587 |         if ($eventId !== null) {
588 |             $frame .= "id: {$eventId}\n";
589 |         }
590 | 
591 |         $lines = explode("\n", $data);
592 |         foreach ($lines as $line) {
593 |             $frame .= "data: {$line}\n";
594 |         }
595 |         $frame .= "\n";
596 | 
597 |         return $stream->write($frame);
598 |     }
599 | 
600 |     public function close(): void
601 |     {
602 |         if ($this->closing) {
603 |             return;
604 |         }
605 | 
606 |         $this->closing = true;
607 |         $this->listening = false;
608 |         $this->logger->info('Closing transport...');
609 | 
610 |         if ($this->socket) {
611 |             $this->socket->close();
612 |             $this->socket = null;
613 |         }
614 | 
615 |         foreach ($this->activeSseStreams as $streamId => $streamInfo) {
616 |             if ($streamInfo['stream']->isWritable()) {
617 |                 $streamInfo['stream']->end();
618 |             }
619 |         }
620 | 
621 |         if ($this->getStream !== null) {
622 |             $this->getStream->end();
623 |             $this->getStream = null;
624 |         }
625 | 
626 |         foreach ($this->pendingRequests as $pendingRequestId => $deferred) {
627 |             $deferred->reject(new TransportException('Transport is closing.'));
628 |         }
629 | 
630 |         $this->activeSseStreams = [];
631 |         $this->pendingRequests = [];
632 | 
633 |         $this->emit('close', ['Transport closed.']);
634 |         $this->removeAllListeners();
635 |     }
636 | }
637 | 
```

--------------------------------------------------------------------------------
/src/Utils/SchemaGenerator.php:
--------------------------------------------------------------------------------

```php
  1 | <?php
  2 | 
  3 | namespace PhpMcp\Server\Utils;
  4 | 
  5 | use phpDocumentor\Reflection\DocBlock\Tags\Param;
  6 | use PhpMcp\Server\Attributes\Schema;
  7 | use PhpMcp\Server\Context;
  8 | use ReflectionEnum;
  9 | use ReflectionIntersectionType;
 10 | use ReflectionMethod;
 11 | use ReflectionNamedType;
 12 | use ReflectionParameter;
 13 | use ReflectionType;
 14 | use ReflectionUnionType;
 15 | use stdClass;
 16 | 
 17 | /**
 18 |  * Generates JSON Schema for method parameters with intelligent Schema attribute handling.
 19 |  *
 20 |  * Priority system:
 21 |  * 1. Schema attributes (method-level and parameter-level)
 22 |  * 2. Reflection type information
 23 |  * 3. DocBlock type information
 24 |  */
 25 | class SchemaGenerator
 26 | {
 27 |     private DocBlockParser $docBlockParser;
 28 | 
 29 |     public function __construct(DocBlockParser $docBlockParser)
 30 |     {
 31 |         $this->docBlockParser = $docBlockParser;
 32 |     }
 33 | 
 34 |     /**
 35 |      * Generates a JSON Schema object (as a PHP array) for a method's or function's parameters.
 36 |      */
 37 |     public function generate(\ReflectionMethod|\ReflectionFunction $reflection): array
 38 |     {
 39 |         $methodSchema = $this->extractMethodLevelSchema($reflection);
 40 | 
 41 |         if ($methodSchema && isset($methodSchema['definition'])) {
 42 |             return $methodSchema['definition'];
 43 |         }
 44 | 
 45 |         $parametersInfo = $this->parseParametersInfo($reflection);
 46 | 
 47 |         return $this->buildSchemaFromParameters($parametersInfo, $methodSchema);
 48 |     }
 49 | 
 50 |     /**
 51 |      * Extracts method-level or function-level Schema attribute.
 52 |      */
 53 |     private function extractMethodLevelSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array
 54 |     {
 55 |         $schemaAttrs = $reflection->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF);
 56 |         if (empty($schemaAttrs)) {
 57 |             return null;
 58 |         }
 59 | 
 60 |         $schemaAttr = $schemaAttrs[0]->newInstance();
 61 |         return $schemaAttr->toArray();
 62 |     }
 63 | 
 64 |     /**
 65 |      * Extracts parameter-level Schema attribute.
 66 |      */
 67 |     private function extractParameterLevelSchema(ReflectionParameter $parameter): array
 68 |     {
 69 |         $schemaAttrs = $parameter->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF);
 70 |         if (empty($schemaAttrs)) {
 71 |             return [];
 72 |         }
 73 | 
 74 |         $schemaAttr = $schemaAttrs[0]->newInstance();
 75 |         return $schemaAttr->toArray();
 76 |     }
 77 | 
 78 |     /**
 79 |      * Builds the final schema from parameter information and method-level schema.
 80 |      *
 81 |      * @param array<int, array{
 82 |      *     name: string,
 83 |      *     doc_block_tag: Param|null,
 84 |      *     reflection_param: ReflectionParameter,
 85 |      *     reflection_type_object: ReflectionType|null,
 86 |      *     type_string: string,
 87 |      *     description: string|null,
 88 |      *     required: bool,
 89 |      *     allows_null: bool,
 90 |      *     default_value: mixed|null,
 91 |      *     has_default: bool,
 92 |      *     is_variadic: bool,
 93 |      *     parameter_schema: array<string, mixed>
 94 |      * }> $parametersInfo
 95 |      *
 96 |      * @param array<string, mixed>|null $methodSchema
 97 |      *
 98 |      * @return array<string, mixed>
 99 |      */
100 |     private function buildSchemaFromParameters(array $parametersInfo, ?array $methodSchema): array
101 |     {
102 |         $schema = [
103 |             'type' => 'object',
104 |             'properties' => [],
105 |             'required' => [],
106 |         ];
107 | 
108 |         // Apply method-level schema as base
109 |         if ($methodSchema) {
110 |             $schema = array_merge($schema, $methodSchema);
111 |             if (!isset($schema['type'])) {
112 |                 $schema['type'] = 'object';
113 |             }
114 |             if (!isset($schema['properties'])) {
115 |                 $schema['properties'] = [];
116 |             }
117 |             if (!isset($schema['required'])) {
118 |                 $schema['required'] = [];
119 |             }
120 |         }
121 | 
122 |         foreach ($parametersInfo as $paramInfo) {
123 |             $paramName = $paramInfo['name'];
124 | 
125 |             $methodLevelParamSchema = $schema['properties'][$paramName] ?? null;
126 | 
127 |             $paramSchema = $this->buildParameterSchema($paramInfo, $methodLevelParamSchema);
128 | 
129 |             $schema['properties'][$paramName] = $paramSchema;
130 | 
131 |             if ($paramInfo['required'] && !in_array($paramName, $schema['required'])) {
132 |                 $schema['required'][] = $paramName;
133 |             } elseif (!$paramInfo['required'] && ($key = array_search($paramName, $schema['required'])) !== false) {
134 |                 unset($schema['required'][$key]);
135 |                 $schema['required'] = array_values($schema['required']); // Re-index
136 |             }
137 |         }
138 | 
139 |         // Clean up empty properties
140 |         if (empty($schema['properties'])) {
141 |             $schema['properties'] = new stdClass();
142 |         }
143 |         if (empty($schema['required'])) {
144 |             unset($schema['required']);
145 |         }
146 | 
147 |         return $schema;
148 |     }
149 | 
150 |     /**
151 |      * Builds the final schema for a single parameter by merging all three levels.
152 |      *
153 |      * @param array{
154 |      *     name: string,
155 |      *     doc_block_tag: Param|null,
156 |      *     reflection_param: ReflectionParameter,
157 |      *     reflection_type_object: ReflectionType|null,
158 |      *     type_string: string,
159 |      *     description: string|null,
160 |      *     required: bool,
161 |      *     allows_null: bool,
162 |      *     default_value: mixed|null,
163 |      *     has_default: bool,
164 |      *     is_variadic: bool,
165 |      *     parameter_schema: array<string, mixed>
166 |      * } $paramInfo
167 |      * @param array<string, mixed>|null $methodLevelParamSchema
168 |      */
169 |     private function buildParameterSchema(array $paramInfo, ?array $methodLevelParamSchema = null): array
170 |     {
171 |         if ($paramInfo['is_variadic']) {
172 |             return $this->buildVariadicParameterSchema($paramInfo);
173 |         }
174 | 
175 |         $inferredSchema = $this->buildInferredParameterSchema($paramInfo);
176 | 
177 |         // Method-level takes precedence over inferred schema
178 |         $mergedSchema = $inferredSchema;
179 |         if ($methodLevelParamSchema) {
180 |             $mergedSchema = $this->mergeSchemas($inferredSchema, $methodLevelParamSchema);
181 |         }
182 | 
183 |         // Parameter-level takes highest precedence
184 |         $parameterLevelSchema = $paramInfo['parameter_schema'];
185 |         if (!empty($parameterLevelSchema)) {
186 |             if (isset($parameterLevelSchema['definition']) && is_array($parameterLevelSchema['definition'])) {
187 |                 return $parameterLevelSchema['definition'];
188 |             }
189 | 
190 |             $mergedSchema = $this->mergeSchemas($mergedSchema, $parameterLevelSchema);
191 |         }
192 | 
193 |         return $mergedSchema;
194 |     }
195 | 
196 |     /**
197 |      * Merge two schemas where the dominant schema takes precedence over the recessive one.
198 |      *
199 |      * @param array $recessiveSchema The schema with lower precedence
200 |      * @param array $dominantSchema The schema with higher precedence
201 |      */
202 |     private function mergeSchemas(array $recessiveSchema, array $dominantSchema): array
203 |     {
204 |         $mergedSchema = array_merge($recessiveSchema, $dominantSchema);
205 | 
206 |         return $mergedSchema;
207 |     }
208 | 
209 |     /**
210 |      * Builds parameter schema from inferred type and docblock information only.
211 |      * Returns empty array for variadic parameters (handled separately).
212 |      */
213 |     private function buildInferredParameterSchema(array $paramInfo): array
214 |     {
215 |         $paramSchema = [];
216 | 
217 |         // Variadic parameters are handled separately
218 |         if ($paramInfo['is_variadic']) {
219 |             return [];
220 |         }
221 | 
222 |         // Infer JSON Schema types
223 |         $jsonTypes = $this->inferParameterTypes($paramInfo);
224 | 
225 |         if (count($jsonTypes) === 1) {
226 |             $paramSchema['type'] = $jsonTypes[0];
227 |         } elseif (count($jsonTypes) > 1) {
228 |             $paramSchema['type'] = $jsonTypes;
229 |         }
230 | 
231 |         // Add description from docblock
232 |         if ($paramInfo['description']) {
233 |             $paramSchema['description'] = $paramInfo['description'];
234 |         }
235 | 
236 |         // Add default value only if parameter actually has a default
237 |         if ($paramInfo['has_default']) {
238 |             $paramSchema['default'] = $paramInfo['default_value'];
239 |         }
240 | 
241 |         // Handle enums
242 |         $paramSchema = $this->applyEnumConstraints($paramSchema, $paramInfo);
243 | 
244 |         // Handle array items
245 |         $paramSchema = $this->applyArrayConstraints($paramSchema, $paramInfo);
246 | 
247 |         return $paramSchema;
248 |     }
249 | 
250 |     /**
251 |      * Builds schema for variadic parameters.
252 |      */
253 |     private function buildVariadicParameterSchema(array $paramInfo): array
254 |     {
255 |         $paramSchema = ['type' => 'array'];
256 | 
257 |         // Apply parameter-level Schema attributes first
258 |         if (!empty($paramInfo['parameter_schema'])) {
259 |             $paramSchema = array_merge($paramSchema, $paramInfo['parameter_schema']);
260 |             // Ensure type is always array for variadic
261 |             $paramSchema['type'] = 'array';
262 |         }
263 | 
264 |         if ($paramInfo['description']) {
265 |             $paramSchema['description'] = $paramInfo['description'];
266 |         }
267 | 
268 |         // If no items specified by Schema attribute, infer from type
269 |         if (!isset($paramSchema['items'])) {
270 |             $itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']);
271 |             $nonNullItemTypes = array_filter($itemJsonTypes, fn($t) => $t !== 'null');
272 | 
273 |             if (count($nonNullItemTypes) === 1) {
274 |                 $paramSchema['items'] = ['type' => $nonNullItemTypes[0]];
275 |             }
276 |         }
277 | 
278 |         return $paramSchema;
279 |     }
280 | 
281 |     /**
282 |      * Infers JSON Schema types for a parameter.
283 |      */
284 |     private function inferParameterTypes(array $paramInfo): array
285 |     {
286 |         $jsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']);
287 | 
288 |         if ($paramInfo['allows_null'] && strtolower($paramInfo['type_string']) !== 'mixed' && !in_array('null', $jsonTypes)) {
289 |             $jsonTypes[] = 'null';
290 |         }
291 | 
292 |         if (count($jsonTypes) > 1) {
293 |             // Sort but ensure null comes first for consistency
294 |             $nullIndex = array_search('null', $jsonTypes);
295 |             if ($nullIndex !== false) {
296 |                 unset($jsonTypes[$nullIndex]);
297 |                 sort($jsonTypes);
298 |                 array_unshift($jsonTypes, 'null');
299 |             } else {
300 |                 sort($jsonTypes);
301 |             }
302 |         }
303 | 
304 |         return $jsonTypes;
305 |     }
306 | 
307 |     /**
308 |      * Applies enum constraints to parameter schema.
309 |      */
310 |     private function applyEnumConstraints(array $paramSchema, array $paramInfo): array
311 |     {
312 |         $reflectionType = $paramInfo['reflection_type_object'];
313 | 
314 |         if (!($reflectionType instanceof ReflectionNamedType) || $reflectionType->isBuiltin() || !enum_exists($reflectionType->getName())) {
315 |             return $paramSchema;
316 |         }
317 | 
318 |         $enumClass = $reflectionType->getName();
319 |         $enumReflection = new ReflectionEnum($enumClass);
320 |         $backingTypeReflection = $enumReflection->getBackingType();
321 | 
322 |         if ($enumReflection->isBacked() && $backingTypeReflection instanceof ReflectionNamedType) {
323 |             $paramSchema['enum'] = array_column($enumClass::cases(), 'value');
324 |             $jsonBackingType = match ($backingTypeReflection->getName()) {
325 |                 'int' => 'integer',
326 |                 'string' => 'string',
327 |                 default => null,
328 |             };
329 | 
330 |             if ($jsonBackingType) {
331 |                 if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) {
332 |                     $paramSchema['type'] = ['null', $jsonBackingType];
333 |                 } else {
334 |                     $paramSchema['type'] = $jsonBackingType;
335 |                 }
336 |             }
337 |         } else {
338 |             // Non-backed enum - use names as enum values
339 |             $paramSchema['enum'] = array_column($enumClass::cases(), 'name');
340 |             if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) {
341 |                 $paramSchema['type'] = ['null', 'string'];
342 |             } else {
343 |                 $paramSchema['type'] = 'string';
344 |             }
345 |         }
346 | 
347 |         return $paramSchema;
348 |     }
349 | 
350 |     /**
351 |      * Applies array-specific constraints to parameter schema.
352 |      */
353 |     private function applyArrayConstraints(array $paramSchema, array $paramInfo): array
354 |     {
355 |         if (!isset($paramSchema['type'])) {
356 |             return $paramSchema;
357 |         }
358 | 
359 |         $typeString = $paramInfo['type_string'];
360 |         $allowsNull = $paramInfo['allows_null'];
361 | 
362 |         // Handle object-like arrays using array{} syntax
363 |         if (preg_match('/^array\s*{/i', $typeString)) {
364 |             $objectSchema = $this->inferArrayItemsType($typeString);
365 |             if (is_array($objectSchema) && isset($objectSchema['properties'])) {
366 |                 $paramSchema = array_merge($paramSchema, $objectSchema);
367 |                 $paramSchema['type'] = $allowsNull ? ['object', 'null'] : 'object';
368 |             }
369 |         }
370 |         // Handle regular arrays
371 |         elseif (in_array('array', $this->mapPhpTypeToJsonSchemaType($typeString))) {
372 |             $itemsType = $this->inferArrayItemsType($typeString);
373 |             if ($itemsType !== 'any') {
374 |                 if (is_string($itemsType)) {
375 |                     $paramSchema['items'] = ['type' => $itemsType];
376 |                 } else {
377 |                     if (!isset($itemsType['type']) && isset($itemsType['properties'])) {
378 |                         $itemsType = array_merge(['type' => 'object'], $itemsType);
379 |                     }
380 |                     $paramSchema['items'] = $itemsType;
381 |                 }
382 |             }
383 | 
384 |             if ($allowsNull) {
385 |                 $paramSchema['type'] = ['array', 'null'];
386 |                 sort($paramSchema['type']);
387 |             } else {
388 |                 $paramSchema['type'] = 'array';
389 |             }
390 |         }
391 | 
392 |         return $paramSchema;
393 |     }
394 | 
395 |     /**
396 |      * Parses detailed information about a method's parameters.
397 |      *
398 |      * @return array<int, array{
399 |      *     name: string,
400 |      *     doc_block_tag: Param|null,
401 |      *     reflection_param: ReflectionParameter,
402 |      *     reflection_type_object: ReflectionType|null,
403 |      *     type_string: string,
404 |      *     description: string|null,
405 |      *     required: bool,
406 |      *     allows_null: bool,
407 |      *     default_value: mixed|null,
408 |      *     has_default: bool,
409 |      *     is_variadic: bool,
410 |      *     parameter_schema: array<string, mixed>
411 |      * }>
412 |      */
413 |     private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $reflection): array
414 |     {
415 |         $docComment = $reflection->getDocComment() ?: null;
416 |         $docBlock = $this->docBlockParser->parseDocBlock($docComment);
417 |         $paramTags = $this->docBlockParser->getParamTags($docBlock);
418 |         $parametersInfo = [];
419 | 
420 |         foreach ($reflection->getParameters() as $rp) {
421 |             $paramName = $rp->getName();
422 |             $paramTag = $paramTags['$' . $paramName] ?? null;
423 | 
424 |             $reflectionType = $rp->getType();
425 | 
426 |             if ($reflectionType instanceof ReflectionNamedType && $reflectionType?->getName() === Context::class) {
427 |                 continue;
428 |             }
429 | 
430 |             $typeString = $this->getParameterTypeString($rp, $paramTag);
431 |             $description = $this->docBlockParser->getParamDescription($paramTag);
432 |             $hasDefault = $rp->isDefaultValueAvailable();
433 |             $defaultValue = $hasDefault ? $rp->getDefaultValue() : null;
434 |             $isVariadic = $rp->isVariadic();
435 | 
436 |             $parameterSchema = $this->extractParameterLevelSchema($rp);
437 | 
438 |             if ($defaultValue instanceof \BackedEnum) {
439 |                 $defaultValue = $defaultValue->value;
440 |             }
441 | 
442 |             if ($defaultValue instanceof \UnitEnum) {
443 |                 $defaultValue = $defaultValue->name;
444 |             }
445 | 
446 |             $allowsNull = false;
447 |             if ($reflectionType && $reflectionType->allowsNull()) {
448 |                 $allowsNull = true;
449 |             } elseif ($hasDefault && $defaultValue === null) {
450 |                 $allowsNull = true;
451 |             } elseif (str_contains($typeString, 'null') || strtolower($typeString) === 'mixed') {
452 |                 $allowsNull = true;
453 |             }
454 | 
455 |             $parametersInfo[] = [
456 |                 'name' => $paramName,
457 |                 'doc_block_tag' => $paramTag,
458 |                 'reflection_param' => $rp,
459 |                 'reflection_type_object' => $reflectionType,
460 |                 'type_string' => $typeString,
461 |                 'description' => $description,
462 |                 'required' => !$rp->isOptional(),
463 |                 'allows_null' => $allowsNull,
464 |                 'default_value' => $defaultValue,
465 |                 'has_default' => $hasDefault,
466 |                 'is_variadic' => $isVariadic,
467 |                 'parameter_schema' => $parameterSchema,
468 |             ];
469 |         }
470 | 
471 |         return $parametersInfo;
472 |     }
473 | 
474 |     /**
475 |      * Determines the type string for a parameter, prioritizing DocBlock.
476 |      */
477 |     private function getParameterTypeString(ReflectionParameter $rp, ?Param $paramTag): string
478 |     {
479 |         $docBlockType = $this->docBlockParser->getParamTypeString($paramTag);
480 |         $isDocBlockTypeGeneric = false;
481 | 
482 |         if ($docBlockType !== null) {
483 |             if (in_array(strtolower($docBlockType), ['mixed', 'unknown', ''])) {
484 |                 $isDocBlockTypeGeneric = true;
485 |             }
486 |         } else {
487 |             $isDocBlockTypeGeneric = true; // No tag or no type in tag implies generic
488 |         }
489 | 
490 |         $reflectionType = $rp->getType();
491 |         $reflectionTypeString = null;
492 |         if ($reflectionType) {
493 |             $reflectionTypeString = $this->getTypeStringFromReflection($reflectionType, $rp->allowsNull());
494 |         }
495 | 
496 |         // Prioritize Reflection if DocBlock type is generic AND Reflection provides a more specific type
497 |         if ($isDocBlockTypeGeneric && $reflectionTypeString !== null && $reflectionTypeString !== 'mixed') {
498 |             return $reflectionTypeString;
499 |         }
500 | 
501 |         // Otherwise, use the DocBlock type if it was valid and non-generic
502 |         if ($docBlockType !== null && !$isDocBlockTypeGeneric) {
503 |             // Consider if DocBlock adds nullability missing from reflection
504 |             if (stripos($docBlockType, 'null') !== false && $reflectionTypeString && stripos($reflectionTypeString, 'null') === false && !str_ends_with($reflectionTypeString, '|null')) {
505 |                 // If reflection didn't capture null, but docblock did, append |null (if not already mixed)
506 |                 if ($reflectionTypeString !== 'mixed') {
507 |                     return $reflectionTypeString . '|null';
508 |                 }
509 |             }
510 | 
511 |             return $docBlockType;
512 |         }
513 | 
514 |         // Fallback to Reflection type even if it was generic ('mixed')
515 |         if ($reflectionTypeString !== null) {
516 |             return $reflectionTypeString;
517 |         }
518 | 
519 |         // Default to 'mixed' if nothing else found
520 |         return 'mixed';
521 |     }
522 | 
523 |     /**
524 |      * Converts a ReflectionType object into a type string representation.
525 |      */
526 |     private function getTypeStringFromReflection(?ReflectionType $type, bool $nativeAllowsNull): string
527 |     {
528 |         if ($type === null) {
529 |             return 'mixed';
530 |         }
531 | 
532 |         $types = [];
533 |         if ($type instanceof ReflectionUnionType) {
534 |             foreach ($type->getTypes() as $innerType) {
535 |                 $types[] = $this->getTypeStringFromReflection($innerType, $innerType->allowsNull());
536 |             }
537 |             if ($nativeAllowsNull) {
538 |                 $types = array_filter($types, fn($t) => strtolower($t) !== 'null');
539 |             }
540 |             $typeString = implode('|', array_unique(array_filter($types)));
541 |         } elseif ($type instanceof ReflectionIntersectionType) {
542 |             foreach ($type->getTypes() as $innerType) {
543 |                 $types[] = $this->getTypeStringFromReflection($innerType, false);
544 |             }
545 |             $typeString = implode('&', array_unique(array_filter($types)));
546 |         } elseif ($type instanceof ReflectionNamedType) {
547 |             $typeString = $type->getName();
548 |         } else {
549 |             return 'mixed';
550 |         }
551 | 
552 |         $typeString = match (strtolower($typeString)) {
553 |             'bool' => 'boolean',
554 |             'int' => 'integer',
555 |             'float', 'double' => 'number',
556 |             'str' => 'string',
557 |             default => $typeString,
558 |         };
559 | 
560 |         $isNullable = $nativeAllowsNull;
561 |         if ($type instanceof ReflectionNamedType && $type->getName() === 'mixed') {
562 |             $isNullable = true;
563 |         }
564 | 
565 |         if ($type instanceof ReflectionUnionType && !$nativeAllowsNull) {
566 |             foreach ($type->getTypes() as $innerType) {
567 |                 if ($innerType instanceof ReflectionNamedType && strtolower($innerType->getName()) === 'null') {
568 |                     $isNullable = true;
569 |                     break;
570 |                 }
571 |             }
572 |         }
573 | 
574 |         if ($isNullable && $typeString !== 'mixed' && stripos($typeString, 'null') === false) {
575 |             if (!str_ends_with($typeString, '|null') && !str_ends_with($typeString, '&null')) {
576 |                 $typeString .= '|null';
577 |             }
578 |         }
579 | 
580 |         // Remove leading backslash from class names, but handle built-ins like 'int' or unions like 'int|string'
581 |         if (str_contains($typeString, '\\')) {
582 |             $parts = preg_split('/([|&])/', $typeString, -1, PREG_SPLIT_DELIM_CAPTURE);
583 |             $processedParts = array_map(fn($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts);
584 |             $typeString = implode('', $processedParts);
585 |         }
586 | 
587 |         return $typeString ?: 'mixed';
588 |     }
589 | 
590 |     /**
591 |      * Maps a PHP type string (potentially a union) to an array of JSON Schema type names.
592 |      */
593 |     private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array
594 |     {
595 |         $normalizedType = strtolower(trim($phpTypeString));
596 | 
597 |         // PRIORITY 1: Check for array{} syntax which should be treated as object
598 |         if (preg_match('/^array\s*{/i', $normalizedType)) {
599 |             return ['object'];
600 |         }
601 | 
602 |         // PRIORITY 2: Check for array syntax first (T[] or generics)
603 |         if (
604 |             str_contains($normalizedType, '[]') ||
605 |             preg_match('/^(array|list|iterable|collection)</i', $normalizedType)
606 |         ) {
607 |             return ['array'];
608 |         }
609 | 
610 |         // PRIORITY 3: Handle unions (recursive)
611 |         if (str_contains($normalizedType, '|')) {
612 |             $types = explode('|', $normalizedType);
613 |             $jsonTypes = [];
614 |             foreach ($types as $type) {
615 |                 $mapped = $this->mapPhpTypeToJsonSchemaType(trim($type));
616 |                 $jsonTypes = array_merge($jsonTypes, $mapped);
617 |             }
618 | 
619 |             return array_values(array_unique($jsonTypes));
620 |         }
621 | 
622 |         // PRIORITY 4: Handle simple built-in types
623 |         return match ($normalizedType) {
624 |             'string', 'scalar' => ['string'],
625 |             '?string' => ['null', 'string'],
626 |             'int', 'integer' => ['integer'],
627 |             '?int', '?integer' => ['null', 'integer'],
628 |             'float', 'double', 'number' => ['number'],
629 |             '?float', '?double', '?number' => ['null', 'number'],
630 |             'bool', 'boolean' => ['boolean'],
631 |             '?bool', '?boolean' => ['null', 'boolean'],
632 |             'array' => ['array'],
633 |             '?array' => ['null', 'array'],
634 |             'object', 'stdclass' => ['object'],
635 |             '?object', '?stdclass' => ['null', 'object'],
636 |             'null' => ['null'],
637 |             'resource', 'callable' => ['object'],
638 |             'mixed' => [],
639 |             'void', 'never' => [],
640 |             default => ['object'],
641 |         };
642 |     }
643 | 
644 |     /**
645 |      * Infers the 'items' schema type for an array based on DocBlock type hints.
646 |      */
647 |     private function inferArrayItemsType(string $phpTypeString): string|array
648 |     {
649 |         $normalizedType = trim($phpTypeString);
650 | 
651 |         // Case 1: Simple T[] syntax (e.g., string[], int[], bool[], etc.)
652 |         if (preg_match('/^(\\??)([\w\\\\]+)\\s*\\[\\]$/i', $normalizedType, $matches)) {
653 |             $itemType = strtolower($matches[2]);
654 |             return $this->mapSimpleTypeToJsonSchema($itemType);
655 |         }
656 | 
657 |         // Case 2: Generic array<T> syntax (e.g., array<string>, array<int>, etc.)
658 |         if (preg_match('/^(\\??)array\s*<\s*([\w\\\\|]+)\s*>$/i', $normalizedType, $matches)) {
659 |             $itemType = strtolower($matches[2]);
660 |             return $this->mapSimpleTypeToJsonSchema($itemType);
661 |         }
662 | 
663 |         // Case 3: Nested array<array<T>> syntax or T[][] syntax
664 |         if (
665 |             preg_match('/^(\\??)array\s*<\s*array\s*<\s*([\w\\\\|]+)\s*>\s*>$/i', $normalizedType, $matches) ||
666 |             preg_match('/^(\\??)([\w\\\\]+)\s*\[\]\[\]$/i', $normalizedType, $matches)
667 |         ) {
668 |             $innerType = $this->mapSimpleTypeToJsonSchema(isset($matches[2]) ? strtolower($matches[2]) : 'any');
669 |             // Return a schema for array with items being arrays
670 |             return [
671 |                 'type' => 'array',
672 |                 'items' => [
673 |                     'type' => $innerType
674 |                 ]
675 |             ];
676 |         }
677 | 
678 |         // Case 4: Object-like array syntax (e.g., array{name: string, age: int})
679 |         if (preg_match('/^(\\??)array\s*\{(.+)\}$/is', $normalizedType, $matches)) {
680 |             return $this->parseObjectLikeArray($matches[2]);
681 |         }
682 | 
683 |         return 'any';
684 |     }
685 | 
686 |     /**
687 |      * Parses object-like array syntax into a JSON Schema object
688 |      */
689 |     private function parseObjectLikeArray(string $propertiesStr): array
690 |     {
691 |         $properties = [];
692 |         $required = [];
693 | 
694 |         // Parse properties from the string, handling nested structures
695 |         $depth = 0;
696 |         $buffer = '';
697 | 
698 |         for ($i = 0; $i < strlen($propertiesStr); $i++) {
699 |             $char = $propertiesStr[$i];
700 | 
701 |             // Track nested braces
702 |             if ($char === '{') {
703 |                 $depth++;
704 |                 $buffer .= $char;
705 |             } elseif ($char === '}') {
706 |                 $depth--;
707 |                 $buffer .= $char;
708 |             }
709 |             // Property separator (comma)
710 |             elseif ($char === ',' && $depth === 0) {
711 |                 // Process the completed property
712 |                 $this->parsePropertyDefinition(trim($buffer), $properties, $required);
713 |                 $buffer = '';
714 |             } else {
715 |                 $buffer .= $char;
716 |             }
717 |         }
718 | 
719 |         // Process the last property
720 |         if (!empty($buffer)) {
721 |             $this->parsePropertyDefinition(trim($buffer), $properties, $required);
722 |         }
723 | 
724 |         if (!empty($properties)) {
725 |             return [
726 |                 'type' => 'object',
727 |                 'properties' => $properties,
728 |                 'required' => $required
729 |             ];
730 |         }
731 | 
732 |         return ['type' => 'object'];
733 |     }
734 | 
735 |     /**
736 |      * Parses a single property definition from an object-like array syntax
737 |      */
738 |     private function parsePropertyDefinition(string $propDefinition, array &$properties, array &$required): void
739 |     {
740 |         // Match property name and type
741 |         if (preg_match('/^([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s*:\s*(.+)$/i', $propDefinition, $matches)) {
742 |             $propName = $matches[1];
743 |             $propType = trim($matches[2]);
744 | 
745 |             // Add to required properties
746 |             $required[] = $propName;
747 | 
748 |             // Check for nested array{} syntax
749 |             if (preg_match('/^array\s*\{(.+)\}$/is', $propType, $nestedMatches)) {
750 |                 $nestedSchema = $this->parseObjectLikeArray($nestedMatches[1]);
751 |                 $properties[$propName] = $nestedSchema;
752 |             }
753 |             // Check for array<T> or T[] syntax
754 |             elseif (
755 |                 preg_match('/^array\s*<\s*([\w\\\\|]+)\s*>$/i', $propType, $arrayMatches) ||
756 |                 preg_match('/^([\w\\\\]+)\s*\[\]$/i', $propType, $arrayMatches)
757 |             ) {
758 |                 $itemType = $arrayMatches[1] ?? 'any';
759 |                 $properties[$propName] = [
760 |                     'type' => 'array',
761 |                     'items' => [
762 |                         'type' => $this->mapSimpleTypeToJsonSchema($itemType)
763 |                     ]
764 |                 ];
765 |             }
766 |             // Simple type
767 |             else {
768 |                 $properties[$propName] = ['type' => $this->mapSimpleTypeToJsonSchema($propType)];
769 |             }
770 |         }
771 |     }
772 | 
773 |     /**
774 |      * Helper method to map basic PHP types to JSON Schema types
775 |      */
776 |     private function mapSimpleTypeToJsonSchema(string $type): string
777 |     {
778 |         return match (strtolower($type)) {
779 |             'string' => 'string',
780 |             'int', 'integer' => 'integer',
781 |             'bool', 'boolean' => 'boolean',
782 |             'float', 'double', 'number' => 'number',
783 |             'array' => 'array',
784 |             'object', 'stdclass' => 'object',
785 |             default => in_array(strtolower($type), ['datetime', 'datetimeinterface']) ? 'string' : 'object',
786 |         };
787 |     }
788 | }
789 | 
```

--------------------------------------------------------------------------------
/tests/Unit/DispatcherTest.php:
--------------------------------------------------------------------------------

```php
  1 | <?php
  2 | 
  3 | namespace PhpMcp\Server\Tests\Unit;
  4 | 
  5 | use Mockery;
  6 | use Mockery\MockInterface;
  7 | use PhpMcp\Schema\ClientCapabilities;
  8 | use PhpMcp\Server\Context;
  9 | use PhpMcp\Server\Configuration;
 10 | use PhpMcp\Server\Contracts\CompletionProviderInterface;
 11 | use PhpMcp\Server\Contracts\SessionInterface;
 12 | use PhpMcp\Server\Dispatcher;
 13 | use PhpMcp\Server\Elements\RegisteredPrompt;
 14 | use PhpMcp\Server\Elements\RegisteredResource;
 15 | use PhpMcp\Server\Elements\RegisteredResourceTemplate;
 16 | use PhpMcp\Server\Elements\RegisteredTool;
 17 | use PhpMcp\Server\Exception\McpServerException;
 18 | use PhpMcp\Schema\Implementation;
 19 | use PhpMcp\Schema\JsonRpc\Notification as JsonRpcNotification;
 20 | use PhpMcp\Schema\JsonRpc\Request as JsonRpcRequest;
 21 | use PhpMcp\Schema\Prompt as PromptSchema;
 22 | use PhpMcp\Schema\PromptArgument;
 23 | use PhpMcp\Schema\Request\CallToolRequest;
 24 | use PhpMcp\Schema\Request\CompletionCompleteRequest;
 25 | use PhpMcp\Schema\Request\GetPromptRequest;
 26 | use PhpMcp\Schema\Request\InitializeRequest;
 27 | use PhpMcp\Schema\Request\ListToolsRequest;
 28 | use PhpMcp\Schema\Request\ReadResourceRequest;
 29 | use PhpMcp\Schema\Request\ResourceSubscribeRequest;
 30 | use PhpMcp\Schema\Request\SetLogLevelRequest;
 31 | use PhpMcp\Schema\Resource as ResourceSchema;
 32 | use PhpMcp\Schema\ResourceTemplate as ResourceTemplateSchema;
 33 | use PhpMcp\Schema\Result\CallToolResult;
 34 | use PhpMcp\Schema\Result\CompletionCompleteResult;
 35 | use PhpMcp\Schema\Result\EmptyResult;
 36 | use PhpMcp\Schema\Result\GetPromptResult;
 37 | use PhpMcp\Schema\Result\InitializeResult;
 38 | use PhpMcp\Schema\Result\ReadResourceResult;
 39 | use PhpMcp\Schema\ServerCapabilities;
 40 | use PhpMcp\Schema\Tool as ToolSchema;
 41 | use PhpMcp\Server\Registry;
 42 | use PhpMcp\Server\Session\SubscriptionManager;
 43 | use PhpMcp\Server\Utils\SchemaValidator;
 44 | use Psr\Container\ContainerInterface;
 45 | use Psr\Log\NullLogger;
 46 | use PhpMcp\Schema\Content\TextContent;
 47 | use PhpMcp\Schema\Content\PromptMessage;
 48 | use PhpMcp\Schema\Enum\LoggingLevel;
 49 | use PhpMcp\Schema\Enum\Role;
 50 | use PhpMcp\Schema\PromptReference;
 51 | use PhpMcp\Schema\Request\ListPromptsRequest;
 52 | use PhpMcp\Schema\Request\ListResourcesRequest;
 53 | use PhpMcp\Schema\Request\ListResourceTemplatesRequest;
 54 | use PhpMcp\Schema\ResourceReference;
 55 | use PhpMcp\Server\Protocol;
 56 | use PhpMcp\Server\Tests\Fixtures\Enums\StatusEnum;
 57 | use React\EventLoop\Loop;
 58 | 
 59 | const DISPATCHER_SESSION_ID = 'dispatcher-session-xyz';
 60 | const DISPATCHER_PAGINATION_LIMIT = 3;
 61 | 
 62 | beforeEach(function () {
 63 |     /** @var MockInterface&Configuration $configuration */
 64 |     $this->configuration = Mockery::mock(Configuration::class);
 65 |     /** @var MockInterface&Registry $registry */
 66 |     $this->registry = Mockery::mock(Registry::class);
 67 |     /** @var MockInterface&SubscriptionManager $subscriptionManager */
 68 |     $this->subscriptionManager = Mockery::mock(SubscriptionManager::class);
 69 |     /** @var MockInterface&SchemaValidator $schemaValidator */
 70 |     $this->schemaValidator = Mockery::mock(SchemaValidator::class);
 71 |     /** @var MockInterface&SessionInterface $session */
 72 |     $this->session = Mockery::mock(SessionInterface::class);
 73 |     /** @var MockInterface&ContainerInterface $container */
 74 |     $this->container = Mockery::mock(ContainerInterface::class);
 75 |     $this->context = new Context($this->session);
 76 | 
 77 |     $configuration = new Configuration(
 78 |         serverInfo: Implementation::make('DispatcherTestServer', '1.0'),
 79 |         capabilities: ServerCapabilities::make(),
 80 |         paginationLimit: DISPATCHER_PAGINATION_LIMIT,
 81 |         logger: new NullLogger(),
 82 |         loop: Loop::get(),
 83 |         cache: null,
 84 |         container: $this->container
 85 |     );
 86 | 
 87 |     $this->dispatcher = new Dispatcher(
 88 |         $configuration,
 89 |         $this->registry,
 90 |         $this->subscriptionManager,
 91 |         $this->schemaValidator
 92 |     );
 93 | });
 94 | 
 95 | it('routes to handleInitialize for initialize request', function () {
 96 |     $request = new JsonRpcRequest(
 97 |         jsonrpc: '2.0',
 98 |         id: 1,
 99 |         method: 'initialize',
100 |         params: [
101 |             'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION,
102 |             'clientInfo' => ['name' => 'client', 'version' => '1.0'],
103 |             'capabilities' => [],
104 |         ]
105 |     );
106 |     $this->session->shouldReceive('set')->with('client_info', Mockery::on(fn($value) => $value['name'] === 'client' && $value['version'] === '1.0'))->once();
107 |     $this->session->shouldReceive('set')->with('protocol_version', Protocol::LATEST_PROTOCOL_VERSION)->once();
108 | 
109 |     $result = $this->dispatcher->handleRequest($request, $this->context);
110 |     expect($result)->toBeInstanceOf(InitializeResult::class);
111 |     expect($result->protocolVersion)->toBe(Protocol::LATEST_PROTOCOL_VERSION);
112 |     expect($result->serverInfo->name)->toBe('DispatcherTestServer');
113 | });
114 | 
115 | it('routes to handlePing for ping request', function () {
116 |     $request = new JsonRpcRequest('2.0', 'id1', 'ping', []);
117 |     $result = $this->dispatcher->handleRequest($request, $this->context);
118 |     expect($result)->toBeInstanceOf(EmptyResult::class);
119 | });
120 | 
121 | it('throws MethodNotFound for unknown request method', function () {
122 |     $rawRequest = new JsonRpcRequest('2.0', 'id1', 'unknown/method', []);
123 |     $this->dispatcher->handleRequest($rawRequest, $this->context);
124 | })->throws(McpServerException::class, "Method 'unknown/method' not found.");
125 | 
126 | it('routes to handleNotificationInitialized for initialized notification', function () {
127 |     $notification = new JsonRpcNotification('2.0', 'notifications/initialized', []);
128 |     $this->session->shouldReceive('set')->with('initialized', true)->once();
129 |     $this->dispatcher->handleNotification($notification, $this->session);
130 | });
131 | 
132 | it('does nothing for unknown notification method', function () {
133 |     $rawNotification = new JsonRpcNotification('2.0', 'unknown/notification', []);
134 |     $this->session->shouldNotReceive('set');
135 |     $this->dispatcher->handleNotification($rawNotification, $this->session);
136 | });
137 | 
138 | 
139 | it('can handle initialize request', function () {
140 |     $clientInfo = Implementation::make('TestClient', '0.9.9');
141 |     $request = InitializeRequest::make(1, Protocol::LATEST_PROTOCOL_VERSION, ClientCapabilities::make(), $clientInfo, []);
142 |     $this->session->shouldReceive('set')->with('client_info', $clientInfo->toArray())->once();
143 |     $this->session->shouldReceive('set')->with('protocol_version', Protocol::LATEST_PROTOCOL_VERSION)->once();
144 | 
145 |     $result = $this->dispatcher->handleInitialize($request, $this->session);
146 |     expect($result->protocolVersion)->toBe(Protocol::LATEST_PROTOCOL_VERSION);
147 |     expect($result->serverInfo->name)->toBe('DispatcherTestServer');
148 |     expect($result->capabilities)->toBeInstanceOf(ServerCapabilities::class);
149 | });
150 | 
151 | it('can handle initialize request with older supported protocol version', function () {
152 |     $clientInfo = Implementation::make('TestClient', '0.9.9');
153 |     $clientRequestedVersion = '2024-11-05';
154 |     $request = InitializeRequest::make(1, $clientRequestedVersion, ClientCapabilities::make(), $clientInfo, []);
155 |     $this->session->shouldReceive('set')->with('client_info', $clientInfo->toArray())->once();
156 |     $this->session->shouldReceive('set')->with('protocol_version', $clientRequestedVersion)->once();
157 | 
158 |     $result = $this->dispatcher->handleInitialize($request, $this->session);
159 |     expect($result->protocolVersion)->toBe($clientRequestedVersion);
160 |     expect($result->serverInfo->name)->toBe('DispatcherTestServer');
161 |     expect($result->capabilities)->toBeInstanceOf(ServerCapabilities::class);
162 | });
163 | 
164 | it('can handle initialize request with unsupported protocol version', function () {
165 |     $clientInfo = Implementation::make('TestClient', '0.9.9');
166 |     $unsupportedVersion = '1999-01-01';
167 |     $request = InitializeRequest::make(1, $unsupportedVersion, ClientCapabilities::make(), $clientInfo, []);
168 |     $this->session->shouldReceive('set')->with('client_info', $clientInfo->toArray())->once();
169 |     $this->session->shouldReceive('set')->with('protocol_version', Protocol::LATEST_PROTOCOL_VERSION)->once();
170 | 
171 |     $result = $this->dispatcher->handleInitialize($request, $this->session);
172 |     expect($result->protocolVersion)->toBe(Protocol::LATEST_PROTOCOL_VERSION);
173 |     expect($result->serverInfo->name)->toBe('DispatcherTestServer');
174 |     expect($result->capabilities)->toBeInstanceOf(ServerCapabilities::class);
175 | });
176 | 
177 | it('can handle tool list request and return paginated tools', function () {
178 |     $toolSchemas = [
179 |         ToolSchema::make('tool1', ['type' => 'object', 'properties' => []]),
180 |         ToolSchema::make('tool2', ['type' => 'object', 'properties' => []]),
181 |         ToolSchema::make('tool3', ['type' => 'object', 'properties' => []]),
182 |         ToolSchema::make('tool4', ['type' => 'object', 'properties' => []]),
183 |     ];
184 |     $this->registry->shouldReceive('getTools')->andReturn($toolSchemas);
185 | 
186 |     $request = ListToolsRequest::make(1);
187 |     $result = $this->dispatcher->handleToolList($request);
188 |     expect($result->tools)->toHaveCount(DISPATCHER_PAGINATION_LIMIT);
189 |     expect($result->tools[0]->name)->toBe('tool1');
190 |     expect($result->nextCursor)->toBeString();
191 | 
192 |     $nextCursor = $result->nextCursor;
193 |     $requestPage2 = ListToolsRequest::make(2, $nextCursor);
194 |     $resultPage2 = $this->dispatcher->handleToolList($requestPage2);
195 |     expect($resultPage2->tools)->toHaveCount(count($toolSchemas) - DISPATCHER_PAGINATION_LIMIT);
196 |     expect($resultPage2->tools[0]->name)->toBe('tool4');
197 |     expect($resultPage2->nextCursor)->toBeNull();
198 | });
199 | 
200 | it('can handle tool call request and return result', function () {
201 |     $toolName = 'my-calculator';
202 |     $args = ['a' => 10, 'b' => 5];
203 |     $toolSchema = ToolSchema::make($toolName, ['type' => 'object', 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']]]);
204 |     $registeredToolMock = Mockery::mock(RegisteredTool::class, [$toolSchema, 'MyToolHandler', 'handleTool', false]);
205 | 
206 |     $this->registry->shouldReceive('getTool')->with($toolName)->andReturn($registeredToolMock);
207 |     $this->schemaValidator->shouldReceive('validateAgainstJsonSchema')->with($args, $toolSchema->inputSchema)->andReturn([]); // No validation errors
208 |     $registeredToolMock->shouldReceive('call')->with($this->container, $args, $this->context)->andReturn([TextContent::make("Result: 15")]);
209 | 
210 |     $request = CallToolRequest::make(1, $toolName, $args);
211 |     $result = $this->dispatcher->handleToolCall($request, $this->context);
212 | 
213 |     expect($result)->toBeInstanceOf(CallToolResult::class);
214 |     expect($result->content[0]->text)->toBe("Result: 15");
215 |     expect($result->isError)->toBeFalse();
216 | });
217 | 
218 | it('can handle tool call request and throw exception if tool not found', function () {
219 |     $this->registry->shouldReceive('getTool')->with('unknown-tool')->andReturn(null);
220 |     $request = CallToolRequest::make(1, 'unknown-tool', []);
221 |     $this->dispatcher->handleToolCall($request, $this->context);
222 | })->throws(McpServerException::class, "Tool 'unknown-tool' not found.");
223 | 
224 | it('can handle tool call request and throw exception if argument validation fails', function () {
225 |     $toolName = 'strict-tool';
226 |     $args = ['param' => 'wrong_type'];
227 |     $toolSchema = ToolSchema::make($toolName, ['type' => 'object', 'properties' => ['param' => ['type' => 'integer']]]);
228 |     $registeredToolMock = Mockery::mock(RegisteredTool::class, [$toolSchema, 'MyToolHandler', 'handleTool', false]);
229 | 
230 |     $this->registry->shouldReceive('getTool')->with($toolName)->andReturn($registeredToolMock);
231 |     $validationErrors = [['pointer' => '/param', 'keyword' => 'type', 'message' => 'Expected integer']];
232 |     $this->schemaValidator->shouldReceive('validateAgainstJsonSchema')->with($args, $toolSchema->inputSchema)->andReturn($validationErrors);
233 | 
234 |     $request = CallToolRequest::make(1, $toolName, $args);
235 |     try {
236 |         $this->dispatcher->handleToolCall($request, $this->context);
237 |     } catch (McpServerException $e) {
238 |         expect($e->getMessage())->toContain("Invalid parameters for tool 'strict-tool'");
239 |         expect($e->getData()['validation_errors'])->toBeArray();
240 |     }
241 | });
242 | 
243 | it('can handle tool call request and return error if tool execution throws exception', function () {
244 |     $toolName = 'failing-tool';
245 |     $toolSchema = ToolSchema::make($toolName, ['type' => 'object', 'properties' => []]);
246 |     $registeredToolMock = Mockery::mock(RegisteredTool::class, [$toolSchema, 'MyToolHandler', 'handleTool', false]);
247 | 
248 |     $this->registry->shouldReceive('getTool')->with($toolName)->andReturn($registeredToolMock);
249 |     $this->schemaValidator->shouldReceive('validateAgainstJsonSchema')->andReturn([]);
250 |     $registeredToolMock->shouldReceive('call')->andThrow(new \RuntimeException("Tool crashed!"));
251 | 
252 |     $request = CallToolRequest::make(1, $toolName, []);
253 |     $result = $this->dispatcher->handleToolCall($request, $this->context);
254 | 
255 |     expect($result->isError)->toBeTrue();
256 |     expect($result->content[0]->text)->toBe("Tool execution failed: Tool crashed!");
257 | });
258 | 
259 | it('can handle tool call request and return error if result formatting fails', function () {
260 |     $toolName = 'bad-result-tool';
261 |     $toolSchema = ToolSchema::make($toolName, ['type' => 'object', 'properties' => []]);
262 |     $registeredToolMock = Mockery::mock(RegisteredTool::class, [$toolSchema, 'MyToolHandler', 'handleTool', false]);
263 | 
264 |     $this->registry->shouldReceive('getTool')->with($toolName)->andReturn($registeredToolMock);
265 |     $this->schemaValidator->shouldReceive('validateAgainstJsonSchema')->andReturn([]);
266 |     $registeredToolMock->shouldReceive('call')->andThrow(new \JsonException("Unencodable."));
267 | 
268 | 
269 |     $request = CallToolRequest::make(1, $toolName, []);
270 |     $result = $this->dispatcher->handleToolCall($request, $this->context);
271 | 
272 |     expect($result->isError)->toBeTrue();
273 |     expect($result->content[0]->text)->toBe("Failed to serialize tool result: Unencodable.");
274 | });
275 | 
276 | it('can handle resources list request and return paginated resources', function () {
277 |     $resourceSchemas = [
278 |         ResourceSchema::make('res://1', 'Resource1'),
279 |         ResourceSchema::make('res://2', 'Resource2'),
280 |         ResourceSchema::make('res://3', 'Resource3'),
281 |         ResourceSchema::make('res://4', 'Resource4'),
282 |         ResourceSchema::make('res://5', 'Resource5')
283 |     ];
284 |     $this->registry->shouldReceive('getResources')->andReturn($resourceSchemas);
285 | 
286 |     $requestP1 = ListResourcesRequest::make(1);
287 |     $resultP1 = $this->dispatcher->handleResourcesList($requestP1);
288 |     expect($resultP1->resources)->toHaveCount(DISPATCHER_PAGINATION_LIMIT);
289 |     expect(array_map(fn($r) => $r->name, $resultP1->resources))->toEqual(['Resource1', 'Resource2', 'Resource3']);
290 |     expect($resultP1->nextCursor)->toBe(base64_encode('offset=3'));
291 | 
292 |     // Page 2
293 |     $requestP2 = ListResourcesRequest::make(2, $resultP1->nextCursor);
294 |     $resultP2 = $this->dispatcher->handleResourcesList($requestP2);
295 |     expect($resultP2->resources)->toHaveCount(2);
296 |     expect(array_map(fn($r) => $r->name, $resultP2->resources))->toEqual(['Resource4', 'Resource5']);
297 |     expect($resultP2->nextCursor)->toBeNull();
298 | });
299 | 
300 | it('can handle resources list request and return empty if registry has no resources', function () {
301 |     $this->registry->shouldReceive('getResources')->andReturn([]);
302 |     $request = ListResourcesRequest::make(1);
303 |     $result = $this->dispatcher->handleResourcesList($request);
304 |     expect($result->resources)->toBeEmpty();
305 |     expect($result->nextCursor)->toBeNull();
306 | });
307 | 
308 | it('can handle resource template list request and return paginated templates', function () {
309 |     $templateSchemas = [
310 |         ResourceTemplateSchema::make('tpl://{id}/1', 'Template1'),
311 |         ResourceTemplateSchema::make('tpl://{id}/2', 'Template2'),
312 |         ResourceTemplateSchema::make('tpl://{id}/3', 'Template3'),
313 |         ResourceTemplateSchema::make('tpl://{id}/4', 'Template4'),
314 |     ];
315 |     $this->registry->shouldReceive('getResourceTemplates')->andReturn($templateSchemas);
316 | 
317 |     // Page 1
318 |     $requestP1 = ListResourceTemplatesRequest::make(1);
319 |     $resultP1 = $this->dispatcher->handleResourceTemplateList($requestP1);
320 |     expect($resultP1->resourceTemplates)->toHaveCount(DISPATCHER_PAGINATION_LIMIT);
321 |     expect(array_map(fn($rt) => $rt->name, $resultP1->resourceTemplates))->toEqual(['Template1', 'Template2', 'Template3']);
322 |     expect($resultP1->nextCursor)->toBe(base64_encode('offset=3'));
323 | 
324 |     // Page 2
325 |     $requestP2 = ListResourceTemplatesRequest::make(2, $resultP1->nextCursor);
326 |     $resultP2 = $this->dispatcher->handleResourceTemplateList($requestP2);
327 |     expect($resultP2->resourceTemplates)->toHaveCount(1);
328 |     expect(array_map(fn($rt) => $rt->name, $resultP2->resourceTemplates))->toEqual(['Template4']);
329 |     expect($resultP2->nextCursor)->toBeNull();
330 | });
331 | 
332 | it('can handle resource read request and return resource contents', function () {
333 |     $uri = 'file://data.txt';
334 |     $resourceSchema = ResourceSchema::make($uri, 'file_resource');
335 |     $registeredResourceMock = Mockery::mock(RegisteredResource::class, [$resourceSchema, ['MyResourceHandler', 'read'], false]);
336 |     $resourceContents = [TextContent::make('File content')];
337 | 
338 |     $this->registry->shouldReceive('getResource')->with($uri)->andReturn($registeredResourceMock);
339 |     $registeredResourceMock->shouldReceive('read')->with($this->container, $uri, $this->context)->andReturn($resourceContents);
340 | 
341 |     $request = ReadResourceRequest::make(1, $uri);
342 |     $result = $this->dispatcher->handleResourceRead($request, $this->context);
343 | 
344 |     expect($result)->toBeInstanceOf(ReadResourceResult::class);
345 |     expect($result->contents)->toEqual($resourceContents);
346 | });
347 | 
348 | it('can handle resource read request and throw exception if resource not found', function () {
349 |     $this->registry->shouldReceive('getResource')->with('unknown://uri')->andReturn(null);
350 |     $request = ReadResourceRequest::make(1, 'unknown://uri');
351 |     $this->dispatcher->handleResourceRead($request, $this->context);
352 | })->throws(McpServerException::class, "Resource URI 'unknown://uri' not found.");
353 | 
354 | it('can handle resource subscribe request and call subscription manager', function () {
355 |     $uri = 'news://updates';
356 |     $this->session->shouldReceive('getId')->andReturn(DISPATCHER_SESSION_ID);
357 |     $this->subscriptionManager->shouldReceive('subscribe')->with(DISPATCHER_SESSION_ID, $uri)->once();
358 |     $request = ResourceSubscribeRequest::make(1, $uri);
359 |     $result = $this->dispatcher->handleResourceSubscribe($request, $this->session);
360 |     expect($result)->toBeInstanceOf(EmptyResult::class);
361 | });
362 | 
363 | it('can handle prompts list request and return paginated prompts', function () {
364 |     $promptSchemas = [
365 |         PromptSchema::make('promptA', '', []),
366 |         PromptSchema::make('promptB', '', []),
367 |         PromptSchema::make('promptC', '', []),
368 |         PromptSchema::make('promptD', '', []),
369 |         PromptSchema::make('promptE', '', []),
370 |         PromptSchema::make('promptF', '', []),
371 |     ]; // 6 prompts
372 |     $this->registry->shouldReceive('getPrompts')->andReturn($promptSchemas);
373 | 
374 |     // Page 1
375 |     $requestP1 = ListPromptsRequest::make(1);
376 |     $resultP1 = $this->dispatcher->handlePromptsList($requestP1);
377 |     expect($resultP1->prompts)->toHaveCount(DISPATCHER_PAGINATION_LIMIT);
378 |     expect(array_map(fn($p) => $p->name, $resultP1->prompts))->toEqual(['promptA', 'promptB', 'promptC']);
379 |     expect($resultP1->nextCursor)->toBe(base64_encode('offset=3'));
380 | 
381 |     // Page 2
382 |     $requestP2 = ListPromptsRequest::make(2, $resultP1->nextCursor);
383 |     $resultP2 = $this->dispatcher->handlePromptsList($requestP2);
384 |     expect($resultP2->prompts)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); // 3 more
385 |     expect(array_map(fn($p) => $p->name, $resultP2->prompts))->toEqual(['promptD', 'promptE', 'promptF']);
386 |     expect($resultP2->nextCursor)->toBeNull(); // End of list
387 | });
388 | 
389 | it('can handle prompt get request and return prompt messages', function () {
390 |     $promptName = 'daily-summary';
391 |     $args = ['date' => '2024-07-16'];
392 |     $promptSchema = PromptSchema::make($promptName, 'summary_prompt', [PromptArgument::make('date', required: true)]);
393 |     $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, ['MyPromptHandler', 'get'], false]);
394 |     $promptMessages = [PromptMessage::make(Role::User, TextContent::make("Summary for 2024-07-16"))];
395 | 
396 |     $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock);
397 |     $registeredPromptMock->shouldReceive('get')->with($this->container, $args, $this->context)->andReturn($promptMessages);
398 | 
399 |     $request = GetPromptRequest::make(1, $promptName, $args);
400 |     $result = $this->dispatcher->handlePromptGet($request, $this->context);
401 | 
402 |     expect($result)->toBeInstanceOf(GetPromptResult::class);
403 |     expect($result->messages)->toEqual($promptMessages);
404 |     expect($result->description)->toBe($promptSchema->description);
405 | });
406 | 
407 | it('can handle prompt get request and throw exception if required argument is missing', function () {
408 |     $promptName = 'needs-topic';
409 |     $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make('topic', required: true)]);
410 |     $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, ['MyPromptHandler', 'get'], false]);
411 |     $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock);
412 | 
413 |     $request = GetPromptRequest::make(1, $promptName, ['other_arg' => 'value']); // 'topic' is missing
414 |     $this->dispatcher->handlePromptGet($request, $this->context);
415 | })->throws(McpServerException::class, "Missing required argument 'topic' for prompt 'needs-topic'.");
416 | 
417 | 
418 | it('can handle logging set level request and set log level on session', function () {
419 |     $level = LoggingLevel::Debug;
420 |     $this->session->shouldReceive('getId')->andReturn(DISPATCHER_SESSION_ID);
421 |     $this->session->shouldReceive('set')->with('log_level', 'debug')->once();
422 | 
423 |     $request = SetLogLevelRequest::make(1, $level);
424 |     $result = $this->dispatcher->handleLoggingSetLevel($request, $this->session);
425 | 
426 |     expect($result)->toBeInstanceOf(EmptyResult::class);
427 | });
428 | 
429 | it('can handle completion complete request for prompt and delegate to provider', function () {
430 |     $promptName = 'my-completable-prompt';
431 |     $argName = 'tagName';
432 |     $currentValue = 'php';
433 |     $completions = ['php-mcp', 'php-fig'];
434 |     $mockCompletionProvider = Mockery::mock(CompletionProviderInterface::class);
435 |     $providerClass = get_class($mockCompletionProvider);
436 | 
437 |     $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make($argName)]);
438 |     $registeredPrompt = new RegisteredPrompt(
439 |         schema: $promptSchema,
440 |         handler: ['MyPromptHandler', 'get'],
441 |         isManual: false,
442 |         completionProviders: [$argName => $providerClass]
443 |     );
444 | 
445 |     $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPrompt);
446 |     $this->container->shouldReceive('get')->with($providerClass)->andReturn($mockCompletionProvider);
447 |     $mockCompletionProvider->shouldReceive('getCompletions')->with($currentValue, $this->session)->andReturn($completions);
448 | 
449 |     $request = CompletionCompleteRequest::make(1, PromptReference::make($promptName), ['name' => $argName, 'value' => $currentValue]);
450 |     $result = $this->dispatcher->handleCompletionComplete($request, $this->session);
451 | 
452 |     expect($result)->toBeInstanceOf(CompletionCompleteResult::class);
453 |     expect($result->values)->toEqual($completions);
454 |     expect($result->total)->toBe(count($completions));
455 |     expect($result->hasMore)->toBeFalse();
456 | });
457 | 
458 | it('can handle completion complete request for resource template and delegate to provider', function () {
459 |     $templateUri = 'item://{itemId}/category/{catName}';
460 |     $uriVarName = 'catName';
461 |     $currentValue = 'boo';
462 |     $completions = ['books', 'boomerangs'];
463 |     $mockCompletionProvider = Mockery::mock(CompletionProviderInterface::class);
464 |     $providerClass = get_class($mockCompletionProvider);
465 | 
466 |     $templateSchema = ResourceTemplateSchema::make($templateUri, 'item-template');
467 |     $registeredTemplate = new RegisteredResourceTemplate(
468 |         schema: $templateSchema,
469 |         handler: ['MyResourceTemplateHandler', 'get'],
470 |         isManual: false,
471 |         completionProviders: [$uriVarName => $providerClass]
472 |     );
473 | 
474 |     $this->registry->shouldReceive('getResourceTemplate')->with($templateUri)->andReturn($registeredTemplate);
475 |     $this->container->shouldReceive('get')->with($providerClass)->andReturn($mockCompletionProvider);
476 |     $mockCompletionProvider->shouldReceive('getCompletions')->with($currentValue, $this->session)->andReturn($completions);
477 | 
478 |     $request = CompletionCompleteRequest::make(1, ResourceReference::make($templateUri), ['name' => $uriVarName, 'value' => $currentValue]);
479 |     $result = $this->dispatcher->handleCompletionComplete($request, $this->session);
480 | 
481 |     expect($result->values)->toEqual($completions);
482 | });
483 | 
484 | it('can handle completion complete request and return empty if no provider', function () {
485 |     $promptName = 'no-provider-prompt';
486 |     $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make('arg')]);
487 |     $registeredPrompt = new RegisteredPrompt(
488 |         schema: $promptSchema,
489 |         handler: ['MyPromptHandler', 'get'],
490 |         isManual: false,
491 |         completionProviders: []
492 |     );
493 |     $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPrompt);
494 | 
495 |     $request = CompletionCompleteRequest::make(1, PromptReference::make($promptName), ['name' => 'arg', 'value' => '']);
496 |     $result = $this->dispatcher->handleCompletionComplete($request, $this->session);
497 |     expect($result->values)->toBeEmpty();
498 | });
499 | 
500 | it('can handle completion complete request with ListCompletionProvider instance', function () {
501 |     $promptName = 'list-completion-prompt';
502 |     $argName = 'category';
503 |     $currentValue = 'bl';
504 |     $expectedCompletions = ['blog'];
505 | 
506 |     $listProvider = new \PhpMcp\Server\Defaults\ListCompletionProvider(['blog', 'news', 'docs', 'api']);
507 | 
508 |     $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make($argName)]);
509 |     $registeredPrompt = new RegisteredPrompt(
510 |         schema: $promptSchema,
511 |         handler: ['MyPromptHandler', 'get'],
512 |         isManual: false,
513 |         completionProviders: [$argName => $listProvider]
514 |     );
515 | 
516 |     $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPrompt);
517 | 
518 |     $request = CompletionCompleteRequest::make(1, PromptReference::make($promptName), ['name' => $argName, 'value' => $currentValue]);
519 |     $result = $this->dispatcher->handleCompletionComplete($request, $this->session);
520 | 
521 |     expect($result->values)->toEqual($expectedCompletions);
522 |     expect($result->total)->toBe(1);
523 |     expect($result->hasMore)->toBeFalse();
524 | });
525 | 
526 | it('can handle completion complete request with EnumCompletionProvider instance', function () {
527 |     $promptName = 'enum-completion-prompt';
528 |     $argName = 'status';
529 |     $currentValue = 'a';
530 |     $expectedCompletions = ['archived'];
531 | 
532 |     $enumProvider = new \PhpMcp\Server\Defaults\EnumCompletionProvider(StatusEnum::class);
533 | 
534 |     $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make($argName)]);
535 |     $registeredPrompt = new RegisteredPrompt(
536 |         schema: $promptSchema,
537 |         handler: ['MyPromptHandler', 'get'],
538 |         isManual: false,
539 |         completionProviders: [$argName => $enumProvider]
540 |     );
541 | 
542 |     $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPrompt);
543 | 
544 |     $request = CompletionCompleteRequest::make(1, PromptReference::make($promptName), ['name' => $argName, 'value' => $currentValue]);
545 |     $result = $this->dispatcher->handleCompletionComplete($request, $this->session);
546 | 
547 |     expect($result->values)->toEqual($expectedCompletions);
548 |     expect($result->total)->toBe(1);
549 |     expect($result->hasMore)->toBeFalse();
550 | });
551 | 
552 | 
553 | it('decodeCursor handles null and invalid cursors', function () {
554 |     $method = new \ReflectionMethod(Dispatcher::class, 'decodeCursor');
555 |     $method->setAccessible(true);
556 | 
557 |     expect($method->invoke($this->dispatcher, null))->toBe(0);
558 |     expect($method->invoke($this->dispatcher, 'not_base64_$$$'))->toBe(0);
559 |     expect($method->invoke($this->dispatcher, base64_encode('invalid_format')))->toBe(0);
560 |     expect($method->invoke($this->dispatcher, base64_encode('offset=123')))->toBe(123);
561 | });
562 | 
563 | it('encodeNextCursor generates correct cursor or null', function () {
564 |     $method = new \ReflectionMethod(Dispatcher::class, 'encodeNextCursor');
565 |     $method->setAccessible(true);
566 |     $limit = DISPATCHER_PAGINATION_LIMIT;
567 | 
568 |     expect($method->invoke($this->dispatcher, 0, $limit, 10, $limit))->toBe(base64_encode('offset=3'));
569 |     expect($method->invoke($this->dispatcher, 0, $limit, $limit, $limit))->toBeNull();
570 |     expect($method->invoke($this->dispatcher, $limit, 2, $limit + 2 + 1, $limit))->toBe(base64_encode('offset=' . ($limit + 2)));
571 |     expect($method->invoke($this->dispatcher, $limit, 1, $limit + 1, $limit))->toBeNull();
572 |     expect($method->invoke($this->dispatcher, 0, 0, 10, $limit))->toBeNull();
573 | });
574 | 
```
Page 6/7FirstPrevNextLast