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