This is page 4 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/Fixtures/Utils/SchemaGeneratorFixture.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Fixtures\Utils; 4 | 5 | use PhpMcp\Server\Attributes\Schema; 6 | use PhpMcp\Server\Context; 7 | use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum; 8 | use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; 9 | use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum; 10 | use stdClass; 11 | 12 | /** 13 | * Comprehensive fixture for testing SchemaGenerator with various scenarios. 14 | */ 15 | class SchemaGeneratorFixture 16 | { 17 | // ===== BASIC SCENARIOS ===== 18 | 19 | public function noParams(): void 20 | { 21 | } 22 | 23 | /** 24 | * Type hints only - no Schema attributes. 25 | */ 26 | public function typeHintsOnly(string $name, int $age, bool $active, array $tags, ?stdClass $config = null): void 27 | { 28 | } 29 | 30 | /** 31 | * DocBlock types only - no PHP type hints, no Schema attributes. 32 | * @param string $username The username 33 | * @param int $count Number of items 34 | * @param bool $enabled Whether enabled 35 | * @param array $data Some data 36 | */ 37 | public function docBlockOnly($username, $count, $enabled, $data): void 38 | { 39 | } 40 | 41 | /** 42 | * Type hints with DocBlock descriptions. 43 | * @param string $email User email address 44 | * @param int $score User score 45 | * @param bool $verified Whether user is verified 46 | */ 47 | public function typeHintsWithDocBlock(string $email, int $score, bool $verified): void 48 | { 49 | } 50 | 51 | public function contextParameter(Context $context): void 52 | { 53 | } 54 | 55 | // ===== METHOD-LEVEL SCHEMA SCENARIOS ===== 56 | 57 | /** 58 | * Method-level Schema with complete definition. 59 | */ 60 | #[Schema(definition: [ 61 | 'type' => 'object', 62 | 'description' => 'Creates a custom filter with complete definition', 63 | 'properties' => [ 64 | 'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']], 65 | 'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']], 66 | 'value' => ['description' => 'Value to filter by, type depends on field and operator'] 67 | ], 68 | 'required' => ['field', 'operator', 'value'], 69 | 'if' => [ 70 | 'properties' => ['field' => ['const' => 'date']] 71 | ], 72 | 'then' => [ 73 | 'properties' => ['value' => ['type' => 'string', 'format' => 'date']] 74 | ] 75 | ])] 76 | public function methodLevelCompleteDefinition(string $field, string $operator, mixed $value): array 77 | { 78 | return compact('field', 'operator', 'value'); 79 | } 80 | 81 | /** 82 | * Method-level Schema defining properties. 83 | */ 84 | #[Schema( 85 | description: "Creates a new user with detailed information.", 86 | properties: [ 87 | 'username' => ['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'], 88 | 'email' => ['type' => 'string', 'format' => 'email'], 89 | 'age' => ['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'], 90 | 'isActive' => ['type' => 'boolean', 'default' => true] 91 | ], 92 | required: ['username', 'email'] 93 | )] 94 | public function methodLevelWithProperties(string $username, string $email, int $age, bool $isActive = true): array 95 | { 96 | return compact('username', 'email', 'age', 'isActive'); 97 | } 98 | 99 | /** 100 | * Method-level Schema for complex array argument. 101 | */ 102 | #[Schema( 103 | properties: [ 104 | 'profiles' => [ 105 | 'type' => 'array', 106 | 'description' => 'An array of user profiles to update.', 107 | 'minItems' => 1, 108 | 'items' => [ 109 | 'type' => 'object', 110 | 'properties' => [ 111 | 'id' => ['type' => 'integer'], 112 | 'data' => ['type' => 'object', 'additionalProperties' => true] 113 | ], 114 | 'required' => ['id', 'data'] 115 | ] 116 | ] 117 | ], 118 | required: ['profiles'] 119 | )] 120 | public function methodLevelArrayArgument(array $profiles): array 121 | { 122 | return ['updated_count' => count($profiles)]; 123 | } 124 | 125 | // ===== PARAMETER-LEVEL SCHEMA SCENARIOS ===== 126 | 127 | /** 128 | * Parameter-level Schema attributes only. 129 | */ 130 | public function parameterLevelOnly( 131 | #[Schema(description: "Recipient ID", pattern: "^user_")] 132 | string $recipientId, 133 | #[Schema(maxLength: 1024)] 134 | string $messageBody, 135 | #[Schema(type: 'integer', enum: [1, 2, 5])] 136 | int $priority = 1, 137 | #[Schema( 138 | type: 'object', 139 | properties: [ 140 | 'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']], 141 | 'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'] 142 | ], 143 | required: ['type'] 144 | )] 145 | ?array $notificationConfig = null 146 | ): array { 147 | return compact('recipientId', 'messageBody', 'priority', 'notificationConfig'); 148 | } 149 | 150 | /** 151 | * Parameter-level Schema with string constraints. 152 | */ 153 | public function parameterStringConstraints( 154 | #[Schema(format: 'email')] 155 | string $email, 156 | #[Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$')] 157 | string $password, 158 | string $regularString 159 | ): void { 160 | } 161 | 162 | /** 163 | * Parameter-level Schema with numeric constraints. 164 | */ 165 | public function parameterNumericConstraints( 166 | #[Schema(minimum: 18, maximum: 120)] 167 | int $age, 168 | #[Schema(minimum: 0, maximum: 5, exclusiveMaximum: true)] 169 | float $rating, 170 | #[Schema(multipleOf: 10)] 171 | int $count 172 | ): void { 173 | } 174 | 175 | /** 176 | * Parameter-level Schema with array constraints. 177 | */ 178 | public function parameterArrayConstraints( 179 | #[Schema(type: 'array', items: ['type' => 'string'], minItems: 1, uniqueItems: true)] 180 | array $tags, 181 | #[Schema(type: 'array', items: ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], minItems: 1, maxItems: 5)] 182 | array $scores 183 | ): void { 184 | } 185 | 186 | // ===== COMBINED SCENARIOS ===== 187 | 188 | /** 189 | * Method-level + Parameter-level Schema combination. 190 | * @param string $settingKey The key of the setting 191 | * @param mixed $newValue The new value for the setting 192 | */ 193 | #[Schema( 194 | properties: [ 195 | 'settingKey' => ['type' => 'string', 'description' => 'The key of the setting.'], 196 | 'newValue' => ['description' => 'The new value for the setting (any type).'] 197 | ], 198 | required: ['settingKey', 'newValue'] 199 | )] 200 | public function methodAndParameterLevel( 201 | string $settingKey, 202 | #[Schema(description: "The specific new boolean value.", type: 'boolean')] 203 | mixed $newValue 204 | ): array { 205 | return compact('settingKey', 'newValue'); 206 | } 207 | 208 | /** 209 | * Type hints + DocBlock + Parameter-level Schema. 210 | * @param string $username The user's name 211 | * @param int $priority Task priority level 212 | */ 213 | public function typeHintDocBlockAndParameterSchema( 214 | #[Schema(minLength: 3, pattern: '^[a-zA-Z0-9_]+$')] 215 | string $username, 216 | #[Schema(minimum: 1, maximum: 10)] 217 | int $priority 218 | ): void { 219 | } 220 | 221 | // ===== ENUM SCENARIOS ===== 222 | 223 | /** 224 | * Various enum parameter types. 225 | * @param BackedStringEnum $stringEnum Backed string enum 226 | * @param BackedIntEnum $intEnum Backed int enum 227 | * @param UnitEnum $unitEnum Unit enum 228 | */ 229 | public function enumParameters( 230 | BackedStringEnum $stringEnum, 231 | BackedIntEnum $intEnum, 232 | UnitEnum $unitEnum, 233 | ?BackedStringEnum $nullableEnum = null, 234 | BackedIntEnum $enumWithDefault = BackedIntEnum::First 235 | ): void { 236 | } 237 | 238 | // ===== ARRAY TYPE SCENARIOS ===== 239 | 240 | /** 241 | * Various array type scenarios. 242 | * @param array $genericArray Generic array 243 | * @param string[] $stringArray Array of strings 244 | * @param int[] $intArray Array of integers 245 | * @param array<string, mixed> $mixedMap Mixed array map 246 | * @param array{name: string, age: int} $objectLikeArray Object-like array 247 | * @param array{user: array{id: int, name: string}, items: int[]} $nestedObjectArray Nested object array 248 | */ 249 | public function arrayTypeScenarios( 250 | array $genericArray, 251 | array $stringArray, 252 | array $intArray, 253 | array $mixedMap, 254 | array $objectLikeArray, 255 | array $nestedObjectArray 256 | ): void { 257 | } 258 | 259 | // ===== NULLABLE AND OPTIONAL SCENARIOS ===== 260 | 261 | /** 262 | * Nullable and optional parameter scenarios. 263 | * @param string|null $nullableString Nullable string 264 | * @param int|null $nullableInt Nullable integer 265 | */ 266 | public function nullableAndOptional( 267 | ?string $nullableString, 268 | ?int $nullableInt = null, 269 | string $optionalString = 'default', 270 | bool $optionalBool = true, 271 | array $optionalArray = [] 272 | ): void { 273 | } 274 | 275 | // ===== UNION TYPE SCENARIOS ===== 276 | 277 | /** 278 | * Union type parameters. 279 | * @param string|int $stringOrInt String or integer 280 | * @param bool|string|null $multiUnion Bool, string or null 281 | */ 282 | public function unionTypes( 283 | string|int $stringOrInt, 284 | bool|string|null $multiUnion 285 | ): void { 286 | } 287 | 288 | // ===== VARIADIC SCENARIOS ===== 289 | 290 | /** 291 | * Variadic parameter scenarios. 292 | * @param string ...$items Variadic strings 293 | */ 294 | public function variadicStrings(string ...$items): void 295 | { 296 | } 297 | 298 | /** 299 | * Variadic with Schema constraints. 300 | * @param int ...$numbers Variadic integers 301 | */ 302 | public function variadicWithConstraints( 303 | #[Schema(items: ['type' => 'integer', 'minimum' => 0])] 304 | int ...$numbers 305 | ): void { 306 | } 307 | 308 | // ===== MIXED TYPE SCENARIOS ===== 309 | 310 | /** 311 | * Mixed type scenarios. 312 | * @param mixed $anyValue Any value 313 | * @param mixed $optionalAny Optional any value 314 | */ 315 | public function mixedTypes( 316 | mixed $anyValue, 317 | mixed $optionalAny = 'default' 318 | ): void { 319 | } 320 | 321 | // ===== COMPLEX NESTED SCENARIOS ===== 322 | 323 | /** 324 | * Complex nested Schema constraints. 325 | */ 326 | public function complexNestedSchema( 327 | #[Schema( 328 | type: 'object', 329 | properties: [ 330 | 'customer' => [ 331 | 'type' => 'object', 332 | 'properties' => [ 333 | 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], 334 | 'name' => ['type' => 'string', 'minLength' => 2], 335 | 'email' => ['type' => 'string', 'format' => 'email'] 336 | ], 337 | 'required' => ['id', 'name'] 338 | ], 339 | 'items' => [ 340 | 'type' => 'array', 341 | 'minItems' => 1, 342 | 'items' => [ 343 | 'type' => 'object', 344 | 'properties' => [ 345 | 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], 346 | 'quantity' => ['type' => 'integer', 'minimum' => 1], 347 | 'price' => ['type' => 'number', 'minimum' => 0] 348 | ], 349 | 'required' => ['product_id', 'quantity', 'price'] 350 | ] 351 | ], 352 | 'metadata' => [ 353 | 'type' => 'object', 354 | 'additionalProperties' => true 355 | ] 356 | ], 357 | required: ['customer', 'items'] 358 | )] 359 | array $order 360 | ): array { 361 | return ['order_id' => uniqid()]; 362 | } 363 | 364 | // ===== TYPE PRECEDENCE SCENARIOS ===== 365 | 366 | /** 367 | * Testing type precedence between PHP, DocBlock, and Schema. 368 | * @param integer $numericString DocBlock says integer despite string type hint 369 | * @param string $stringWithConstraints String with Schema constraints 370 | * @param array<string> $arrayWithItems Array with Schema item overrides 371 | */ 372 | public function typePrecedenceTest( 373 | string $numericString, 374 | #[Schema(format: 'email', minLength: 5)] 375 | string $stringWithConstraints, 376 | #[Schema(items: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])] 377 | array $arrayWithItems 378 | ): void { 379 | } 380 | 381 | // ===== ERROR EDGE CASES ===== 382 | 383 | /** 384 | * Method with no parameters but Schema description. 385 | */ 386 | #[Schema(description: "Gets server status. Takes no arguments.", properties: [])] 387 | public function noParamsWithSchema(): array 388 | { 389 | return ['status' => 'OK']; 390 | } 391 | 392 | /** 393 | * Parameter with Schema but inferred type. 394 | */ 395 | public function parameterSchemaInferredType( 396 | #[Schema(description: "Some parameter", minLength: 3)] 397 | $inferredParam 398 | ): void { 399 | } 400 | 401 | /** 402 | * Parameter with complete custom definition via #[Schema(definition: ...)] 403 | */ 404 | public function parameterWithRawDefinition( 405 | #[Schema(definition: [ 406 | 'description' => 'Custom-defined schema', 407 | 'type' => 'string', 408 | 'format' => 'uuid' 409 | ])] 410 | string $custom 411 | ): void { 412 | } 413 | } 414 | ``` -------------------------------------------------------------------------------- /src/Elements/RegisteredPrompt.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server\Elements; 6 | 7 | use PhpMcp\Schema\Content\AudioContent; 8 | use PhpMcp\Schema\Content\BlobResourceContents; 9 | use PhpMcp\Schema\Content\Content; 10 | use PhpMcp\Schema\Content\EmbeddedResource; 11 | use PhpMcp\Schema\Content\ImageContent; 12 | use PhpMcp\Schema\Prompt; 13 | use PhpMcp\Schema\Content\PromptMessage; 14 | use PhpMcp\Schema\Content\TextContent; 15 | use PhpMcp\Schema\Content\TextResourceContents; 16 | use PhpMcp\Schema\Enum\Role; 17 | use PhpMcp\Schema\Result\CompletionCompleteResult; 18 | use PhpMcp\Server\Context; 19 | use PhpMcp\Server\Contracts\CompletionProviderInterface; 20 | use PhpMcp\Server\Contracts\SessionInterface; 21 | use Psr\Container\ContainerInterface; 22 | use Throwable; 23 | 24 | class RegisteredPrompt extends RegisteredElement 25 | { 26 | public function __construct( 27 | public readonly Prompt $schema, 28 | callable|array|string $handler, 29 | bool $isManual = false, 30 | public readonly array $completionProviders = [] 31 | ) { 32 | parent::__construct($handler, $isManual); 33 | } 34 | 35 | public static function make(Prompt $schema, callable|array|string $handler, bool $isManual = false, array $completionProviders = []): self 36 | { 37 | return new self($schema, $handler, $isManual, $completionProviders); 38 | } 39 | 40 | /** 41 | * Gets the prompt messages. 42 | * 43 | * @param ContainerInterface $container 44 | * @param array $arguments 45 | * @return PromptMessage[] 46 | */ 47 | public function get(ContainerInterface $container, array $arguments, Context $context): array 48 | { 49 | $result = $this->handle($container, $arguments, $context); 50 | 51 | return $this->formatResult($result); 52 | } 53 | 54 | public function complete(ContainerInterface $container, string $argument, string $value, SessionInterface $session): CompletionCompleteResult 55 | { 56 | $providerClassOrInstance = $this->completionProviders[$argument] ?? null; 57 | if ($providerClassOrInstance === null) { 58 | return new CompletionCompleteResult([]); 59 | } 60 | 61 | if (is_string($providerClassOrInstance)) { 62 | if (! class_exists($providerClassOrInstance)) { 63 | throw new \RuntimeException("Completion provider class '{$providerClassOrInstance}' does not exist."); 64 | } 65 | 66 | $provider = $container->get($providerClassOrInstance); 67 | } else { 68 | $provider = $providerClassOrInstance; 69 | } 70 | 71 | $completions = $provider->getCompletions($value, $session); 72 | 73 | $total = count($completions); 74 | $hasMore = $total > 100; 75 | 76 | $pagedCompletions = array_slice($completions, 0, 100); 77 | 78 | return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); 79 | } 80 | 81 | /** 82 | * Formats the raw result of a prompt generator into an array of MCP PromptMessages. 83 | * 84 | * @param mixed $promptGenerationResult Expected: array of message structures. 85 | * @return PromptMessage[] Array of PromptMessage objects. 86 | * 87 | * @throws \RuntimeException If the result cannot be formatted. 88 | * @throws \JsonException If JSON encoding fails. 89 | */ 90 | protected function formatResult(mixed $promptGenerationResult): array 91 | { 92 | if ($promptGenerationResult instanceof PromptMessage) { 93 | return [$promptGenerationResult]; 94 | } 95 | 96 | if (! is_array($promptGenerationResult)) { 97 | throw new \RuntimeException('Prompt generator method must return an array of messages.'); 98 | } 99 | 100 | if (empty($promptGenerationResult)) { 101 | return []; 102 | } 103 | 104 | if (is_array($promptGenerationResult)) { 105 | $allArePromptMessages = true; 106 | $hasPromptMessages = false; 107 | 108 | foreach ($promptGenerationResult as $item) { 109 | if ($item instanceof PromptMessage) { 110 | $hasPromptMessages = true; 111 | } else { 112 | $allArePromptMessages = false; 113 | } 114 | } 115 | 116 | if ($allArePromptMessages && $hasPromptMessages) { 117 | return $promptGenerationResult; 118 | } 119 | 120 | if ($hasPromptMessages) { 121 | $result = []; 122 | foreach ($promptGenerationResult as $index => $item) { 123 | if ($item instanceof PromptMessage) { 124 | $result[] = $item; 125 | } else { 126 | $result = array_merge($result, $this->formatResult($item)); 127 | } 128 | } 129 | return $result; 130 | } 131 | 132 | if (! array_is_list($promptGenerationResult)) { 133 | if (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) { 134 | $result = []; 135 | if (isset($promptGenerationResult['user'])) { 136 | $userContent = $this->formatContent($promptGenerationResult['user']); 137 | $result[] = PromptMessage::make(Role::User, $userContent); 138 | } 139 | if (isset($promptGenerationResult['assistant'])) { 140 | $assistantContent = $this->formatContent($promptGenerationResult['assistant']); 141 | $result[] = PromptMessage::make(Role::Assistant, $assistantContent); 142 | } 143 | return $result; 144 | } 145 | 146 | if (isset($promptGenerationResult['role']) && isset($promptGenerationResult['content'])) { 147 | return [$this->formatMessage($promptGenerationResult)]; 148 | } 149 | 150 | throw new \RuntimeException('Associative array must contain either role/content keys or user/assistant keys.'); 151 | } 152 | 153 | $formattedMessages = []; 154 | foreach ($promptGenerationResult as $index => $message) { 155 | if ($message instanceof PromptMessage) { 156 | $formattedMessages[] = $message; 157 | } else { 158 | $formattedMessages[] = $this->formatMessage($message, $index); 159 | } 160 | } 161 | return $formattedMessages; 162 | } 163 | 164 | throw new \RuntimeException('Invalid prompt generation result format.'); 165 | } 166 | 167 | /** 168 | * Formats a single message into a PromptMessage. 169 | */ 170 | private function formatMessage(mixed $message, ?int $index = null): PromptMessage 171 | { 172 | $indexStr = $index !== null ? " at index {$index}" : ''; 173 | 174 | if (! is_array($message) || ! array_key_exists('role', $message) || ! array_key_exists('content', $message)) { 175 | throw new \RuntimeException("Invalid message format{$indexStr}. Expected an array with 'role' and 'content' keys."); 176 | } 177 | 178 | $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']); 179 | if ($role === null) { 180 | throw new \RuntimeException("Invalid role '{$message['role']}' in prompt message{$indexStr}. Only 'user' or 'assistant' are supported."); 181 | } 182 | 183 | $content = $this->formatContent($message['content'], $index); 184 | 185 | return new PromptMessage($role, $content); 186 | } 187 | 188 | /** 189 | * Formats content into a proper Content object. 190 | */ 191 | private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource 192 | { 193 | $indexStr = $index !== null ? " at index {$index}" : ''; 194 | 195 | if ($content instanceof Content) { 196 | if ( 197 | $content instanceof TextContent || $content instanceof ImageContent || 198 | $content instanceof AudioContent || $content instanceof EmbeddedResource 199 | ) { 200 | return $content; 201 | } 202 | throw new \RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource."); 203 | } 204 | 205 | if (is_string($content)) { 206 | return TextContent::make($content); 207 | } 208 | 209 | if (is_array($content) && isset($content['type'])) { 210 | return $this->formatTypedContent($content, $index); 211 | } 212 | 213 | if (is_scalar($content) || $content === null) { 214 | $stringContent = $content === null ? '(null)' : (is_bool($content) ? ($content ? 'true' : 'false') : (string)$content); 215 | return TextContent::make($stringContent); 216 | } 217 | 218 | $jsonContent = json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); 219 | return TextContent::make($jsonContent); 220 | } 221 | 222 | /** 223 | * Formats typed content arrays into Content objects. 224 | */ 225 | private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource 226 | { 227 | $indexStr = $index !== null ? " at index {$index}" : ''; 228 | $type = $content['type']; 229 | 230 | return match ($type) { 231 | 'text' => $this->formatTextContent($content, $indexStr), 232 | 'image' => $this->formatImageContent($content, $indexStr), 233 | 'audio' => $this->formatAudioContent($content, $indexStr), 234 | 'resource' => $this->formatResourceContent($content, $indexStr), 235 | default => throw new \RuntimeException("Invalid content type '{$type}'{$indexStr}.") 236 | }; 237 | } 238 | 239 | private function formatTextContent(array $content, string $indexStr): TextContent 240 | { 241 | if (! isset($content['text']) || ! is_string($content['text'])) { 242 | throw new \RuntimeException("Invalid 'text' content{$indexStr}: Missing or invalid 'text' string."); 243 | } 244 | return TextContent::make($content['text']); 245 | } 246 | 247 | private function formatImageContent(array $content, string $indexStr): ImageContent 248 | { 249 | if (! isset($content['data']) || ! is_string($content['data'])) { 250 | throw new \RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'data' string (base64)."); 251 | } 252 | if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { 253 | throw new \RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'mimeType' string."); 254 | } 255 | return ImageContent::make($content['data'], $content['mimeType']); 256 | } 257 | 258 | private function formatAudioContent(array $content, string $indexStr): AudioContent 259 | { 260 | if (! isset($content['data']) || ! is_string($content['data'])) { 261 | throw new \RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'data' string (base64)."); 262 | } 263 | if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { 264 | throw new \RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'mimeType' string."); 265 | } 266 | return AudioContent::make($content['data'], $content['mimeType']); 267 | } 268 | 269 | private function formatResourceContent(array $content, string $indexStr): EmbeddedResource 270 | { 271 | if (! isset($content['resource']) || ! is_array($content['resource'])) { 272 | throw new \RuntimeException("Invalid 'resource' content{$indexStr}: Missing or invalid 'resource' object."); 273 | } 274 | 275 | $resource = $content['resource']; 276 | if (! isset($resource['uri']) || ! is_string($resource['uri'])) { 277 | throw new \RuntimeException("Invalid resource{$indexStr}: Missing or invalid 'uri'."); 278 | } 279 | 280 | if (isset($resource['text']) && is_string($resource['text'])) { 281 | $resourceObj = TextResourceContents::make($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); 282 | } elseif (isset($resource['blob']) && is_string($resource['blob'])) { 283 | $resourceObj = BlobResourceContents::make( 284 | $resource['uri'], 285 | $resource['mimeType'] ?? 'application/octet-stream', 286 | $resource['blob'] 287 | ); 288 | } else { 289 | throw new \RuntimeException("Invalid resource{$indexStr}: Must contain 'text' or 'blob'."); 290 | } 291 | 292 | return new EmbeddedResource($resourceObj); 293 | } 294 | 295 | public function toArray(): array 296 | { 297 | $completionProviders = []; 298 | foreach ($this->completionProviders as $argument => $provider) { 299 | $completionProviders[$argument] = serialize($provider); 300 | } 301 | 302 | return [ 303 | 'schema' => $this->schema->toArray(), 304 | 'completionProviders' => $completionProviders, 305 | ...parent::toArray(), 306 | ]; 307 | } 308 | 309 | public static function fromArray(array $data): self|false 310 | { 311 | try { 312 | if (! isset($data['schema']) || ! isset($data['handler'])) { 313 | return false; 314 | } 315 | 316 | $completionProviders = []; 317 | foreach ($data['completionProviders'] ?? [] as $argument => $provider) { 318 | $completionProviders[$argument] = unserialize($provider); 319 | } 320 | 321 | return new self( 322 | Prompt::fromArray($data['schema']), 323 | $data['handler'], 324 | $data['isManual'] ?? false, 325 | $completionProviders, 326 | ); 327 | } catch (Throwable $e) { 328 | return false; 329 | } 330 | } 331 | } 332 | ``` -------------------------------------------------------------------------------- /src/Utils/SchemaValidator.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Utils; 4 | 5 | use InvalidArgumentException; 6 | use JsonException; 7 | use Opis\JsonSchema\Errors\ValidationError; 8 | use Opis\JsonSchema\Validator; 9 | use Psr\Log\LoggerInterface; 10 | use Throwable; 11 | 12 | /** 13 | * Validates data against JSON Schema definitions using opis/json-schema. 14 | */ 15 | class SchemaValidator 16 | { 17 | private ?Validator $jsonSchemaValidator = null; 18 | 19 | private LoggerInterface $logger; 20 | 21 | public function __construct(LoggerInterface $logger) 22 | { 23 | $this->logger = $logger; 24 | } 25 | 26 | /** 27 | * Validates data against a JSON schema. 28 | * 29 | * @param mixed $data The data to validate (should generally be decoded JSON). 30 | * @param array|object $schema The JSON Schema definition (as PHP array or object). 31 | * @return list<array{pointer: string, keyword: string, message: string}> Array of validation errors, empty if valid. 32 | */ 33 | public function validateAgainstJsonSchema(mixed $data, array|object $schema): array 34 | { 35 | if (is_array($data) && empty($data)) { 36 | $data = new \stdClass(); 37 | } 38 | 39 | try { 40 | // --- Schema Preparation --- 41 | if (is_array($schema)) { 42 | $schemaJson = json_encode($schema, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); 43 | $schemaObject = json_decode($schemaJson, false, 512, JSON_THROW_ON_ERROR); 44 | } elseif (is_object($schema)) { 45 | // This might be overly cautious but safer against varied inputs. 46 | $schemaJson = json_encode($schema, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); 47 | $schemaObject = json_decode($schemaJson, false, 512, JSON_THROW_ON_ERROR); 48 | } else { 49 | throw new InvalidArgumentException('Schema must be an array or object.'); 50 | } 51 | 52 | // --- Data Preparation --- 53 | // Opis Validator generally prefers objects for object validation 54 | $dataToValidate = $this->convertDataForValidator($data); 55 | } catch (JsonException $e) { 56 | $this->logger->error('MCP SDK: Invalid schema structure provided for validation (JSON conversion failed).', ['exception' => $e]); 57 | 58 | return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Invalid schema definition provided (JSON error).']]; 59 | } catch (InvalidArgumentException $e) { 60 | $this->logger->error('MCP SDK: Invalid schema structure provided for validation.', ['exception' => $e]); 61 | 62 | return [['pointer' => '', 'keyword' => 'internal', 'message' => $e->getMessage()]]; 63 | } catch (Throwable $e) { 64 | $this->logger->error('MCP SDK: Error preparing data/schema for validation.', ['exception' => $e]); 65 | 66 | return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Internal validation preparation error.']]; 67 | } 68 | 69 | $validator = $this->getJsonSchemaValidator(); 70 | 71 | try { 72 | $result = $validator->validate($dataToValidate, $schemaObject); 73 | } catch (Throwable $e) { 74 | $this->logger->error('MCP SDK: JSON Schema validation failed internally.', [ 75 | 'exception_message' => $e->getMessage(), 76 | 'exception_trace' => $e->getTraceAsString(), 77 | 'data' => json_encode($dataToValidate), 78 | 'schema' => json_encode($schemaObject), 79 | ]); 80 | 81 | return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: ' . $e->getMessage()]]; 82 | } 83 | 84 | if ($result->isValid()) { 85 | return []; 86 | } 87 | 88 | $formattedErrors = []; 89 | $topError = $result->error(); 90 | 91 | if ($topError) { 92 | $this->collectSubErrors($topError, $formattedErrors); 93 | } 94 | 95 | if (empty($formattedErrors) && $topError) { // Fallback 96 | $formattedErrors[] = [ 97 | 'pointer' => $this->formatJsonPointerPath($topError->data()?->path()), 98 | 'keyword' => $topError->keyword(), 99 | 'message' => $this->formatValidationError($topError), 100 | ]; 101 | } 102 | 103 | return $formattedErrors; 104 | } 105 | 106 | /** 107 | * Get or create the JSON Schema validator instance. 108 | */ 109 | private function getJsonSchemaValidator(): Validator 110 | { 111 | if ($this->jsonSchemaValidator === null) { 112 | $this->jsonSchemaValidator = new Validator(); 113 | // Potentially configure resolver here if needed later 114 | } 115 | 116 | return $this->jsonSchemaValidator; 117 | } 118 | 119 | /** 120 | * Recursively converts associative arrays to stdClass objects for validator compatibility. 121 | */ 122 | private function convertDataForValidator(mixed $data): mixed 123 | { 124 | if (is_array($data)) { 125 | // Check if it's an associative array (keys are not sequential numbers 0..N-1) 126 | if (! empty($data) && array_keys($data) !== range(0, count($data) - 1)) { 127 | $obj = new \stdClass(); 128 | foreach ($data as $key => $value) { 129 | $obj->{$key} = $this->convertDataForValidator($value); 130 | } 131 | 132 | return $obj; 133 | } else { 134 | // It's a list (sequential array), convert items recursively 135 | return array_map([$this, 'convertDataForValidator'], $data); 136 | } 137 | } elseif (is_object($data) && $data instanceof \stdClass) { 138 | // Deep copy/convert stdClass objects as well 139 | $obj = new \stdClass(); 140 | foreach (get_object_vars($data) as $key => $value) { 141 | $obj->{$key} = $this->convertDataForValidator($value); 142 | } 143 | 144 | return $obj; 145 | } 146 | 147 | // Leave other objects and scalar types as they are 148 | return $data; 149 | } 150 | 151 | /** 152 | * Recursively collects leaf validation errors. 153 | */ 154 | private function collectSubErrors(ValidationError $error, array &$collectedErrors): void 155 | { 156 | $subErrors = $error->subErrors(); 157 | if (empty($subErrors)) { 158 | $collectedErrors[] = [ 159 | 'pointer' => $this->formatJsonPointerPath($error->data()?->path()), 160 | 'keyword' => $error->keyword(), 161 | 'message' => $this->formatValidationError($error), 162 | ]; 163 | } else { 164 | foreach ($subErrors as $subError) { 165 | $this->collectSubErrors($subError, $collectedErrors); 166 | } 167 | } 168 | } 169 | 170 | /** 171 | * Formats the path array into a JSON Pointer string. 172 | */ 173 | private function formatJsonPointerPath(?array $pathComponents): string 174 | { 175 | if ($pathComponents === null || empty($pathComponents)) { 176 | return '/'; 177 | } 178 | $escapedComponents = array_map(function ($component) { 179 | $componentStr = (string) $component; 180 | 181 | return str_replace(['~', '/'], ['~0', '~1'], $componentStr); 182 | }, $pathComponents); 183 | 184 | return '/' . implode('/', $escapedComponents); 185 | } 186 | 187 | /** 188 | * Formats an Opis SchemaValidationError into a user-friendly message. 189 | */ 190 | private function formatValidationError(ValidationError $error): string 191 | { 192 | $keyword = $error->keyword(); 193 | $args = $error->args(); 194 | $message = "Constraint `{$keyword}` failed."; 195 | 196 | switch (strtolower($keyword)) { 197 | case 'required': 198 | $missing = $args['missing'] ?? []; 199 | $formattedMissing = implode(', ', array_map(fn($p) => "`{$p}`", $missing)); 200 | $message = "Missing required properties: {$formattedMissing}."; 201 | break; 202 | case 'type': 203 | $expected = implode('|', (array) ($args['expected'] ?? [])); 204 | $used = $args['used'] ?? 'unknown'; 205 | $message = "Invalid type. Expected `{$expected}`, but received `{$used}`."; 206 | break; 207 | case 'enum': 208 | $schemaData = $error->schema()?->info()?->data(); 209 | $allowedValues = []; 210 | if (is_object($schemaData) && property_exists($schemaData, 'enum') && is_array($schemaData->enum)) { 211 | $allowedValues = $schemaData->enum; 212 | } elseif (is_array($schemaData) && isset($schemaData['enum']) && is_array($schemaData['enum'])) { 213 | $allowedValues = $schemaData['enum']; 214 | } else { 215 | $this->logger->warning("MCP SDK: Could not retrieve 'enum' values from schema info for error.", ['error_args' => $args]); 216 | } 217 | if (empty($allowedValues)) { 218 | $message = 'Value does not match the allowed enumeration.'; 219 | } else { 220 | $formattedAllowed = array_map(function ($v) { /* ... formatting logic ... */ 221 | if (is_string($v)) { 222 | return '"' . $v . '"'; 223 | } 224 | if (is_bool($v)) { 225 | return $v ? 'true' : 'false'; 226 | } 227 | if ($v === null) { 228 | return 'null'; 229 | } 230 | 231 | return (string) $v; 232 | }, $allowedValues); 233 | $message = 'Value must be one of the allowed values: ' . implode(', ', $formattedAllowed) . '.'; 234 | } 235 | break; 236 | case 'const': 237 | $expected = json_encode($args['expected'] ?? 'null', JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 238 | $message = "Value must be equal to the constant value: {$expected}."; 239 | break; 240 | case 'minLength': // Corrected casing 241 | $min = $args['min'] ?? '?'; 242 | $message = "String must be at least {$min} characters long."; 243 | break; 244 | case 'maxLength': // Corrected casing 245 | $max = $args['max'] ?? '?'; 246 | $message = "String must not be longer than {$max} characters."; 247 | break; 248 | case 'pattern': 249 | $pattern = $args['pattern'] ?? '?'; 250 | $message = "String does not match the required pattern: `{$pattern}`."; 251 | break; 252 | case 'minimum': 253 | $min = $args['min'] ?? '?'; 254 | $message = "Number must be greater than or equal to {$min}."; 255 | break; 256 | case 'maximum': 257 | $max = $args['max'] ?? '?'; 258 | $message = "Number must be less than or equal to {$max}."; 259 | break; 260 | case 'exclusiveMinimum': // Corrected casing 261 | $min = $args['min'] ?? '?'; 262 | $message = "Number must be strictly greater than {$min}."; 263 | break; 264 | case 'exclusiveMaximum': // Corrected casing 265 | $max = $args['max'] ?? '?'; 266 | $message = "Number must be strictly less than {$max}."; 267 | break; 268 | case 'multipleOf': // Corrected casing 269 | $value = $args['value'] ?? '?'; 270 | $message = "Number must be a multiple of {$value}."; 271 | break; 272 | case 'minItems': // Corrected casing 273 | $min = $args['min'] ?? '?'; 274 | $message = "Array must contain at least {$min} items."; 275 | break; 276 | case 'maxItems': // Corrected casing 277 | $max = $args['max'] ?? '?'; 278 | $message = "Array must contain no more than {$max} items."; 279 | break; 280 | case 'uniqueItems': // Corrected casing 281 | $message = 'Array items must be unique.'; 282 | break; 283 | case 'minProperties': // Corrected casing 284 | $min = $args['min'] ?? '?'; 285 | $message = "Object must have at least {$min} properties."; 286 | break; 287 | case 'maxProperties': // Corrected casing 288 | $max = $args['max'] ?? '?'; 289 | $message = "Object must have no more than {$max} properties."; 290 | break; 291 | case 'additionalProperties': // Corrected casing 292 | $unexpected = $args['properties'] ?? []; 293 | $formattedUnexpected = implode(', ', array_map(fn($p) => "`{$p}`", $unexpected)); 294 | $message = "Object contains unexpected additional properties: {$formattedUnexpected}."; 295 | break; 296 | case 'format': 297 | $format = $args['format'] ?? 'unknown'; 298 | $message = "Value does not match the required format: `{$format}`."; 299 | break; 300 | default: 301 | $builtInMessage = $error->message(); 302 | if ($builtInMessage && $builtInMessage !== 'The data must match the schema') { 303 | $placeholders = $args ?? []; 304 | $builtInMessage = preg_replace_callback('/\{(\w+)\}/', function ($match) use ($placeholders) { 305 | $key = $match[1]; 306 | $value = $placeholders[$key] ?? '{' . $key . '}'; 307 | 308 | return is_array($value) ? json_encode($value) : (string) $value; 309 | }, $builtInMessage); 310 | $message = $builtInMessage; 311 | } 312 | break; 313 | } 314 | 315 | return $message; 316 | } 317 | } 318 | ``` -------------------------------------------------------------------------------- /src/Transports/HttpServerTransport.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\LoggerAwareInterface; 9 | use PhpMcp\Server\Contracts\LoopAwareInterface; 10 | use PhpMcp\Server\Contracts\ServerTransportInterface; 11 | use PhpMcp\Server\Exception\TransportException; 12 | use PhpMcp\Schema\JsonRpc\Message; 13 | use PhpMcp\Schema\JsonRpc\Error; 14 | use PhpMcp\Schema\JsonRpc\Parser; 15 | use Psr\Http\Message\ServerRequestInterface; 16 | use Psr\Log\LoggerInterface; 17 | use Psr\Log\NullLogger; 18 | use React\EventLoop\Loop; 19 | use React\EventLoop\LoopInterface; 20 | use React\Http\HttpServer; 21 | use React\Http\Message\Response; 22 | use React\Promise\Deferred; 23 | use React\Promise\PromiseInterface; 24 | use React\Socket\SocketServer; 25 | use React\Stream\ThroughStream; 26 | use React\Stream\WritableStreamInterface; 27 | use Throwable; 28 | 29 | use function React\Promise\resolve; 30 | use function React\Promise\reject; 31 | 32 | /** 33 | * Implementation of the HTTP+SSE server transport using ReactPHP components. 34 | * 35 | * Listens for HTTP connections, manages SSE streams, and emits events. 36 | */ 37 | class HttpServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface 38 | { 39 | use EventEmitterTrait; 40 | 41 | protected LoggerInterface $logger; 42 | 43 | protected LoopInterface $loop; 44 | 45 | protected ?SocketServer $socket = null; 46 | 47 | protected ?HttpServer $http = null; 48 | 49 | /** @var array<string, ThroughStream> sessionId => SSE Stream */ 50 | private array $activeSseStreams = []; 51 | 52 | protected bool $listening = false; 53 | 54 | protected bool $closing = false; 55 | 56 | protected string $ssePath; 57 | 58 | protected string $messagePath; 59 | 60 | /** 61 | * @param string $host Host to bind to (e.g., '127.0.0.1', '0.0.0.0'). 62 | * @param int $port Port to listen on (e.g., 8080). 63 | * @param string $mcpPathPrefix URL prefix for MCP endpoints (e.g., 'mcp'). 64 | * @param array|null $sslContext Optional SSL context options for React SocketServer (for HTTPS). 65 | * @param array<callable(\Psr\Http\Message\ServerRequestInterface, callable): (\Psr\Http\Message\ResponseInterface|\React\Promise\PromiseInterface)> $middlewares Middlewares to be applied to the HTTP server. 66 | */ 67 | public function __construct( 68 | private readonly string $host = '127.0.0.1', 69 | private readonly int $port = 8080, 70 | private readonly string $mcpPathPrefix = 'mcp', 71 | private readonly ?array $sslContext = null, 72 | private array $middlewares = [] 73 | ) { 74 | $this->logger = new NullLogger(); 75 | $this->loop = Loop::get(); 76 | $this->ssePath = '/' . trim($mcpPathPrefix, '/') . '/sse'; 77 | $this->messagePath = '/' . trim($mcpPathPrefix, '/') . '/message'; 78 | 79 | foreach ($this->middlewares as $mw) { 80 | if (!is_callable($mw)) { 81 | throw new \InvalidArgumentException('All provided middlewares must be callable.'); 82 | } 83 | } 84 | } 85 | 86 | public function setLogger(LoggerInterface $logger): void 87 | { 88 | $this->logger = $logger; 89 | } 90 | 91 | public function setLoop(LoopInterface $loop): void 92 | { 93 | $this->loop = $loop; 94 | } 95 | 96 | protected function generateId(): string 97 | { 98 | return bin2hex(random_bytes(16)); // 32 hex characters 99 | } 100 | 101 | /** 102 | * Starts the HTTP server listener. 103 | * 104 | * @throws TransportException If port binding fails. 105 | */ 106 | public function listen(): void 107 | { 108 | if ($this->listening) { 109 | throw new TransportException('Http transport is already listening.'); 110 | } 111 | if ($this->closing) { 112 | throw new TransportException('Cannot listen, transport is closing/closed.'); 113 | } 114 | 115 | $listenAddress = "{$this->host}:{$this->port}"; 116 | $protocol = $this->sslContext ? 'https' : 'http'; 117 | 118 | try { 119 | $this->socket = new SocketServer( 120 | $listenAddress, 121 | $this->sslContext ?? [], 122 | $this->loop 123 | ); 124 | 125 | $handlers = array_merge($this->middlewares, [$this->createRequestHandler()]); 126 | $this->http = new HttpServer($this->loop, ...$handlers); 127 | $this->http->listen($this->socket); 128 | 129 | $this->socket->on('error', function (Throwable $error) { 130 | $this->logger->error('Socket server error.', ['error' => $error->getMessage()]); 131 | $this->emit('error', [new TransportException("Socket server error: {$error->getMessage()}", 0, $error)]); 132 | $this->close(); 133 | }); 134 | 135 | $this->logger->info("Server is up and listening on {$protocol}://{$listenAddress} 🚀"); 136 | $this->logger->info("SSE Endpoint: {$protocol}://{$listenAddress}{$this->ssePath}"); 137 | $this->logger->info("Message Endpoint: {$protocol}://{$listenAddress}{$this->messagePath}"); 138 | 139 | $this->listening = true; 140 | $this->closing = false; 141 | $this->emit('ready'); 142 | } catch (Throwable $e) { 143 | $this->logger->error("Failed to start listener on {$listenAddress}", ['exception' => $e]); 144 | throw new TransportException("Failed to start HTTP listener on {$listenAddress}: {$e->getMessage()}", 0, $e); 145 | } 146 | } 147 | 148 | /** Creates the main request handling callback for ReactPHP HttpServer */ 149 | protected function createRequestHandler(): callable 150 | { 151 | return function (ServerRequestInterface $request) { 152 | $path = $request->getUri()->getPath(); 153 | $method = $request->getMethod(); 154 | $this->logger->debug('Received request', ['method' => $method, 'path' => $path]); 155 | 156 | if ($method === 'GET' && $path === $this->ssePath) { 157 | return $this->handleSseRequest($request); 158 | } 159 | 160 | if ($method === 'POST' && $path === $this->messagePath) { 161 | return $this->handleMessagePostRequest($request); 162 | } 163 | 164 | $this->logger->debug('404 Not Found', ['method' => $method, 'path' => $path]); 165 | 166 | return new Response(404, ['Content-Type' => 'text/plain'], 'Not Found'); 167 | }; 168 | } 169 | 170 | /** Handles a new SSE connection request */ 171 | protected function handleSseRequest(ServerRequestInterface $request): Response 172 | { 173 | $sessionId = $this->generateId(); 174 | $this->logger->info('New SSE connection', ['sessionId' => $sessionId]); 175 | 176 | $sseStream = new ThroughStream(); 177 | 178 | $sseStream->on('close', function () use ($sessionId) { 179 | $this->logger->info('SSE stream closed', ['sessionId' => $sessionId]); 180 | unset($this->activeSseStreams[$sessionId]); 181 | $this->emit('client_disconnected', [$sessionId, 'SSE stream closed']); 182 | }); 183 | 184 | $sseStream->on('error', function (Throwable $error) use ($sessionId) { 185 | $this->logger->warning('SSE stream error', ['sessionId' => $sessionId, 'error' => $error->getMessage()]); 186 | unset($this->activeSseStreams[$sessionId]); 187 | $this->emit('error', [new TransportException("SSE Stream Error: {$error->getMessage()}", 0, $error), $sessionId]); 188 | $this->emit('client_disconnected', [$sessionId, 'SSE stream error']); 189 | }); 190 | 191 | $this->activeSseStreams[$sessionId] = $sseStream; 192 | 193 | $this->loop->futureTick(function () use ($sessionId, $request, $sseStream) { 194 | if (! isset($this->activeSseStreams[$sessionId]) || ! $sseStream->isWritable()) { 195 | $this->logger->warning('Cannot send initial endpoint event, stream closed/invalid early.', ['sessionId' => $sessionId]); 196 | 197 | return; 198 | } 199 | 200 | try { 201 | $baseUri = $request->getUri()->withPath($this->messagePath)->withQuery('')->withFragment(''); 202 | $postEndpointWithId = (string) $baseUri->withQuery("clientId={$sessionId}"); 203 | $this->sendSseEvent($sseStream, 'endpoint', $postEndpointWithId, "init-{$sessionId}"); 204 | 205 | $this->emit('client_connected', [$sessionId]); 206 | } catch (Throwable $e) { 207 | $this->logger->error('Error sending initial endpoint event', ['sessionId' => $sessionId, 'exception' => $e]); 208 | $sseStream->close(); 209 | } 210 | }); 211 | 212 | return new Response( 213 | 200, 214 | [ 215 | 'Content-Type' => 'text/event-stream', 216 | 'Cache-Control' => 'no-cache', 217 | 'Connection' => 'keep-alive', 218 | 'X-Accel-Buffering' => 'no', 219 | 'Access-Control-Allow-Origin' => '*', 220 | ], 221 | $sseStream 222 | ); 223 | } 224 | 225 | /** Handles incoming POST requests with messages */ 226 | protected function handleMessagePostRequest(ServerRequestInterface $request): Response 227 | { 228 | $queryParams = $request->getQueryParams(); 229 | $sessionId = $queryParams['clientId'] ?? null; 230 | $jsonEncodeFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; 231 | 232 | if (! $sessionId || ! is_string($sessionId)) { 233 | $this->logger->warning('Received POST without valid clientId query parameter.'); 234 | $error = Error::forInvalidRequest('Missing or invalid clientId query parameter'); 235 | 236 | return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); 237 | } 238 | 239 | if (! isset($this->activeSseStreams[$sessionId])) { 240 | $this->logger->warning('Received POST for unknown or disconnected sessionId.', ['sessionId' => $sessionId]); 241 | 242 | $error = Error::forInvalidRequest('Session ID not found or disconnected'); 243 | 244 | return new Response(404, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); 245 | } 246 | 247 | if (! str_contains(strtolower($request->getHeaderLine('Content-Type')), 'application/json')) { 248 | $error = Error::forInvalidRequest('Content-Type must be application/json'); 249 | 250 | return new Response(415, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); 251 | } 252 | 253 | $body = $request->getBody()->getContents(); 254 | 255 | if (empty($body)) { 256 | $this->logger->warning('Received empty POST body', ['sessionId' => $sessionId]); 257 | 258 | $error = Error::forInvalidRequest('Empty request body'); 259 | 260 | return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); 261 | } 262 | 263 | try { 264 | $message = Parser::parse($body); 265 | } catch (Throwable $e) { 266 | $this->logger->error('Error parsing message', ['sessionId' => $sessionId, 'exception' => $e]); 267 | 268 | $error = Error::forParseError('Invalid JSON-RPC message: ' . $e->getMessage()); 269 | 270 | return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); 271 | } 272 | 273 | $context = [ 274 | 'request' => $request, 275 | ]; 276 | $this->emit('message', [$message, $sessionId, $context]); 277 | 278 | return new Response(202, ['Content-Type' => 'text/plain'], 'Accepted'); 279 | } 280 | 281 | 282 | /** 283 | * Sends a raw JSON-RPC message frame to a specific client via SSE. 284 | */ 285 | public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface 286 | { 287 | if (! isset($this->activeSseStreams[$sessionId])) { 288 | return reject(new TransportException("Cannot send message: Client '{$sessionId}' not connected via SSE.")); 289 | } 290 | 291 | $stream = $this->activeSseStreams[$sessionId]; 292 | if (! $stream->isWritable()) { 293 | return reject(new TransportException("Cannot send message: SSE stream for client '{$sessionId}' is not writable.")); 294 | } 295 | 296 | $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 297 | 298 | if ($json === '') { 299 | return resolve(null); 300 | } 301 | 302 | $deferred = new Deferred(); 303 | $written = $this->sendSseEvent($stream, 'message', $json); 304 | 305 | if ($written) { 306 | $deferred->resolve(null); 307 | } else { 308 | $this->logger->debug('SSE stream buffer full, waiting for drain.', ['sessionId' => $sessionId]); 309 | $stream->once('drain', function () use ($deferred, $sessionId) { 310 | $this->logger->debug('SSE stream drained.', ['sessionId' => $sessionId]); 311 | $deferred->resolve(null); 312 | }); 313 | } 314 | 315 | return $deferred->promise(); 316 | } 317 | 318 | /** Helper to format and write an SSE event */ 319 | private function sendSseEvent(WritableStreamInterface $stream, string $event, string $data, ?string $id = null): bool 320 | { 321 | if (! $stream->isWritable()) { 322 | return false; 323 | } 324 | 325 | $frame = "event: {$event}\n"; 326 | if ($id !== null) { 327 | $frame .= "id: {$id}\n"; 328 | } 329 | 330 | $lines = explode("\n", $data); 331 | foreach ($lines as $line) { 332 | $frame .= "data: {$line}\n"; 333 | } 334 | $frame .= "\n"; // End of event 335 | 336 | $this->logger->debug('Sending SSE event', ['event' => $event, 'frame' => $frame]); 337 | 338 | return $stream->write($frame); 339 | } 340 | 341 | /** 342 | * Stops the HTTP server and closes all connections. 343 | */ 344 | public function close(): void 345 | { 346 | if ($this->closing) { 347 | return; 348 | } 349 | $this->closing = true; 350 | $this->listening = false; 351 | $this->logger->info('Closing transport...'); 352 | 353 | if ($this->socket) { 354 | $this->socket->close(); 355 | $this->socket = null; 356 | } 357 | 358 | $activeStreams = $this->activeSseStreams; 359 | $this->activeSseStreams = []; 360 | foreach ($activeStreams as $sessionId => $stream) { 361 | $this->logger->debug('Closing active SSE stream', ['sessionId' => $sessionId]); 362 | unset($this->activeSseStreams[$sessionId]); 363 | $stream->close(); 364 | } 365 | 366 | $this->emit('close', ['HttpTransport closed.']); 367 | $this->removeAllListeners(); 368 | } 369 | } 370 | ``` -------------------------------------------------------------------------------- /tests/Unit/Elements/RegisteredElementTest.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Unit\Elements; 4 | 5 | use Mockery; 6 | use PhpMcp\Server\Context; 7 | use PhpMcp\Server\Contracts\SessionInterface; 8 | use PhpMcp\Server\Elements\RegisteredElement; 9 | use PhpMcp\Server\Exception\McpServerException; 10 | use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum; 11 | use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; 12 | use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum; 13 | use PhpMcp\Server\Tests\Fixtures\General\VariousTypesHandler; 14 | use Psr\Container\ContainerInterface; 15 | use Psr\Http\Message\ServerRequestInterface; 16 | use stdClass; 17 | 18 | // --- Test Fixtures for Handler Types --- 19 | 20 | class MyInvokableTestHandler 21 | { 22 | public function __invoke(string $name): string 23 | { 24 | return "Hello, {$name}!"; 25 | } 26 | } 27 | 28 | class MyStaticMethodTestHandler 29 | { 30 | public static function myStaticMethod(int $a, int $b): int 31 | { 32 | return $a + $b; 33 | } 34 | } 35 | 36 | function my_global_test_function(bool $flag): string 37 | { 38 | return $flag ? 'on' : 'off'; 39 | } 40 | 41 | 42 | beforeEach(function () { 43 | $this->container = Mockery::mock(ContainerInterface::class); 44 | $this->container->shouldReceive('get')->with(VariousTypesHandler::class)->andReturn(new VariousTypesHandler()); 45 | $this->context = new Context(Mockery::mock(SessionInterface::class)); 46 | }); 47 | 48 | it('can be constructed as manual or discovered', function () { 49 | $handler = [VariousTypesHandler::class, 'noArgsMethod']; 50 | $elManual = new RegisteredElement($handler, true); 51 | $elDiscovered = new RegisteredElement($handler, false); 52 | expect($elManual->isManual)->toBeTrue(); 53 | expect($elDiscovered->isManual)->toBeFalse(); 54 | expect($elDiscovered->handler)->toBe($handler); 55 | }); 56 | 57 | it('prepares arguments in correct order for simple required types', function () { 58 | $element = new RegisteredElement([VariousTypesHandler::class, 'simpleRequiredArgs']); 59 | $args = ['pString' => 'hello', 'pBool' => true, 'pInt' => 123]; 60 | $result = $element->handle($this->container, $args, $this->context); 61 | 62 | $expectedResult = ['pString' => 'hello', 'pInt' => 123, 'pBool' => true]; 63 | 64 | expect($result)->toBe($expectedResult); 65 | }); 66 | 67 | it('uses default values for missing optional arguments', function () { 68 | $element = new RegisteredElement([VariousTypesHandler::class, 'optionalArgsWithDefaults']); 69 | 70 | $result1 = $element->handle($this->container, ['pString' => 'override'], $this->context); 71 | expect($result1['pString'])->toBe('override'); 72 | expect($result1['pInt'])->toBe(100); 73 | expect($result1['pNullableBool'])->toBeTrue(); 74 | expect($result1['pFloat'])->toBe(3.14); 75 | 76 | $result2 = $element->handle($this->container, [], $this->context); 77 | expect($result2['pString'])->toBe('default_string'); 78 | expect($result2['pInt'])->toBe(100); 79 | expect($result2['pNullableBool'])->toBeTrue(); 80 | expect($result2['pFloat'])->toBe(3.14); 81 | }); 82 | 83 | it('passes null for nullable arguments if not provided', function () { 84 | $elementNoDefaults = new RegisteredElement([VariousTypesHandler::class, 'nullableArgsWithoutDefaults']); 85 | $result2 = $elementNoDefaults->handle($this->container, [], $this->context); 86 | expect($result2['pString'])->toBeNull(); 87 | expect($result2['pInt'])->toBeNull(); 88 | expect($result2['pArray'])->toBeNull(); 89 | }); 90 | 91 | it('passes null explicitly for nullable arguments', function () { 92 | $element = new RegisteredElement([VariousTypesHandler::class, 'nullableArgsWithoutDefaults']); 93 | $result = $element->handle($this->container, ['pString' => null, 'pInt' => null, 'pArray' => null], $this->context); 94 | expect($result['pString'])->toBeNull(); 95 | expect($result['pInt'])->toBeNull(); 96 | expect($result['pArray'])->toBeNull(); 97 | }); 98 | 99 | it('handles mixed type arguments', function () { 100 | $element = new RegisteredElement([VariousTypesHandler::class, 'mixedTypeArg']); 101 | $obj = new stdClass(); 102 | $testValues = [ 103 | 'a string', 104 | 123, 105 | true, 106 | null, 107 | ['an', 'array'], 108 | $obj 109 | ]; 110 | foreach ($testValues as $value) { 111 | $result = $element->handle($this->container, ['pMixed' => $value], $this->context); 112 | expect($result['pMixed'])->toBe($value); 113 | } 114 | }); 115 | 116 | it('throws McpServerException for missing required argument', function () { 117 | $element = new RegisteredElement([VariousTypesHandler::class, 'simpleRequiredArgs']); 118 | $element->handle($this->container, ['pString' => 'hello', 'pInt' => 123], $this->context); 119 | })->throws(McpServerException::class, 'Missing required argument `pBool`'); 120 | 121 | dataset('valid_type_casts', [ 122 | 'string_from_int' => ['strParam', 123, '123'], 123 | 'int_from_valid_string' => ['intParam', '456', 456], 124 | 'int_from_neg_string' => ['intParam', '-10', -10], 125 | 'int_from_float_whole' => ['intParam', 77.0, 77], 126 | 'bool_from_int_1' => ['boolProp', 1, true], 127 | 'bool_from_string_true' => ['boolProp', 'true', true], 128 | 'bool_from_string_TRUE' => ['boolProp', 'TRUE', true], 129 | 'bool_from_int_0' => ['boolProp', 0, false], 130 | 'bool_from_string_false' => ['boolProp', 'false', false], 131 | 'bool_from_string_FALSE' => ['boolProp', 'FALSE', false], 132 | 'float_from_valid_string' => ['floatParam', '7.89', 7.89], 133 | 'float_from_int' => ['floatParam', 10, 10.0], 134 | 'array_passthrough' => ['arrayParam', ['x', 'y'], ['x', 'y']], 135 | 'object_passthrough' => ['objectParam', (object)['a' => 1], (object)['a' => 1]], 136 | 'string_for_int_cast_specific' => ['stringForIntCast', '999', 999], 137 | 'string_for_float_cast_specific' => ['stringForFloatCast', '123.45', 123.45], 138 | 'string_for_bool_true_cast_specific' => ['stringForBoolTrueCast', '1', true], 139 | 'string_for_bool_false_cast_specific' => ['stringForBoolFalseCast', '0', false], 140 | 'int_for_string_cast_specific' => ['intForStringCast', 55, '55'], 141 | 'int_for_float_cast_specific' => ['intForFloatCast', 66, 66.0], 142 | 'bool_for_string_cast_specific' => ['boolForStringCast', true, '1'], 143 | 'backed_string_enum_valid_val' => ['backedStringEnumParam', 'A', BackedStringEnum::OptionA], 144 | 'backed_int_enum_valid_val' => ['backedIntEnumParam', 1, BackedIntEnum::First], 145 | 'unit_enum_valid_val' => ['unitEnumParam', 'Yes', UnitEnum::Yes], 146 | ]); 147 | 148 | it('casts argument types correctly for valid inputs (comprehensive)', function (string $paramName, mixed $inputValue, mixed $expectedValue) { 149 | $element = new RegisteredElement([VariousTypesHandler::class, 'comprehensiveArgumentTest']); 150 | 151 | $allArgs = [ 152 | 'strParam' => 'default string', 153 | 'intParam' => 0, 154 | 'boolProp' => false, 155 | 'floatParam' => 0.0, 156 | 'arrayParam' => [], 157 | 'backedStringEnumParam' => BackedStringEnum::OptionA, 158 | 'backedIntEnumParam' => BackedIntEnum::First, 159 | 'unitEnumParam' => 'Yes', 160 | 'nullableStringParam' => null, 161 | 'mixedParam' => 'default mixed', 162 | 'objectParam' => new stdClass(), 163 | 'stringForIntCast' => '0', 164 | 'stringForFloatCast' => '0.0', 165 | 'stringForBoolTrueCast' => 'false', 166 | 'stringForBoolFalseCast' => 'true', 167 | 'intForStringCast' => 0, 168 | 'intForFloatCast' => 0, 169 | 'boolForStringCast' => false, 170 | 'valueForBackedStringEnum' => 'A', 171 | 'valueForBackedIntEnum' => 1, 172 | ]; 173 | $testArgs = array_merge($allArgs, [$paramName => $inputValue]); 174 | 175 | $result = $element->handle($this->container, $testArgs, $this->context); 176 | expect($result[$paramName])->toEqual($expectedValue); 177 | })->with('valid_type_casts'); 178 | 179 | 180 | dataset('invalid_type_casts', [ 181 | 'int_from_alpha_string' => ['intParam', 'abc', '/Cannot cast value to integer/i'], 182 | 'int_from_float_non_whole' => ['intParam', 12.3, '/Cannot cast value to integer/i'], 183 | 'bool_from_string_random' => ['boolProp', 'random', '/Cannot cast value to boolean/i'], 184 | 'bool_from_int_invalid' => ['boolProp', 2, '/Cannot cast value to boolean/i'], 185 | 'float_from_alpha_string' => ['floatParam', 'xyz', '/Cannot cast value to float/i'], 186 | 'array_from_string' => ['arrayParam', 'not_an_array', '/Cannot cast value to array/i'], 187 | 'backed_string_enum_invalid_val' => ['backedStringEnumParam', 'Z', "/Invalid value 'Z' for backed enum .*BackedStringEnum/i"], 188 | 'backed_int_enum_invalid_val' => ['backedIntEnumParam', 99, "/Invalid value '99' for backed enum .*BackedIntEnum/i"], 189 | 'unit_enum_invalid_string_val' => ['unitEnumParam', 'Maybe', "/Invalid value 'Maybe' for unit enum .*UnitEnum/i"], 190 | ]); 191 | 192 | it('throws McpServerException for invalid type casting', function (string $paramName, mixed $invalidValue, string $expectedMsgRegex) { 193 | $element = new RegisteredElement([VariousTypesHandler::class, 'comprehensiveArgumentTest']); 194 | $allArgs = [ /* fill with defaults as in valid_type_casts */ 195 | 'strParam' => 's', 196 | 'intParam' => 1, 197 | 'boolProp' => true, 198 | 'floatParam' => 1.1, 199 | 'arrayParam' => [], 200 | 'backedStringEnumParam' => BackedStringEnum::OptionA, 201 | 'backedIntEnumParam' => BackedIntEnum::First, 202 | 'unitEnumParam' => UnitEnum::Yes, 203 | 'nullableStringParam' => null, 204 | 'mixedParam' => 'mix', 205 | 'objectParam' => new stdClass(), 206 | 'stringForIntCast' => '0', 207 | 'stringForFloatCast' => '0.0', 208 | 'stringForBoolTrueCast' => 'false', 209 | 'stringForBoolFalseCast' => 'true', 210 | 'intForStringCast' => 0, 211 | 'intForFloatCast' => 0, 212 | 'boolForStringCast' => false, 213 | 'valueForBackedStringEnum' => 'A', 214 | 'valueForBackedIntEnum' => 1, 215 | ]; 216 | $testArgs = array_merge($allArgs, [$paramName => $invalidValue]); 217 | 218 | try { 219 | $element->handle($this->container, $testArgs, $this->context); 220 | } catch (McpServerException $e) { 221 | expect($e->getMessage())->toMatch($expectedMsgRegex); 222 | } 223 | })->with('invalid_type_casts'); 224 | 225 | it('casts to BackedStringEnum correctly', function () { 226 | $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); 227 | $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 1], $this->context); 228 | expect($result['pBackedString'])->toBe(BackedStringEnum::OptionA); 229 | }); 230 | 231 | it('throws for invalid BackedStringEnum value', function () { 232 | $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); 233 | $element->handle($this->container, ['pBackedString' => 'Invalid', 'pBackedInt' => 1], $this->context); 234 | })->throws(McpServerException::class, "Invalid value 'Invalid' for backed enum"); 235 | 236 | it('casts to BackedIntEnum correctly', function () { 237 | $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); 238 | $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 2], $this->context); 239 | expect($result['pBackedInt'])->toBe(BackedIntEnum::Second); 240 | }); 241 | 242 | it('throws for invalid BackedIntEnum value', function () { 243 | $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); 244 | $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 999], $this->context); 245 | })->throws(McpServerException::class, "Invalid value '999' for backed enum"); 246 | 247 | it('casts to UnitEnum correctly', function () { 248 | $element = new RegisteredElement([VariousTypesHandler::class, 'unitEnumArg']); 249 | $result = $element->handle($this->container, ['pUnitEnum' => 'Yes'], $this->context); 250 | expect($result['pUnitEnum'])->toBe(UnitEnum::Yes); 251 | }); 252 | 253 | it('throws for invalid UnitEnum value', function () { 254 | $element = new RegisteredElement([VariousTypesHandler::class, 'unitEnumArg']); 255 | $element->handle($this->container, ['pUnitEnum' => 'Invalid'], $this->context); 256 | })->throws(McpServerException::class, "Invalid value 'Invalid' for unit enum"); 257 | 258 | 259 | it('throws ReflectionException if handler method does not exist', function () { 260 | $element = new RegisteredElement([VariousTypesHandler::class, 'nonExistentMethod']); 261 | $element->handle($this->container, [], $this->context); 262 | })->throws(\ReflectionException::class, "VariousTypesHandler::nonExistentMethod() does not exist"); 263 | 264 | it('passes Context object', function() { 265 | $sessionMock = Mockery::mock(SessionInterface::class); 266 | $sessionMock->expects('get')->with('testKey')->andReturn('testValue'); 267 | $requestMock = Mockery::mock(ServerRequestInterface::class); 268 | $requestMock->expects('getHeaderLine')->with('testHeader')->andReturn('testHeaderValue'); 269 | 270 | $context = new Context($sessionMock, $requestMock); 271 | $element = new RegisteredElement([VariousTypesHandler::class, 'contextArg']); 272 | $result = $element->handle($this->container, [], $context); 273 | expect($result)->toBe([ 274 | 'session' => 'testValue', 275 | 'request' => 'testHeaderValue' 276 | ]); 277 | }); 278 | 279 | describe('Handler Types', function () { 280 | it('handles invokable class handler', function () { 281 | $this->container->shouldReceive('get') 282 | ->with(MyInvokableTestHandler::class) 283 | ->andReturn(new MyInvokableTestHandler()); 284 | 285 | $element = new RegisteredElement(MyInvokableTestHandler::class); 286 | $result = $element->handle($this->container, ['name' => 'World'], $this->context); 287 | 288 | expect($result)->toBe('Hello, World!'); 289 | }); 290 | 291 | it('handles closure handler', function () { 292 | $closure = function (string $a, string $b) { 293 | return $a . $b; 294 | }; 295 | $element = new RegisteredElement($closure); 296 | $result = $element->handle($this->container, ['a' => 'foo', 'b' => 'bar'], $this->context); 297 | expect($result)->toBe('foobar'); 298 | }); 299 | 300 | it('handles static method handler', function () { 301 | $handler = [MyStaticMethodTestHandler::class, 'myStaticMethod']; 302 | $element = new RegisteredElement($handler); 303 | $result = $element->handle($this->container, ['a' => 5, 'b' => 10], $this->context); 304 | expect($result)->toBe(15); 305 | }); 306 | 307 | it('handles global function name handler', function () { 308 | $handler = 'PhpMcp\Server\Tests\Unit\Elements\my_global_test_function'; 309 | $element = new RegisteredElement($handler); 310 | $result = $element->handle($this->container, ['flag' => true], $this->context); 311 | expect($result)->toBe('on'); 312 | }); 313 | }); 314 | ``` -------------------------------------------------------------------------------- /examples/08-schema-showcase-streamable/SchemaShowcaseElements.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace Mcp\SchemaShowcaseExample; 4 | 5 | use DateTime; 6 | use DateInterval; 7 | use PhpMcp\Server\Attributes\McpTool; 8 | use PhpMcp\Server\Attributes\Schema; 9 | 10 | class SchemaShowcaseElements 11 | { 12 | /** 13 | * Validates and formats text with string constraints. 14 | * Demonstrates: minLength, maxLength, pattern validation. 15 | */ 16 | #[McpTool( 17 | name: 'format_text', 18 | description: 'Formats text with validation constraints. Text must be 5-100 characters and contain only letters, numbers, spaces, and basic punctuation.' 19 | )] 20 | public function formatText( 21 | #[Schema( 22 | type: 'string', 23 | description: 'The text to format', 24 | minLength: 5, 25 | maxLength: 100, 26 | pattern: '^[a-zA-Z0-9\s\.,!?\-]+$' 27 | )] 28 | string $text, 29 | 30 | #[Schema( 31 | type: 'string', 32 | description: 'Format style', 33 | enum: ['uppercase', 'lowercase', 'title', 'sentence'] 34 | )] 35 | string $format = 'sentence' 36 | ): array { 37 | fwrite(STDERR, "Format text tool called: text='$text', format='$format'\n"); 38 | 39 | $formatted = match ($format) { 40 | 'uppercase' => strtoupper($text), 41 | 'lowercase' => strtolower($text), 42 | 'title' => ucwords(strtolower($text)), 43 | 'sentence' => ucfirst(strtolower($text)), 44 | default => $text 45 | }; 46 | 47 | return [ 48 | 'original' => $text, 49 | 'formatted' => $formatted, 50 | 'length' => strlen($text), 51 | 'format_applied' => $format 52 | ]; 53 | } 54 | 55 | /** 56 | * Performs mathematical operations with numeric constraints. 57 | * 58 | * Demonstrates: METHOD-LEVEL Schema 59 | */ 60 | #[McpTool(name: 'calculate_range')] 61 | #[Schema( 62 | type: 'object', 63 | properties: [ 64 | 'first' => [ 65 | 'type' => 'number', 66 | 'description' => 'First number (must be between 0 and 1000)', 67 | 'minimum' => 0, 68 | 'maximum' => 1000 69 | ], 70 | 'second' => [ 71 | 'type' => 'number', 72 | 'description' => 'Second number (must be between 0 and 1000)', 73 | 'minimum' => 0, 74 | 'maximum' => 1000 75 | ], 76 | 'operation' => [ 77 | 'type' => 'string', 78 | 'description' => 'Operation to perform', 79 | 'enum' => ['add', 'subtract', 'multiply', 'divide', 'power'] 80 | ], 81 | 'precision' => [ 82 | 'type' => 'integer', 83 | 'description' => 'Decimal precision (must be multiple of 2, between 0-10)', 84 | 'minimum' => 0, 85 | 'maximum' => 10, 86 | 'multipleOf' => 2 87 | ] 88 | ], 89 | required: ['first', 'second', 'operation'], 90 | )] 91 | public function calculateRange(float $first, float $second, string $operation, int $precision = 2): array 92 | { 93 | fwrite(STDERR, "Calculate range tool called: $first $operation $second (precision: $precision)\n"); 94 | 95 | $result = match ($operation) { 96 | 'add' => $first + $second, 97 | 'subtract' => $first - $second, 98 | 'multiply' => $first * $second, 99 | 'divide' => $second != 0 ? $first / $second : null, 100 | 'power' => pow($first, $second), 101 | default => null 102 | }; 103 | 104 | if ($result === null) { 105 | return [ 106 | 'error' => $operation === 'divide' ? 'Division by zero' : 'Invalid operation', 107 | 'inputs' => compact('first', 'second', 'operation', 'precision') 108 | ]; 109 | } 110 | 111 | return [ 112 | 'result' => round($result, $precision), 113 | 'operation' => "$first $operation $second", 114 | 'precision' => $precision, 115 | 'within_bounds' => $result >= 0 && $result <= 1000000 116 | ]; 117 | } 118 | 119 | /** 120 | * Processes user profile data with object schema validation. 121 | * Demonstrates: object properties, required fields, additionalProperties. 122 | */ 123 | #[McpTool( 124 | name: 'validate_profile', 125 | description: 'Validates and processes user profile data with strict schema requirements.' 126 | )] 127 | public function validateProfile( 128 | #[Schema( 129 | type: 'object', 130 | description: 'User profile information', 131 | properties: [ 132 | 'name' => [ 133 | 'type' => 'string', 134 | 'minLength' => 2, 135 | 'maxLength' => 50, 136 | 'description' => 'Full name' 137 | ], 138 | 'email' => [ 139 | 'type' => 'string', 140 | 'format' => 'email', 141 | 'description' => 'Valid email address' 142 | ], 143 | 'age' => [ 144 | 'type' => 'integer', 145 | 'minimum' => 13, 146 | 'maximum' => 120, 147 | 'description' => 'Age in years' 148 | ], 149 | 'role' => [ 150 | 'type' => 'string', 151 | 'enum' => ['user', 'admin', 'moderator', 'guest'], 152 | 'description' => 'User role' 153 | ], 154 | 'preferences' => [ 155 | 'type' => 'object', 156 | 'properties' => [ 157 | 'notifications' => ['type' => 'boolean'], 158 | 'theme' => ['type' => 'string', 'enum' => ['light', 'dark', 'auto']] 159 | ], 160 | 'additionalProperties' => false 161 | ] 162 | ], 163 | required: ['name', 'email', 'age'], 164 | additionalProperties: true 165 | )] 166 | array $profile 167 | ): array { 168 | fwrite(STDERR, "Validate profile tool called with: " . json_encode($profile) . "\n"); 169 | 170 | $errors = []; 171 | $warnings = []; 172 | 173 | // Additional business logic validation 174 | if (isset($profile['age']) && $profile['age'] < 18 && ($profile['role'] ?? 'user') === 'admin') { 175 | $errors[] = 'Admin role requires age 18 or older'; 176 | } 177 | 178 | if (isset($profile['email']) && !filter_var($profile['email'], FILTER_VALIDATE_EMAIL)) { 179 | $errors[] = 'Invalid email format'; 180 | } 181 | 182 | if (!isset($profile['role'])) { 183 | $warnings[] = 'No role specified, defaulting to "user"'; 184 | $profile['role'] = 'user'; 185 | } 186 | 187 | return [ 188 | 'valid' => empty($errors), 189 | 'profile' => $profile, 190 | 'errors' => $errors, 191 | 'warnings' => $warnings, 192 | 'processed_at' => date('Y-m-d H:i:s') 193 | ]; 194 | } 195 | 196 | /** 197 | * Manages a list of items with array constraints. 198 | * Demonstrates: array items, minItems, maxItems, uniqueItems. 199 | */ 200 | #[McpTool( 201 | name: 'manage_list', 202 | description: 'Manages a list of items with size and uniqueness constraints.' 203 | )] 204 | public function manageList( 205 | #[Schema( 206 | type: 'array', 207 | description: 'List of items to manage (2-10 unique strings)', 208 | items: [ 209 | 'type' => 'string', 210 | 'minLength' => 1, 211 | 'maxLength' => 30 212 | ], 213 | minItems: 2, 214 | maxItems: 10, 215 | uniqueItems: true 216 | )] 217 | array $items, 218 | 219 | #[Schema( 220 | type: 'string', 221 | description: 'Action to perform on the list', 222 | enum: ['sort', 'reverse', 'shuffle', 'deduplicate', 'filter_short', 'filter_long'] 223 | )] 224 | string $action = 'sort' 225 | ): array { 226 | fwrite(STDERR, "Manage list tool called with " . count($items) . " items, action: $action\n"); 227 | 228 | $original = $items; 229 | $processed = $items; 230 | 231 | switch ($action) { 232 | case 'sort': 233 | sort($processed); 234 | break; 235 | case 'reverse': 236 | $processed = array_reverse($processed); 237 | break; 238 | case 'shuffle': 239 | shuffle($processed); 240 | break; 241 | case 'deduplicate': 242 | $processed = array_unique($processed); 243 | break; 244 | case 'filter_short': 245 | $processed = array_filter($processed, fn($item) => strlen($item) <= 10); 246 | break; 247 | case 'filter_long': 248 | $processed = array_filter($processed, fn($item) => strlen($item) > 10); 249 | break; 250 | } 251 | 252 | return [ 253 | 'original_count' => count($original), 254 | 'processed_count' => count($processed), 255 | 'action' => $action, 256 | 'original' => $original, 257 | 'processed' => array_values($processed), // Re-index array 258 | 'stats' => [ 259 | 'average_length' => count($processed) > 0 ? round(array_sum(array_map('strlen', $processed)) / count($processed), 2) : 0, 260 | 'shortest' => count($processed) > 0 ? min(array_map('strlen', $processed)) : 0, 261 | 'longest' => count($processed) > 0 ? max(array_map('strlen', $processed)) : 0, 262 | ] 263 | ]; 264 | } 265 | 266 | /** 267 | * Generates configuration with format validation. 268 | * Demonstrates: format constraints (date-time, uri, etc). 269 | */ 270 | #[McpTool( 271 | name: 'generate_config', 272 | description: 'Generates configuration with format-validated inputs.' 273 | )] 274 | public function generateConfig( 275 | #[Schema( 276 | type: 'string', 277 | description: 'Application name (alphanumeric with hyphens)', 278 | pattern: '^[a-zA-Z0-9\-]+$', 279 | minLength: 3, 280 | maxLength: 20 281 | )] 282 | string $appName, 283 | 284 | #[Schema( 285 | type: 'string', 286 | description: 'Valid URL for the application', 287 | format: 'uri' 288 | )] 289 | string $baseUrl, 290 | 291 | #[Schema( 292 | type: 'string', 293 | description: 'Environment type', 294 | enum: ['development', 'staging', 'production'] 295 | )] 296 | string $environment = 'development', 297 | 298 | #[Schema( 299 | type: 'boolean', 300 | description: 'Enable debug mode' 301 | )] 302 | bool $debug = true, 303 | 304 | #[Schema( 305 | type: 'integer', 306 | description: 'Port number (1024-65535)', 307 | minimum: 1024, 308 | maximum: 65535 309 | )] 310 | int $port = 8080 311 | ): array { 312 | fwrite(STDERR, "Generate config tool called for app: $appName\n"); 313 | 314 | $config = [ 315 | 'app' => [ 316 | 'name' => $appName, 317 | 'env' => $environment, 318 | 'debug' => $debug, 319 | 'url' => $baseUrl, 320 | 'port' => $port, 321 | ], 322 | 'generated_at' => date('c'), // ISO 8601 format 323 | 'version' => '1.0.0', 324 | 'features' => [ 325 | 'logging' => $environment !== 'production' || $debug, 326 | 'caching' => $environment === 'production', 327 | 'analytics' => $environment === 'production', 328 | 'rate_limiting' => $environment !== 'development', 329 | ] 330 | ]; 331 | 332 | return [ 333 | 'success' => true, 334 | 'config' => $config, 335 | 'validation' => [ 336 | 'app_name_valid' => preg_match('/^[a-zA-Z0-9\-]+$/', $appName) === 1, 337 | 'url_valid' => filter_var($baseUrl, FILTER_VALIDATE_URL) !== false, 338 | 'port_in_range' => $port >= 1024 && $port <= 65535, 339 | ] 340 | ]; 341 | } 342 | 343 | /** 344 | * Processes time-based data with date-time format validation. 345 | * Demonstrates: date-time format, exclusiveMinimum, exclusiveMaximum. 346 | */ 347 | #[McpTool( 348 | name: 'schedule_event', 349 | description: 'Schedules an event with time validation and constraints.' 350 | )] 351 | public function scheduleEvent( 352 | #[Schema( 353 | type: 'string', 354 | description: 'Event title (3-50 characters)', 355 | minLength: 3, 356 | maxLength: 50 357 | )] 358 | string $title, 359 | 360 | #[Schema( 361 | type: 'string', 362 | description: 'Event start time in ISO 8601 format', 363 | format: 'date-time' 364 | )] 365 | string $startTime, 366 | 367 | #[Schema( 368 | type: 'number', 369 | description: 'Duration in hours (minimum 0.5, maximum 24)', 370 | minimum: 0.5, 371 | maximum: 24, 372 | multipleOf: 0.5 373 | )] 374 | float $durationHours, 375 | 376 | #[Schema( 377 | type: 'string', 378 | description: 'Event priority level', 379 | enum: ['low', 'medium', 'high', 'urgent'] 380 | )] 381 | string $priority = 'medium', 382 | 383 | #[Schema( 384 | type: 'array', 385 | description: 'List of attendee email addresses', 386 | items: [ 387 | 'type' => 'string', 388 | 'format' => 'email' 389 | ], 390 | maxItems: 20 391 | )] 392 | array $attendees = [] 393 | ): array { 394 | fwrite(STDERR, "Schedule event tool called: $title at $startTime\n"); 395 | 396 | $start = DateTime::createFromFormat(DateTime::ISO8601, $startTime); 397 | if (!$start) { 398 | $start = DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $startTime); 399 | } 400 | 401 | if (!$start) { 402 | return [ 403 | 'success' => false, 404 | 'error' => 'Invalid date-time format. Use ISO 8601 format.', 405 | 'example' => '2024-01-15T14:30:00Z' 406 | ]; 407 | } 408 | 409 | $end = clone $start; 410 | $end->add(new DateInterval('PT' . ($durationHours * 60) . 'M')); 411 | 412 | $event = [ 413 | 'id' => uniqid('event_'), 414 | 'title' => $title, 415 | 'start_time' => $start->format('c'), 416 | 'end_time' => $end->format('c'), 417 | 'duration_hours' => $durationHours, 418 | 'priority' => $priority, 419 | 'attendees' => $attendees, 420 | 'created_at' => date('c') 421 | ]; 422 | 423 | return [ 424 | 'success' => true, 425 | 'event' => $event, 426 | 'info' => [ 427 | 'attendee_count' => count($attendees), 428 | 'is_all_day' => $durationHours >= 24, 429 | 'is_future' => $start > new DateTime(), 430 | 'timezone_note' => 'Times are in UTC' 431 | ] 432 | ]; 433 | } 434 | } 435 | ``` -------------------------------------------------------------------------------- /tests/Unit/Elements/RegisteredPromptTest.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Unit\Elements; 4 | 5 | use Mockery; 6 | use PhpMcp\Schema\Prompt as PromptSchema; 7 | use PhpMcp\Schema\PromptArgument; 8 | use PhpMcp\Server\Context; 9 | use PhpMcp\Server\Contracts\SessionInterface; 10 | use PhpMcp\Server\Elements\RegisteredPrompt; 11 | use PhpMcp\Schema\Content\PromptMessage; 12 | use PhpMcp\Schema\Enum\Role; 13 | use PhpMcp\Schema\Content\TextContent; 14 | use PhpMcp\Schema\Content\ImageContent; 15 | use PhpMcp\Schema\Content\AudioContent; 16 | use PhpMcp\Schema\Content\EmbeddedResource; 17 | use PhpMcp\Server\Tests\Fixtures\Enums\StatusEnum; 18 | use PhpMcp\Server\Tests\Fixtures\General\PromptHandlerFixture; 19 | use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture; 20 | use PhpMcp\Server\Tests\Unit\Attributes\TestEnum; 21 | use Psr\Container\ContainerInterface; 22 | 23 | beforeEach(function () { 24 | $this->container = Mockery::mock(ContainerInterface::class); 25 | $this->container->shouldReceive('get') 26 | ->with(PromptHandlerFixture::class) 27 | ->andReturn(new PromptHandlerFixture()) 28 | ->byDefault(); 29 | 30 | $this->promptSchema = PromptSchema::make( 31 | 'test-greeting-prompt', 32 | 'Generates a greeting.', 33 | [PromptArgument::make('name', 'The name to greet.', true)] 34 | ); 35 | 36 | $this->context = new Context(Mockery::mock(SessionInterface::class)); 37 | }); 38 | 39 | it('constructs correctly with schema, handler, and completion providers', function () { 40 | $providers = ['name' => CompletionProviderFixture::class]; 41 | $prompt = RegisteredPrompt::make( 42 | $this->promptSchema, 43 | [PromptHandlerFixture::class, 'promptWithArgumentCompletion'], 44 | false, 45 | $providers 46 | ); 47 | 48 | expect($prompt->schema)->toBe($this->promptSchema); 49 | expect($prompt->handler)->toBe([PromptHandlerFixture::class, 'promptWithArgumentCompletion']); 50 | expect($prompt->isManual)->toBeFalse(); 51 | expect($prompt->completionProviders)->toEqual($providers); 52 | expect($prompt->completionProviders['name'])->toBe(CompletionProviderFixture::class); 53 | expect($prompt->completionProviders)->not->toHaveKey('nonExistentArg'); 54 | }); 55 | 56 | it('can be made as a manual registration', function () { 57 | $manualPrompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'generateSimpleGreeting'], true); 58 | expect($manualPrompt->isManual)->toBeTrue(); 59 | }); 60 | 61 | it('calls handler with prepared arguments via get()', function () { 62 | $handlerMock = Mockery::mock(PromptHandlerFixture::class); 63 | $handlerMock->shouldReceive('generateSimpleGreeting') 64 | ->with('Alice', 'warm') 65 | ->once() 66 | ->andReturn([['role' => 'user', 'content' => 'Warm greeting for Alice.']]); 67 | $this->container->shouldReceive('get')->with(PromptHandlerFixture::class)->andReturn($handlerMock); 68 | 69 | $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'generateSimpleGreeting']); 70 | $messages = $prompt->get($this->container, ['name' => 'Alice', 'style' => 'warm'], $this->context); 71 | 72 | expect($messages[0]->content->text)->toBe('Warm greeting for Alice.'); 73 | }); 74 | 75 | it('formats single PromptMessage object from handler', function () { 76 | $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnSinglePromptMessageObject']); 77 | $messages = $prompt->get($this->container, [], $this->context); 78 | expect($messages)->toBeArray()->toHaveCount(1); 79 | expect($messages[0])->toBeInstanceOf(PromptMessage::class); 80 | expect($messages[0]->content->text)->toBe("Single PromptMessage object."); 81 | }); 82 | 83 | it('formats array of PromptMessage objects from handler as is', function () { 84 | $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnArrayOfPromptMessageObjects']); 85 | $messages = $prompt->get($this->container, [], $this->context); 86 | expect($messages)->toBeArray()->toHaveCount(2); 87 | expect($messages[0]->content->text)->toBe("First message object."); 88 | expect($messages[1]->content)->toBeInstanceOf(ImageContent::class); 89 | }); 90 | 91 | it('formats empty array from handler as empty array', function () { 92 | $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnEmptyArrayForPrompt']); 93 | $messages = $prompt->get($this->container, [], $this->context); 94 | expect($messages)->toBeArray()->toBeEmpty(); 95 | }); 96 | 97 | it('formats simple user/assistant map from handler', function () { 98 | $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnSimpleUserAssistantMap']); 99 | $messages = $prompt->get($this->container, [], $this->context); 100 | expect($messages)->toHaveCount(2); 101 | expect($messages[0]->role)->toBe(Role::User); 102 | expect($messages[0]->content->text)->toBe("This is the user's turn."); 103 | expect($messages[1]->role)->toBe(Role::Assistant); 104 | expect($messages[1]->content->text)->toBe("And this is the assistant's reply."); 105 | }); 106 | 107 | it('formats user/assistant map with Content objects', function () { 108 | $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithContentObjects']); 109 | $messages = $prompt->get($this->container, [], $this->context); 110 | expect($messages[0]->role)->toBe(Role::User); 111 | expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User text content object."); 112 | expect($messages[1]->role)->toBe(Role::Assistant); 113 | expect($messages[1]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("asst_img_data"); 114 | }); 115 | 116 | it('formats user/assistant map with mixed content (string and Content object)', function () { 117 | $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithMixedContent']); 118 | $messages = $prompt->get($this->container, [], $this->context); 119 | expect($messages[0]->role)->toBe(Role::User); 120 | expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("Plain user string."); 121 | expect($messages[1]->role)->toBe(Role::Assistant); 122 | expect($messages[1]->content)->toBeInstanceOf(AudioContent::class)->data->toBe("aud_data"); 123 | }); 124 | 125 | it('formats user/assistant map with array content', function () { 126 | $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithArrayContent']); 127 | $messages = $prompt->get($this->container, [], $this->context); 128 | expect($messages[0]->role)->toBe(Role::User); 129 | expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User array content"); 130 | expect($messages[1]->role)->toBe(Role::Assistant); 131 | expect($messages[1]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("asst_arr_img_data"); 132 | }); 133 | 134 | it('formats list of raw message arrays with various content types', function () { 135 | $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnListOfRawMessageArrays']); 136 | $messages = $prompt->get($this->container, [], $this->context); 137 | expect($messages)->toHaveCount(6); 138 | expect($messages[0]->content->text)->toBe("First raw message string."); 139 | expect($messages[1]->content)->toBeInstanceOf(TextContent::class); 140 | expect($messages[2]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("raw_img_data"); 141 | expect($messages[3]->content)->toBeInstanceOf(AudioContent::class)->data->toBe("raw_aud_data"); 142 | expect($messages[4]->content)->toBeInstanceOf(EmbeddedResource::class); 143 | expect($messages[4]->content->resource->blob)->toBe(base64_encode('pdf-data')); 144 | expect($messages[5]->content)->toBeInstanceOf(EmbeddedResource::class); 145 | expect($messages[5]->content->resource->text)->toBe('{"theme":"dark"}'); 146 | }); 147 | 148 | it('formats list of raw message arrays with scalar or array content (becoming JSON TextContent)', function () { 149 | $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnListOfRawMessageArraysWithScalars']); 150 | $messages = $prompt->get($this->container, [], $this->context); 151 | expect($messages)->toHaveCount(5); 152 | expect($messages[0]->content->text)->toBe("123"); 153 | expect($messages[1]->content->text)->toBe("true"); 154 | expect($messages[2]->content->text)->toBe("(null)"); 155 | expect($messages[3]->content->text)->toBe("3.14"); 156 | expect($messages[4]->content->text)->toBe(json_encode(['key' => 'value'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); 157 | }); 158 | 159 | it('formats mixed array of PromptMessage objects and raw message arrays', function () { 160 | $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnMixedArrayOfPromptMessagesAndRaw']); 161 | $messages = $prompt->get($this->container, [], $this->context); 162 | expect($messages)->toHaveCount(4); 163 | expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("This is a PromptMessage object."); 164 | expect($messages[1]->content)->toBeInstanceOf(TextContent::class)->text->toBe("This is a raw message array."); 165 | expect($messages[2]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("pm_img"); 166 | expect($messages[3]->content)->toBeInstanceOf(TextContent::class)->text->toBe("Raw message with typed content."); 167 | }); 168 | 169 | 170 | dataset('prompt_format_errors', [ 171 | 'non_array_return' => ['promptReturnsNonArray', '/Prompt generator method must return an array/'], 172 | 'invalid_role_in_array' => ['promptReturnsInvalidRole', "/Invalid role 'system'/"], 173 | 'invalid_content_structure_in_array' => ['promptReturnsArrayWithInvalidContentStructure', "/Invalid message format at index 0. Expected an array with 'role' and 'content' keys./"], // More specific from formatMessage 174 | 'invalid_typed_content_in_array' => ['promptReturnsArrayWithInvalidTypedContent', "/Invalid 'image' content at index 0: Missing or invalid 'data' string/"], 175 | 'invalid_resource_content_in_array' => ['promptReturnsArrayWithInvalidResourceContent', "/Invalid resource at index 0: Must contain 'text' or 'blob'./"], 176 | ]); 177 | 178 | it('throws RuntimeException for invalid prompt result formats', function (string|callable $handlerMethodOrCallable, string $expectedErrorPattern) { 179 | $methodName = is_string($handlerMethodOrCallable) ? $handlerMethodOrCallable : 'customReturn'; 180 | $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, $methodName]); 181 | 182 | if (is_callable($handlerMethodOrCallable)) { 183 | $this->container->shouldReceive('get')->with(PromptHandlerFixture::class)->andReturn( 184 | Mockery::mock(PromptHandlerFixture::class, [$methodName => $handlerMethodOrCallable()]) 185 | ); 186 | } 187 | 188 | try { 189 | $prompt->get($this->container, [], $this->context); 190 | } catch (\RuntimeException $e) { 191 | expect($e->getMessage())->toMatch($expectedErrorPattern); 192 | } 193 | 194 | expect($prompt->toArray())->toBeArray(); 195 | })->with('prompt_format_errors'); 196 | 197 | 198 | it('propagates exceptions from handler during get()', function () { 199 | $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'promptHandlerThrows']); 200 | $prompt->get($this->container, [], $this->context); 201 | })->throws(\LogicException::class, "Prompt generation failed inside handler."); 202 | 203 | 204 | it('can be serialized to array and deserialized with completion providers', function () { 205 | $schema = PromptSchema::make( 206 | 'serialize-prompt', 207 | 'Test SerDe', 208 | [PromptArgument::make('arg1', required: true), PromptArgument::make('arg2', 'description for arg2')] 209 | ); 210 | $providers = ['arg1' => CompletionProviderFixture::class]; 211 | $serializedProviders = ['arg1' => serialize(CompletionProviderFixture::class)]; 212 | $original = RegisteredPrompt::make( 213 | $schema, 214 | [PromptHandlerFixture::class, 'generateSimpleGreeting'], 215 | true, 216 | $providers 217 | ); 218 | 219 | $array = $original->toArray(); 220 | 221 | expect($array['schema']['name'])->toBe('serialize-prompt'); 222 | expect($array['schema']['arguments'])->toHaveCount(2); 223 | expect($array['handler'])->toBe([PromptHandlerFixture::class, 'generateSimpleGreeting']); 224 | expect($array['isManual'])->toBeTrue(); 225 | expect($array['completionProviders'])->toEqual($serializedProviders); 226 | 227 | $rehydrated = RegisteredPrompt::fromArray($array); 228 | expect($rehydrated)->toBeInstanceOf(RegisteredPrompt::class); 229 | expect($rehydrated->schema->name)->toEqual($original->schema->name); 230 | expect($rehydrated->isManual)->toBeTrue(); 231 | expect($rehydrated->completionProviders)->toEqual($providers); 232 | }); 233 | 234 | it('fromArray returns false on failure for prompt', function () { 235 | $badData = ['schema' => ['name' => 'fail']]; 236 | expect(RegisteredPrompt::fromArray($badData))->toBeFalse(); 237 | }); 238 | 239 | it('can be serialized with ListCompletionProvider instances', function () { 240 | $schema = PromptSchema::make( 241 | 'list-prompt', 242 | 'Test list completion', 243 | [PromptArgument::make('status')] 244 | ); 245 | $listProvider = new \PhpMcp\Server\Defaults\ListCompletionProvider(['draft', 'published', 'archived']); 246 | $providers = ['status' => $listProvider]; 247 | 248 | $original = RegisteredPrompt::make( 249 | $schema, 250 | [PromptHandlerFixture::class, 'generateSimpleGreeting'], 251 | true, 252 | $providers 253 | ); 254 | 255 | $array = $original->toArray(); 256 | expect($array['completionProviders']['status'])->toBeString(); // Serialized instance 257 | 258 | $rehydrated = RegisteredPrompt::fromArray($array); 259 | expect($rehydrated->completionProviders['status'])->toBeInstanceOf(\PhpMcp\Server\Defaults\ListCompletionProvider::class); 260 | }); 261 | 262 | it('can be serialized with EnumCompletionProvider instances', function () { 263 | $schema = PromptSchema::make( 264 | 'enum-prompt', 265 | 'Test enum completion', 266 | [PromptArgument::make('priority')] 267 | ); 268 | 269 | $enumProvider = new \PhpMcp\Server\Defaults\EnumCompletionProvider(StatusEnum::class); 270 | $providers = ['priority' => $enumProvider]; 271 | 272 | $original = RegisteredPrompt::make( 273 | $schema, 274 | [PromptHandlerFixture::class, 'generateSimpleGreeting'], 275 | true, 276 | $providers 277 | ); 278 | 279 | $array = $original->toArray(); 280 | expect($array['completionProviders']['priority'])->toBeString(); // Serialized instance 281 | 282 | $rehydrated = RegisteredPrompt::fromArray($array); 283 | expect($rehydrated->completionProviders['priority'])->toBeInstanceOf(\PhpMcp\Server\Defaults\EnumCompletionProvider::class); 284 | }); 285 | ``` -------------------------------------------------------------------------------- /tests/Integration/StdioServerTransportTest.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | use PhpMcp\Server\Protocol; 4 | use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture; 5 | use React\ChildProcess\Process; 6 | use React\EventLoop\Loop; 7 | use React\EventLoop\LoopInterface; 8 | use React\Promise\Deferred; 9 | use React\Promise\PromiseInterface; 10 | 11 | use function React\Async\await; 12 | 13 | const STDIO_SERVER_SCRIPT_PATH = __DIR__ . '/../Fixtures/ServerScripts/StdioTestServer.php'; 14 | const PROCESS_TIMEOUT_SECONDS = 5; 15 | 16 | function sendRequestToServer(Process $process, string $requestId, string $method, array $params = []): void 17 | { 18 | $request = json_encode([ 19 | 'jsonrpc' => '2.0', 20 | 'id' => $requestId, 21 | 'method' => $method, 22 | 'params' => $params, 23 | ]); 24 | $process->stdin->write($request . "\n"); 25 | } 26 | 27 | function sendNotificationToServer(Process $process, string $method, array $params = []): void 28 | { 29 | $notification = json_encode([ 30 | 'jsonrpc' => '2.0', 31 | 'method' => $method, 32 | 'params' => $params, 33 | ]); 34 | 35 | $process->stdin->write($notification . "\n"); 36 | } 37 | 38 | function readResponseFromServer(Process $process, string $expectedRequestId, LoopInterface $loop): PromiseInterface 39 | { 40 | $deferred = new Deferred(); 41 | $buffer = ''; 42 | 43 | $dataListener = function ($chunk) use (&$buffer, $deferred, $expectedRequestId, $process, &$dataListener) { 44 | $buffer .= $chunk; 45 | if (str_contains($buffer, "\n")) { 46 | $lines = explode("\n", $buffer); 47 | $buffer = array_pop($lines); 48 | 49 | foreach ($lines as $line) { 50 | if (empty(trim($line))) { 51 | continue; 52 | } 53 | try { 54 | $response = json_decode(trim($line), true); 55 | if (array_key_exists('id', $response) && $response['id'] == $expectedRequestId) { 56 | $process->stdout->removeListener('data', $dataListener); 57 | $deferred->resolve($response); 58 | return; 59 | } elseif (isset($response['method']) && str_starts_with($response['method'], 'notifications/')) { 60 | // It's a notification, log it or handle if necessary for a specific test, but don't resolve 61 | } 62 | } catch (\JsonException $e) { 63 | $process->stdout->removeListener('data', $dataListener); 64 | $deferred->reject(new \RuntimeException("Failed to decode JSON response: " . $line, 0, $e)); 65 | return; 66 | } 67 | } 68 | } 69 | }; 70 | 71 | $process->stdout->on('data', $dataListener); 72 | 73 | return timeout($deferred->promise(), PROCESS_TIMEOUT_SECONDS, $loop) 74 | ->catch(function ($reason) use ($expectedRequestId) { 75 | if ($reason instanceof \RuntimeException && str_contains($reason->getMessage(), 'Timed out after')) { 76 | throw new \RuntimeException("Timeout waiting for response to request ID '{$expectedRequestId}'"); 77 | } 78 | throw $reason; 79 | }) 80 | ->finally(function () use ($process, $dataListener) { 81 | $process->stdout->removeListener('data', $dataListener); 82 | }); 83 | } 84 | 85 | beforeEach(function () { 86 | $this->loop = Loop::get(); 87 | 88 | if (!is_executable(STDIO_SERVER_SCRIPT_PATH)) { 89 | chmod(STDIO_SERVER_SCRIPT_PATH, 0755); 90 | } 91 | 92 | $phpPath = PHP_BINARY ?: 'php'; 93 | $command = escapeshellarg($phpPath) . ' ' . escapeshellarg(STDIO_SERVER_SCRIPT_PATH); 94 | $this->process = new Process($command); 95 | $this->process->start($this->loop); 96 | 97 | $this->processErrorOutput = ''; 98 | $this->process->stderr->on('data', function ($chunk) { 99 | $this->processErrorOutput .= $chunk; 100 | }); 101 | }); 102 | 103 | afterEach(function () { 104 | if ($this->process instanceof Process && $this->process->isRunning()) { 105 | if ($this->process->stdin->isWritable()) { 106 | $this->process->stdin->end(); 107 | } 108 | $this->process->stdout->close(); 109 | $this->process->stdin->close(); 110 | $this->process->stderr->close(); 111 | $this->process->terminate(SIGTERM); 112 | await(delay(0.05, $this->loop)); 113 | if ($this->process->isRunning()) { 114 | $this->process->terminate(SIGKILL); 115 | } 116 | } 117 | $this->process = null; 118 | }); 119 | 120 | it('starts the stdio server, initializes, calls a tool, and closes', function () { 121 | // 1. Initialize Request 122 | sendRequestToServer($this->process, 'init-1', 'initialize', [ 123 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 124 | 'clientInfo' => ['name' => 'PestTestClient', 'version' => '1.0'], 125 | 'capabilities' => [] 126 | ]); 127 | $initResponse = await(readResponseFromServer($this->process, 'init-1', $this->loop)); 128 | 129 | expect($initResponse['id'])->toBe('init-1'); 130 | expect($initResponse)->not->toHaveKey('error'); 131 | expect($initResponse['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); 132 | expect($initResponse['result']['serverInfo']['name'])->toBe('StdioIntegrationTestServer'); 133 | 134 | // 2. Initialized Notification 135 | sendNotificationToServer($this->process, 'notifications/initialized'); 136 | 137 | await(delay(0.05, $this->loop)); 138 | 139 | // 3. Call a tool 140 | sendRequestToServer($this->process, 'tool-call-1', 'tools/call', [ 141 | 'name' => 'greet_stdio_tool', 142 | 'arguments' => ['name' => 'Integration Tester'] 143 | ]); 144 | $toolResponse = await(readResponseFromServer($this->process, 'tool-call-1', $this->loop)); 145 | 146 | expect($toolResponse['id'])->toBe('tool-call-1'); 147 | expect($toolResponse)->not->toHaveKey('error'); 148 | expect($toolResponse['result']['content'][0]['text'])->toBe('Hello, Integration Tester!'); 149 | expect($toolResponse['result']['isError'])->toBeFalse(); 150 | 151 | $this->process->stdin->end(); 152 | })->group('integration', 'stdio_transport'); 153 | 154 | it('can handle invalid JSON request from client', function () { 155 | $this->process->stdin->write("this is not json\n"); 156 | 157 | $response = await(readResponseFromServer($this->process, '', $this->loop)); 158 | 159 | expect($response['id'])->toBe(''); 160 | expect($response['error']['code'])->toBe(-32700); 161 | expect($response['error']['message'])->toContain('Invalid JSON'); 162 | 163 | $this->process->stdin->end(); 164 | })->group('integration', 'stdio_transport'); 165 | 166 | it('handles request for non-existent method', function () { 167 | sendRequestToServer($this->process, 'init-err', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); 168 | await(readResponseFromServer($this->process, 'init-err', $this->loop)); 169 | 170 | sendNotificationToServer($this->process, 'notifications/initialized'); 171 | await(delay(0.05, $this->loop)); 172 | 173 | sendRequestToServer($this->process, 'err-meth-1', 'non/existentMethod', []); 174 | $response = await(readResponseFromServer($this->process, 'err-meth-1', $this->loop)); 175 | 176 | expect($response['id'])->toBe('err-meth-1'); 177 | expect($response['error']['code'])->toBe(-32601); 178 | expect($response['error']['message'])->toContain("Method 'non/existentMethod' not found"); 179 | 180 | $this->process->stdin->end(); 181 | })->group('integration', 'stdio_transport'); 182 | 183 | it('can handle batch requests correctly', function () { 184 | // 1. Initialize 185 | sendRequestToServer($this->process, 'init-batch', 'initialize', [ 186 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 187 | 'clientInfo' => ['name' => 'BatchClient', 'version' => '1.0'], 188 | 'capabilities' => [] 189 | ]); 190 | await(readResponseFromServer($this->process, 'init-batch', $this->loop)); 191 | sendNotificationToServer($this->process, 'notifications/initialized'); 192 | await(delay(0.05, $this->loop)); 193 | 194 | // 2. Send Batch Request 195 | $batchRequests = [ 196 | ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_stdio_tool', 'arguments' => ['name' => 'Batch Item 1']]], 197 | ['jsonrpc' => '2.0', 'method' => 'notifications/something'], 198 | ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'greet_stdio_tool', 'arguments' => ['name' => 'Batch Item 2']]], 199 | ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] 200 | ]; 201 | 202 | $rawBatchRequest = json_encode($batchRequests); 203 | $this->process->stdin->write($rawBatchRequest . "\n"); 204 | 205 | // 3. Read Batch Response 206 | $batchResponsePromise = new Deferred(); 207 | $fullBuffer = ''; 208 | $batchDataListener = function ($chunk) use (&$fullBuffer, $batchResponsePromise, &$batchDataListener) { 209 | $fullBuffer .= $chunk; 210 | if (str_contains($fullBuffer, "\n")) { 211 | $line = trim($fullBuffer); 212 | $fullBuffer = ''; 213 | try { 214 | $decoded = json_decode($line, true); 215 | if (is_array($decoded)) { // Batch response is an array 216 | $this->process->stdout->removeListener('data', $batchDataListener); 217 | $batchResponsePromise->resolve($decoded); 218 | } 219 | } catch (\JsonException $e) { 220 | $this->process->stdout->removeListener('data', $batchDataListener); 221 | $batchResponsePromise->reject(new \RuntimeException("Batch JSON decode failed: " . $line, 0, $e)); 222 | } 223 | } 224 | }; 225 | $this->process->stdout->on('data', $batchDataListener); 226 | 227 | $batchResponseArray = await(timeout($batchResponsePromise->promise(), PROCESS_TIMEOUT_SECONDS, $this->loop)); 228 | 229 | expect($batchResponseArray)->toBeArray()->toHaveCount(3); // greet1, greet2, error 230 | 231 | $response1 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-1'))[0] ?? null; 232 | $response2 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-2'))[0] ?? null; 233 | $response3 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-3'))[0] ?? null; 234 | 235 | expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); 236 | expect($response2['result']['content'][0]['text'])->toBe('Hello, Batch Item 2!'); 237 | expect($response3['error']['code'])->toBe(-32601); 238 | expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); 239 | 240 | 241 | $this->process->stdin->end(); 242 | })->group('integration', 'stdio_transport'); 243 | 244 | it('can passes an empty context', function () { 245 | sendRequestToServer($this->process, 'init-context', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); 246 | await(readResponseFromServer($this->process, 'init-context', $this->loop)); 247 | sendNotificationToServer($this->process, 'notifications/initialized'); 248 | await(delay(0.05, $this->loop)); 249 | 250 | sendRequestToServer($this->process, 'tool-context-1', 'tools/call', [ 251 | 'name' => 'tool_reads_context', 252 | 'arguments' => [] 253 | ]); 254 | $toolResponse = await(readResponseFromServer($this->process, 'tool-context-1', $this->loop)); 255 | 256 | expect($toolResponse['id'])->toBe('tool-context-1'); 257 | expect($toolResponse)->not->toHaveKey('error'); 258 | expect($toolResponse['result']['content'][0]['text'])->toBe('No request instance present'); 259 | expect($toolResponse['result']['isError'])->toBeFalse(); 260 | 261 | $this->process->stdin->end(); 262 | })->group('integration', 'stdio_transport'); 263 | 264 | it('can handle tool list request', function () { 265 | sendRequestToServer($this->process, 'init-tool-list', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); 266 | await(readResponseFromServer($this->process, 'init-tool-list', $this->loop)); 267 | sendNotificationToServer($this->process, 'notifications/initialized'); 268 | await(delay(0.05, $this->loop)); 269 | 270 | sendRequestToServer($this->process, 'tool-list-1', 'tools/list', []); 271 | $toolListResponse = await(readResponseFromServer($this->process, 'tool-list-1', $this->loop)); 272 | 273 | expect($toolListResponse['id'])->toBe('tool-list-1'); 274 | expect($toolListResponse)->not->toHaveKey('error'); 275 | expect($toolListResponse['result']['tools'])->toBeArray()->toHaveCount(2); 276 | expect($toolListResponse['result']['tools'][0]['name'])->toBe('greet_stdio_tool'); 277 | expect($toolListResponse['result']['tools'][1]['name'])->toBe('tool_reads_context'); 278 | 279 | $this->process->stdin->end(); 280 | })->group('integration', 'stdio_transport'); 281 | 282 | it('can read a registered resource', function () { 283 | sendRequestToServer($this->process, 'init-res', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); 284 | await(readResponseFromServer($this->process, 'init-res', $this->loop)); 285 | sendNotificationToServer($this->process, 'notifications/initialized'); 286 | await(delay(0.05, $this->loop)); 287 | 288 | sendRequestToServer($this->process, 'res-read-1', 'resources/read', ['uri' => 'test://stdio/static']); 289 | $resourceResponse = await(readResponseFromServer($this->process, 'res-read-1', $this->loop)); 290 | 291 | expect($resourceResponse['id'])->toBe('res-read-1'); 292 | expect($resourceResponse)->not->toHaveKey('error'); 293 | expect($resourceResponse['result']['contents'])->toBeArray()->toHaveCount(1); 294 | expect($resourceResponse['result']['contents'][0]['uri'])->toBe('test://stdio/static'); 295 | expect($resourceResponse['result']['contents'][0]['text'])->toBe(ResourceHandlerFixture::$staticTextContent); 296 | expect($resourceResponse['result']['contents'][0]['mimeType'])->toBe('text/plain'); 297 | 298 | $this->process->stdin->end(); 299 | })->group('integration', 'stdio_transport'); 300 | 301 | it('can get a registered prompt', function () { 302 | sendRequestToServer($this->process, 'init-prompt', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); 303 | await(readResponseFromServer($this->process, 'init-prompt', $this->loop)); 304 | sendNotificationToServer($this->process, 'notifications/initialized'); 305 | await(delay(0.05, $this->loop)); 306 | 307 | sendRequestToServer($this->process, 'prompt-get-1', 'prompts/get', [ 308 | 'name' => 'simple_stdio_prompt', 309 | 'arguments' => ['name' => 'StdioPromptUser'] 310 | ]); 311 | $promptResponse = await(readResponseFromServer($this->process, 'prompt-get-1', $this->loop)); 312 | 313 | expect($promptResponse['id'])->toBe('prompt-get-1'); 314 | expect($promptResponse)->not->toHaveKey('error'); 315 | expect($promptResponse['result']['messages'])->toBeArray()->toHaveCount(1); 316 | expect($promptResponse['result']['messages'][0]['role'])->toBe('user'); 317 | expect($promptResponse['result']['messages'][0]['content']['text'])->toBe('Craft a friendly greeting for StdioPromptUser.'); 318 | 319 | $this->process->stdin->end(); 320 | })->group('integration', 'stdio_transport'); 321 | 322 | it('handles client not sending initialized notification before other requests', function () { 323 | sendRequestToServer($this->process, 'init-no-ack', 'initialize', [ 324 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 325 | 'clientInfo' => ['name' => 'ForgetfulClient', 'version' => '1.0'], 326 | 'capabilities' => [] 327 | ]); 328 | await(readResponseFromServer($this->process, 'init-no-ack', $this->loop)); 329 | // Client "forgets" to send notifications/initialized 330 | 331 | 332 | sendRequestToServer($this->process, 'tool-call-no-ack', 'tools/call', [ 333 | 'name' => 'greet_stdio_tool', 334 | 'arguments' => ['name' => 'NoAckUser'] 335 | ]); 336 | $toolResponse = await(readResponseFromServer($this->process, 'tool-call-no-ack', $this->loop)); 337 | 338 | expect($toolResponse['id'])->toBe('tool-call-no-ack'); 339 | expect($toolResponse['error']['code'])->toBe(-32600); 340 | expect($toolResponse['error']['message'])->toContain('Client session not initialized'); 341 | 342 | $this->process->stdin->end(); 343 | })->group('integration', 'stdio_transport'); 344 | ``` -------------------------------------------------------------------------------- /tests/Unit/Utils/SchemaValidatorTest.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Unit\Utils; 4 | 5 | use Mockery; 6 | use PhpMcp\Server\Utils\SchemaValidator; 7 | use Psr\Log\LoggerInterface; 8 | use PhpMcp\Server\Attributes\Schema; 9 | use PhpMcp\Server\Attributes\Schema\ArrayItems; 10 | use PhpMcp\Server\Attributes\Schema\Format; 11 | use PhpMcp\Server\Attributes\Schema\Property; 12 | 13 | // --- Setup --- 14 | beforeEach(function () { 15 | /** @var \Mockery\MockInterface&\Psr\Log\LoggerInterface */ 16 | $this->loggerMock = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); 17 | $this->validator = new SchemaValidator($this->loggerMock); 18 | }); 19 | 20 | // --- Helper Data & Schemas --- 21 | function getSimpleSchema(): array 22 | { 23 | return [ 24 | 'type' => 'object', 25 | 'properties' => [ 26 | 'name' => ['type' => 'string', 'description' => 'The name'], 27 | 'age' => ['type' => 'integer', 'minimum' => 0], 28 | 'active' => ['type' => 'boolean'], 29 | 'score' => ['type' => 'number'], 30 | 'items' => ['type' => 'array', 'items' => ['type' => 'string']], 31 | 'nullableValue' => ['type' => ['string', 'null']], 32 | 'optionalValue' => ['type' => 'string'], // Not required 33 | ], 34 | 'required' => ['name', 'age', 'active', 'score', 'items', 'nullableValue'], 35 | 'additionalProperties' => false, 36 | ]; 37 | } 38 | 39 | function getValidData(): array 40 | { 41 | return [ 42 | 'name' => 'Tester', 43 | 'age' => 30, 44 | 'active' => true, 45 | 'score' => 99.5, 46 | 'items' => ['a', 'b'], 47 | 'nullableValue' => null, 48 | 'optionalValue' => 'present', 49 | ]; 50 | } 51 | 52 | // --- Basic Validation Tests --- 53 | 54 | test('valid data passes validation', function () { 55 | $schema = getSimpleSchema(); 56 | $data = getValidData(); 57 | 58 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 59 | expect($errors)->toBeEmpty(); 60 | }); 61 | 62 | test('invalid type generates type error', function () { 63 | $schema = getSimpleSchema(); 64 | $data = getValidData(); 65 | $data['age'] = 'thirty'; // Invalid type 66 | 67 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 68 | expect($errors)->toHaveCount(1) 69 | ->and($errors[0]['pointer'])->toBe('/age') 70 | ->and($errors[0]['keyword'])->toBe('type') 71 | ->and($errors[0]['message'])->toContain('Expected `integer`'); 72 | }); 73 | 74 | test('missing required property generates required error', function () { 75 | $schema = getSimpleSchema(); 76 | $data = getValidData(); 77 | unset($data['name']); // Missing required 78 | 79 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 80 | expect($errors)->toHaveCount(1) 81 | ->and($errors[0]['keyword'])->toBe('required') 82 | ->and($errors[0]['message'])->toContain('Missing required properties: `name`'); 83 | }); 84 | 85 | test('additional property generates additionalProperties error', function () { 86 | $schema = getSimpleSchema(); 87 | $data = getValidData(); 88 | $data['extra'] = 'not allowed'; // Additional property 89 | 90 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 91 | expect($errors)->toHaveCount(1) 92 | ->and($errors[0]['pointer'])->toBe('/') // Error reported at the object root 93 | ->and($errors[0]['keyword'])->toBe('additionalProperties') 94 | ->and($errors[0]['message'])->toContain('Additional object properties are not allowed: ["extra"]'); 95 | }); 96 | 97 | // --- Keyword Constraint Tests --- 98 | 99 | test('enum constraint violation', function () { 100 | $schema = ['type' => 'string', 'enum' => ['A', 'B']]; 101 | $data = 'C'; 102 | 103 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 104 | expect($errors)->toHaveCount(1) 105 | ->and($errors[0]['keyword'])->toBe('enum') 106 | ->and($errors[0]['message'])->toContain('must be one of the allowed values: "A", "B"'); 107 | }); 108 | 109 | test('minimum constraint violation', function () { 110 | $schema = ['type' => 'integer', 'minimum' => 10]; 111 | $data = 5; 112 | 113 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 114 | expect($errors)->toHaveCount(1) 115 | ->and($errors[0]['keyword'])->toBe('minimum') 116 | ->and($errors[0]['message'])->toContain('must be greater than or equal to 10'); 117 | }); 118 | 119 | test('maxLength constraint violation', function () { 120 | $schema = ['type' => 'string', 'maxLength' => 5]; 121 | $data = 'toolong'; 122 | 123 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 124 | expect($errors)->toHaveCount(1) 125 | ->and($errors[0]['keyword'])->toBe('maxLength') 126 | ->and($errors[0]['message'])->toContain('Maximum string length is 5, found 7'); 127 | }); 128 | 129 | test('pattern constraint violation', function () { 130 | $schema = ['type' => 'string', 'pattern' => '^[a-z]+$']; 131 | $data = '123'; 132 | 133 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 134 | expect($errors)->toHaveCount(1) 135 | ->and($errors[0]['keyword'])->toBe('pattern') 136 | ->and($errors[0]['message'])->toContain('does not match the required pattern: `^[a-z]+$`'); 137 | }); 138 | 139 | test('minItems constraint violation', function () { 140 | $schema = ['type' => 'array', 'minItems' => 2]; 141 | $data = ['one']; 142 | 143 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 144 | expect($errors)->toHaveCount(1) 145 | ->and($errors[0]['keyword'])->toBe('minItems') 146 | ->and($errors[0]['message'])->toContain('Array should have at least 2 items, 1 found'); 147 | }); 148 | 149 | test('uniqueItems constraint violation', function () { 150 | $schema = ['type' => 'array', 'uniqueItems' => true]; 151 | $data = ['a', 'b', 'a']; 152 | 153 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 154 | expect($errors)->toHaveCount(1) 155 | ->and($errors[0]['keyword'])->toBe('uniqueItems') 156 | ->and($errors[0]['message'])->toContain('Array must have unique items'); 157 | }); 158 | 159 | // --- Nested Structures and Pointers --- 160 | test('nested object validation error pointer', function () { 161 | $schema = [ 162 | 'type' => 'object', 163 | 'properties' => [ 164 | 'user' => [ 165 | 'type' => 'object', 166 | 'properties' => ['id' => ['type' => 'integer']], 167 | 'required' => ['id'], 168 | ], 169 | ], 170 | 'required' => ['user'], 171 | ]; 172 | $data = ['user' => ['id' => 'abc']]; // Invalid nested type 173 | 174 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 175 | expect($errors)->toHaveCount(1) 176 | ->and($errors[0]['pointer'])->toBe('/user/id'); 177 | }); 178 | 179 | test('array item validation error pointer', function () { 180 | $schema = [ 181 | 'type' => 'array', 182 | 'items' => ['type' => 'integer'], 183 | ]; 184 | $data = [1, 2, 'three', 4]; // Invalid item type 185 | 186 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 187 | expect($errors)->toHaveCount(1) 188 | ->and($errors[0]['pointer'])->toBe('/2'); // Pointer to the index of the invalid item 189 | }); 190 | 191 | // --- Data Conversion Tests --- 192 | test('validates data passed as stdClass object', function () { 193 | $schema = getSimpleSchema(); 194 | $dataObj = json_decode(json_encode(getValidData())); // Convert to stdClass 195 | 196 | $errors = $this->validator->validateAgainstJsonSchema($dataObj, $schema); 197 | expect($errors)->toBeEmpty(); 198 | }); 199 | 200 | test('validates data with nested associative arrays correctly', function () { 201 | $schema = [ 202 | 'type' => 'object', 203 | 'properties' => [ 204 | 'nested' => [ 205 | 'type' => 'object', 206 | 'properties' => ['key' => ['type' => 'string']], 207 | 'required' => ['key'], 208 | ], 209 | ], 210 | 'required' => ['nested'], 211 | ]; 212 | $data = ['nested' => ['key' => 'value']]; // Nested assoc array 213 | 214 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 215 | expect($errors)->toBeEmpty(); 216 | }); 217 | 218 | // --- Edge Cases --- 219 | test('handles invalid schema structure gracefully', function () { 220 | $schema = ['type' => 'object', 'properties' => ['name' => ['type' => 123]]]; // Invalid type value 221 | $data = ['name' => 'test']; 222 | 223 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 224 | expect($errors)->toHaveCount(1) 225 | ->and($errors[0]['keyword'])->toBe('internal') 226 | ->and($errors[0]['message'])->toContain('Schema validation process failed'); 227 | }); 228 | 229 | test('handles empty data object against schema requiring properties', function () { 230 | $schema = getSimpleSchema(); // Requires name, age etc. 231 | $data = []; // Empty data 232 | 233 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 234 | 235 | expect($errors)->not->toBeEmpty() 236 | ->and($errors[0]['keyword'])->toBe('required'); 237 | }); 238 | 239 | test('handles empty schema (allows anything)', function () { 240 | $schema = []; // Empty schema object/array implies no constraints 241 | $data = ['anything' => [1, 2], 'goes' => true]; 242 | 243 | $errors = $this->validator->validateAgainstJsonSchema($data, $schema); 244 | 245 | expect($errors)->not->toBeEmpty() 246 | ->and($errors[0]['keyword'])->toBe('internal') 247 | ->and($errors[0]['message'])->toContain('Invalid schema'); 248 | }); 249 | 250 | test('validates schema with string format constraints from Schema attribute', function () { 251 | $emailSchema = (new Schema(format: 'email'))->toArray(); 252 | 253 | // Valid email 254 | $validErrors = $this->validator->validateAgainstJsonSchema('[email protected]', $emailSchema); 255 | expect($validErrors)->toBeEmpty(); 256 | 257 | // Invalid email 258 | $invalidErrors = $this->validator->validateAgainstJsonSchema('not-an-email', $emailSchema); 259 | expect($invalidErrors)->not->toBeEmpty() 260 | ->and($invalidErrors[0]['keyword'])->toBe('format') 261 | ->and($invalidErrors[0]['message'])->toContain('email'); 262 | }); 263 | 264 | test('validates schema with string length constraints from Schema attribute', function () { 265 | $passwordSchema = (new Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$'))->toArray(); 266 | 267 | // Valid password (meets length and pattern) 268 | $validErrors = $this->validator->validateAgainstJsonSchema('Password123', $passwordSchema); 269 | expect($validErrors)->toBeEmpty(); 270 | 271 | // Invalid - too short 272 | $shortErrors = $this->validator->validateAgainstJsonSchema('Pass1', $passwordSchema); 273 | expect($shortErrors)->not->toBeEmpty() 274 | ->and($shortErrors[0]['keyword'])->toBe('minLength'); 275 | 276 | // Invalid - no digit 277 | $noDigitErrors = $this->validator->validateAgainstJsonSchema('PasswordXYZ', $passwordSchema); 278 | expect($noDigitErrors)->not->toBeEmpty() 279 | ->and($noDigitErrors[0]['keyword'])->toBe('pattern'); 280 | }); 281 | 282 | test('validates schema with numeric constraints from Schema attribute', function () { 283 | $ageSchema = (new Schema(minimum: 18, maximum: 120))->toArray(); 284 | 285 | // Valid age 286 | $validErrors = $this->validator->validateAgainstJsonSchema(25, $ageSchema); 287 | expect($validErrors)->toBeEmpty(); 288 | 289 | // Invalid - too low 290 | $tooLowErrors = $this->validator->validateAgainstJsonSchema(15, $ageSchema); 291 | expect($tooLowErrors)->not->toBeEmpty() 292 | ->and($tooLowErrors[0]['keyword'])->toBe('minimum'); 293 | 294 | // Invalid - too high 295 | $tooHighErrors = $this->validator->validateAgainstJsonSchema(150, $ageSchema); 296 | expect($tooHighErrors)->not->toBeEmpty() 297 | ->and($tooHighErrors[0]['keyword'])->toBe('maximum'); 298 | }); 299 | 300 | test('validates schema with array constraints from Schema attribute', function () { 301 | $tagsSchema = (new Schema(uniqueItems: true, minItems: 2))->toArray(); 302 | 303 | // Valid tags array 304 | $validErrors = $this->validator->validateAgainstJsonSchema(['php', 'javascript', 'python'], $tagsSchema); 305 | expect($validErrors)->toBeEmpty(); 306 | 307 | // Invalid - duplicate items 308 | $duplicateErrors = $this->validator->validateAgainstJsonSchema(['php', 'php', 'javascript'], $tagsSchema); 309 | expect($duplicateErrors)->not->toBeEmpty() 310 | ->and($duplicateErrors[0]['keyword'])->toBe('uniqueItems'); 311 | 312 | // Invalid - too few items 313 | $tooFewErrors = $this->validator->validateAgainstJsonSchema(['php'], $tagsSchema); 314 | expect($tooFewErrors)->not->toBeEmpty() 315 | ->and($tooFewErrors[0]['keyword'])->toBe('minItems'); 316 | }); 317 | 318 | test('validates schema with object constraints from Schema attribute', function () { 319 | $userSchema = (new Schema( 320 | properties: [ 321 | 'name' => ['type' => 'string', 'minLength' => 2], 322 | 'email' => ['type' => 'string', 'format' => 'email'], 323 | 'age' => ['type' => 'integer', 'minimum' => 18] 324 | ], 325 | required: ['name', 'email'] 326 | ))->toArray(); 327 | 328 | // Valid user object 329 | $validUser = [ 330 | 'name' => 'John', 331 | 'email' => '[email protected]', 332 | 'age' => 25 333 | ]; 334 | $validErrors = $this->validator->validateAgainstJsonSchema($validUser, $userSchema); 335 | expect($validErrors)->toBeEmpty(); 336 | 337 | // Invalid - missing required email 338 | $missingEmailUser = [ 339 | 'name' => 'John', 340 | 'age' => 25 341 | ]; 342 | $missingErrors = $this->validator->validateAgainstJsonSchema($missingEmailUser, $userSchema); 343 | expect($missingErrors)->not->toBeEmpty() 344 | ->and($missingErrors[0]['keyword'])->toBe('required'); 345 | 346 | // Invalid - name too short 347 | $shortNameUser = [ 348 | 'name' => 'J', 349 | 'email' => '[email protected]', 350 | 'age' => 25 351 | ]; 352 | $nameErrors = $this->validator->validateAgainstJsonSchema($shortNameUser, $userSchema); 353 | expect($nameErrors)->not->toBeEmpty() 354 | ->and($nameErrors[0]['keyword'])->toBe('minLength'); 355 | 356 | // Invalid - age too low 357 | $youngUser = [ 358 | 'name' => 'John', 359 | 'email' => '[email protected]', 360 | 'age' => 15 361 | ]; 362 | $ageErrors = $this->validator->validateAgainstJsonSchema($youngUser, $userSchema); 363 | expect($ageErrors)->not->toBeEmpty() 364 | ->and($ageErrors[0]['keyword'])->toBe('minimum'); 365 | }); 366 | 367 | test('validates schema with nested constraints from Schema attribute', function () { 368 | $orderSchema = (new Schema( 369 | properties: [ 370 | 'customer' => [ 371 | 'type' => 'object', 372 | 'properties' => [ 373 | 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], 374 | 'name' => ['type' => 'string', 'minLength' => 2] 375 | ], 376 | ], 377 | 'items' => [ 378 | 'type' => 'array', 379 | 'minItems' => 1, 380 | 'items' => [ 381 | 'type' => 'object', 382 | 'properties' => [ 383 | 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], 384 | 'quantity' => ['type' => 'integer', 'minimum' => 1] 385 | ], 386 | 'required' => ['product_id', 'quantity'] 387 | ] 388 | ] 389 | ], 390 | required: ['customer', 'items'] 391 | ))->toArray(); 392 | 393 | // Valid order 394 | $validOrder = [ 395 | 'customer' => [ 396 | 'id' => 'CUS-123456', 397 | 'name' => 'John' 398 | ], 399 | 'items' => [ 400 | [ 401 | 'product_id' => 'PRD-1234', 402 | 'quantity' => 2 403 | ] 404 | ] 405 | ]; 406 | $validErrors = $this->validator->validateAgainstJsonSchema($validOrder, $orderSchema); 407 | expect($validErrors)->toBeEmpty(); 408 | 409 | // Invalid - bad customer ID format 410 | $badCustomerIdOrder = [ 411 | 'customer' => [ 412 | 'id' => 'CUST-123', // Wrong format 413 | 'name' => 'John' 414 | ], 415 | 'items' => [ 416 | [ 417 | 'product_id' => 'PRD-1234', 418 | 'quantity' => 2 419 | ] 420 | ] 421 | ]; 422 | $customerIdErrors = $this->validator->validateAgainstJsonSchema($badCustomerIdOrder, $orderSchema); 423 | expect($customerIdErrors)->not->toBeEmpty() 424 | ->and($customerIdErrors[0]['keyword'])->toBe('pattern'); 425 | 426 | // Invalid - empty items array 427 | $emptyItemsOrder = [ 428 | 'customer' => [ 429 | 'id' => 'CUS-123456', 430 | 'name' => 'John' 431 | ], 432 | 'items' => [] 433 | ]; 434 | $emptyItemsErrors = $this->validator->validateAgainstJsonSchema($emptyItemsOrder, $orderSchema); 435 | expect($emptyItemsErrors)->not->toBeEmpty() 436 | ->and($emptyItemsErrors[0]['keyword'])->toBe('minItems'); 437 | 438 | // Invalid - missing required property in items 439 | $missingProductIdOrder = [ 440 | 'customer' => [ 441 | 'id' => 'CUS-123456', 442 | 'name' => 'John' 443 | ], 444 | 'items' => [ 445 | [ 446 | // Missing product_id 447 | 'quantity' => 2 448 | ] 449 | ] 450 | ]; 451 | $missingProductIdErrors = $this->validator->validateAgainstJsonSchema($missingProductIdOrder, $orderSchema); 452 | expect($missingProductIdErrors)->not->toBeEmpty() 453 | ->and($missingProductIdErrors[0]['keyword'])->toBe('required'); 454 | }); 455 | ``` -------------------------------------------------------------------------------- /src/Registry.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server; 6 | 7 | use Evenement\EventEmitterInterface; 8 | use Evenement\EventEmitterTrait; 9 | use PhpMcp\Schema\Prompt; 10 | use PhpMcp\Schema\Resource; 11 | use PhpMcp\Schema\ResourceTemplate; 12 | use PhpMcp\Schema\Tool; 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\DefinitionException; 18 | use Psr\Log\LoggerInterface; 19 | use Psr\SimpleCache\CacheInterface; 20 | use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException; 21 | use Throwable; 22 | 23 | class Registry implements EventEmitterInterface 24 | { 25 | use EventEmitterTrait; 26 | 27 | private const DISCOVERED_ELEMENTS_CACHE_KEY = 'mcp_server_discovered_elements'; 28 | 29 | /** @var array<string, RegisteredTool> */ 30 | private array $tools = []; 31 | 32 | /** @var array<string, RegisteredResource> */ 33 | private array $resources = []; 34 | 35 | /** @var array<string, RegisteredPrompt> */ 36 | private array $prompts = []; 37 | 38 | /** @var array<string, RegisteredResourceTemplate> */ 39 | private array $resourceTemplates = []; 40 | 41 | private array $listHashes = [ 42 | 'tools' => '', 43 | 'resources' => '', 44 | 'resource_templates' => '', 45 | 'prompts' => '', 46 | ]; 47 | 48 | private bool $notificationsEnabled = true; 49 | 50 | public function __construct( 51 | protected LoggerInterface $logger, 52 | protected ?CacheInterface $cache = null, 53 | ) { 54 | $this->load(); 55 | $this->computeAllHashes(); 56 | } 57 | 58 | /** 59 | * Compute hashes for all lists for change detection 60 | */ 61 | private function computeAllHashes(): void 62 | { 63 | $this->listHashes['tools'] = $this->computeHash($this->tools); 64 | $this->listHashes['resources'] = $this->computeHash($this->resources); 65 | $this->listHashes['resource_templates'] = $this->computeHash($this->resourceTemplates); 66 | $this->listHashes['prompts'] = $this->computeHash($this->prompts); 67 | } 68 | 69 | /** 70 | * Compute a stable hash for a collection 71 | */ 72 | private function computeHash(array $collection): string 73 | { 74 | if (empty($collection)) { 75 | return ''; 76 | } 77 | 78 | ksort($collection); 79 | return md5(json_encode($collection)); 80 | } 81 | 82 | public function load(): void 83 | { 84 | if ($this->cache === null) { 85 | return; 86 | } 87 | 88 | $this->clear(false); 89 | 90 | try { 91 | $cached = $this->cache->get(self::DISCOVERED_ELEMENTS_CACHE_KEY); 92 | 93 | if (!is_array($cached)) { 94 | $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); 95 | return; 96 | } 97 | 98 | $loadCount = 0; 99 | 100 | foreach ($cached['tools'] ?? [] as $toolData) { 101 | $cachedTool = RegisteredTool::fromArray(json_decode($toolData, true)); 102 | if ($cachedTool === false) { 103 | $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); 104 | continue; 105 | } 106 | 107 | $toolName = $cachedTool->schema->name; 108 | $existingTool = $this->tools[$toolName] ?? null; 109 | 110 | if ($existingTool && $existingTool->isManual) { 111 | $this->logger->debug("Skipping cached tool '{$toolName}' as manual version exists."); 112 | continue; 113 | } 114 | 115 | $this->tools[$toolName] = $cachedTool; 116 | $loadCount++; 117 | } 118 | 119 | foreach ($cached['resources'] ?? [] as $resourceData) { 120 | $cachedResource = RegisteredResource::fromArray(json_decode($resourceData, true)); 121 | if ($cachedResource === false) { 122 | $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); 123 | continue; 124 | } 125 | 126 | $uri = $cachedResource->schema->uri; 127 | $existingResource = $this->resources[$uri] ?? null; 128 | 129 | if ($existingResource && $existingResource->isManual) { 130 | $this->logger->debug("Skipping cached resource '{$uri}' as manual version exists."); 131 | continue; 132 | } 133 | 134 | $this->resources[$uri] = $cachedResource; 135 | $loadCount++; 136 | } 137 | 138 | foreach ($cached['prompts'] ?? [] as $promptData) { 139 | $cachedPrompt = RegisteredPrompt::fromArray(json_decode($promptData, true)); 140 | if ($cachedPrompt === false) { 141 | $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); 142 | continue; 143 | } 144 | 145 | $promptName = $cachedPrompt->schema->name; 146 | $existingPrompt = $this->prompts[$promptName] ?? null; 147 | 148 | if ($existingPrompt && $existingPrompt->isManual) { 149 | $this->logger->debug("Skipping cached prompt '{$promptName}' as manual version exists."); 150 | continue; 151 | } 152 | 153 | $this->prompts[$promptName] = $cachedPrompt; 154 | $loadCount++; 155 | } 156 | 157 | foreach ($cached['resourceTemplates'] ?? [] as $templateData) { 158 | $cachedTemplate = RegisteredResourceTemplate::fromArray(json_decode($templateData, true)); 159 | if ($cachedTemplate === false) { 160 | $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); 161 | continue; 162 | } 163 | 164 | $uriTemplate = $cachedTemplate->schema->uriTemplate; 165 | $existingTemplate = $this->resourceTemplates[$uriTemplate] ?? null; 166 | 167 | if ($existingTemplate && $existingTemplate->isManual) { 168 | $this->logger->debug("Skipping cached template '{$uriTemplate}' as manual version exists."); 169 | continue; 170 | } 171 | 172 | $this->resourceTemplates[$uriTemplate] = $cachedTemplate; 173 | $loadCount++; 174 | } 175 | 176 | $this->logger->debug("Loaded {$loadCount} elements from cache."); 177 | } catch (CacheInvalidArgumentException $e) { 178 | $this->logger->error('Invalid registry cache key used.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); 179 | } catch (Throwable $e) { 180 | $this->logger->error('Unexpected error loading from cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); 181 | } 182 | } 183 | 184 | public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void 185 | { 186 | $toolName = $tool->name; 187 | $existing = $this->tools[$toolName] ?? null; 188 | 189 | if ($existing && ! $isManual && $existing->isManual) { 190 | $this->logger->debug("Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one."); 191 | 192 | return; 193 | } 194 | 195 | $this->tools[$toolName] = RegisteredTool::make($tool, $handler, $isManual); 196 | 197 | $this->checkAndEmitChange('tools', $this->tools); 198 | } 199 | 200 | public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void 201 | { 202 | $uri = $resource->uri; 203 | $existing = $this->resources[$uri] ?? null; 204 | 205 | if ($existing && ! $isManual && $existing->isManual) { 206 | $this->logger->debug("Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one."); 207 | 208 | return; 209 | } 210 | 211 | $this->resources[$uri] = RegisteredResource::make($resource, $handler, $isManual); 212 | 213 | $this->checkAndEmitChange('resources', $this->resources); 214 | } 215 | 216 | public function registerResourceTemplate( 217 | ResourceTemplate $template, 218 | callable|array|string $handler, 219 | array $completionProviders = [], 220 | bool $isManual = false, 221 | ): void { 222 | $uriTemplate = $template->uriTemplate; 223 | $existing = $this->resourceTemplates[$uriTemplate] ?? null; 224 | 225 | if ($existing && ! $isManual && $existing->isManual) { 226 | $this->logger->debug("Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one."); 227 | 228 | return; 229 | } 230 | 231 | $this->resourceTemplates[$uriTemplate] = RegisteredResourceTemplate::make($template, $handler, $isManual, $completionProviders); 232 | 233 | $this->checkAndEmitChange('resource_templates', $this->resourceTemplates); 234 | } 235 | 236 | public function registerPrompt( 237 | Prompt $prompt, 238 | callable|array|string $handler, 239 | array $completionProviders = [], 240 | bool $isManual = false, 241 | ): void { 242 | $promptName = $prompt->name; 243 | $existing = $this->prompts[$promptName] ?? null; 244 | 245 | if ($existing && ! $isManual && $existing->isManual) { 246 | $this->logger->debug("Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one."); 247 | 248 | return; 249 | } 250 | 251 | $this->prompts[$promptName] = RegisteredPrompt::make($prompt, $handler, $isManual, $completionProviders); 252 | 253 | $this->checkAndEmitChange('prompts', $this->prompts); 254 | } 255 | 256 | public function enableNotifications(): void 257 | { 258 | $this->notificationsEnabled = true; 259 | } 260 | 261 | public function disableNotifications(): void 262 | { 263 | $this->notificationsEnabled = false; 264 | } 265 | 266 | /** 267 | * Check if a list has changed and emit event if needed 268 | */ 269 | private function checkAndEmitChange(string $listType, array $collection): void 270 | { 271 | if (! $this->notificationsEnabled) { 272 | return; 273 | } 274 | 275 | $newHash = $this->computeHash($collection); 276 | 277 | if ($newHash !== $this->listHashes[$listType]) { 278 | $this->listHashes[$listType] = $newHash; 279 | $this->emit('list_changed', [$listType]); 280 | } 281 | } 282 | 283 | public function save(): bool 284 | { 285 | if ($this->cache === null) { 286 | return false; 287 | } 288 | 289 | $discoveredData = [ 290 | 'tools' => [], 291 | 'resources' => [], 292 | 'prompts' => [], 293 | 'resourceTemplates' => [], 294 | ]; 295 | 296 | foreach ($this->tools as $name => $tool) { 297 | if (! $tool->isManual) { 298 | if ($tool->handler instanceof \Closure) { 299 | $this->logger->warning("Skipping closure tool from cache: {$name}"); 300 | continue; 301 | } 302 | $discoveredData['tools'][$name] = json_encode($tool); 303 | } 304 | } 305 | 306 | foreach ($this->resources as $uri => $resource) { 307 | if (! $resource->isManual) { 308 | if ($resource->handler instanceof \Closure) { 309 | $this->logger->warning("Skipping closure resource from cache: {$uri}"); 310 | continue; 311 | } 312 | $discoveredData['resources'][$uri] = json_encode($resource); 313 | } 314 | } 315 | 316 | foreach ($this->prompts as $name => $prompt) { 317 | if (! $prompt->isManual) { 318 | if ($prompt->handler instanceof \Closure) { 319 | $this->logger->warning("Skipping closure prompt from cache: {$name}"); 320 | continue; 321 | } 322 | $discoveredData['prompts'][$name] = json_encode($prompt); 323 | } 324 | } 325 | 326 | foreach ($this->resourceTemplates as $uriTemplate => $template) { 327 | if (! $template->isManual) { 328 | if ($template->handler instanceof \Closure) { 329 | $this->logger->warning("Skipping closure template from cache: {$uriTemplate}"); 330 | continue; 331 | } 332 | $discoveredData['resourceTemplates'][$uriTemplate] = json_encode($template); 333 | } 334 | } 335 | 336 | try { 337 | $success = $this->cache->set(self::DISCOVERED_ELEMENTS_CACHE_KEY, $discoveredData); 338 | 339 | if ($success) { 340 | $this->logger->debug('Registry elements saved to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); 341 | } else { 342 | $this->logger->warning('Registry cache set operation returned false.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); 343 | } 344 | 345 | return $success; 346 | } catch (CacheInvalidArgumentException $e) { 347 | $this->logger->error('Invalid cache key or value during save.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); 348 | 349 | return false; 350 | } catch (Throwable $e) { 351 | $this->logger->error('Unexpected error saving to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); 352 | 353 | return false; 354 | } 355 | } 356 | 357 | /** Checks if any elements (manual or discovered) are currently registered. */ 358 | public function hasElements(): bool 359 | { 360 | return ! empty($this->tools) 361 | || ! empty($this->resources) 362 | || ! empty($this->prompts) 363 | || ! empty($this->resourceTemplates); 364 | } 365 | 366 | /** 367 | * Clear discovered elements from registry 368 | * 369 | * @param bool $includeCache Whether to clear the cache as well (default: true) 370 | */ 371 | public function clear(bool $includeCache = true): void 372 | { 373 | if ($includeCache && $this->cache !== null) { 374 | try { 375 | $this->cache->delete(self::DISCOVERED_ELEMENTS_CACHE_KEY); 376 | $this->logger->debug('Registry cache cleared.'); 377 | } catch (Throwable $e) { 378 | $this->logger->error('Error clearing registry cache.', ['exception' => $e]); 379 | } 380 | } 381 | 382 | $clearCount = 0; 383 | 384 | foreach ($this->tools as $name => $tool) { 385 | if (! $tool->isManual) { 386 | unset($this->tools[$name]); 387 | $clearCount++; 388 | } 389 | } 390 | foreach ($this->resources as $uri => $resource) { 391 | if (! $resource->isManual) { 392 | unset($this->resources[$uri]); 393 | $clearCount++; 394 | } 395 | } 396 | foreach ($this->prompts as $name => $prompt) { 397 | if (! $prompt->isManual) { 398 | unset($this->prompts[$name]); 399 | $clearCount++; 400 | } 401 | } 402 | foreach ($this->resourceTemplates as $uriTemplate => $template) { 403 | if (! $template->isManual) { 404 | unset($this->resourceTemplates[$uriTemplate]); 405 | $clearCount++; 406 | } 407 | } 408 | 409 | if ($clearCount > 0) { 410 | $this->logger->debug("Removed {$clearCount} discovered elements from internal registry."); 411 | } 412 | } 413 | 414 | /** @return RegisteredTool|null */ 415 | public function getTool(string $name): ?RegisteredTool 416 | { 417 | return $this->tools[$name] ?? null; 418 | } 419 | 420 | /** @return RegisteredResource|RegisteredResourceTemplate|null */ 421 | public function getResource(string $uri, bool $includeTemplates = true): RegisteredResource|RegisteredResourceTemplate|null 422 | { 423 | $registration = $this->resources[$uri] ?? null; 424 | if ($registration) { 425 | return $registration; 426 | } 427 | 428 | if (! $includeTemplates) { 429 | return null; 430 | } 431 | 432 | foreach ($this->resourceTemplates as $template) { 433 | if ($template->matches($uri)) { 434 | return $template; 435 | } 436 | } 437 | 438 | $this->logger->debug('No resource matched URI.', ['uri' => $uri]); 439 | 440 | return null; 441 | } 442 | 443 | /** @return RegisteredResourceTemplate|null */ 444 | public function getResourceTemplate(string $uriTemplate): ?RegisteredResourceTemplate 445 | { 446 | return $this->resourceTemplates[$uriTemplate] ?? null; 447 | } 448 | 449 | /** @return RegisteredPrompt|null */ 450 | public function getPrompt(string $name): ?RegisteredPrompt 451 | { 452 | return $this->prompts[$name] ?? null; 453 | } 454 | 455 | /** @return array<string, Tool> */ 456 | public function getTools(): array 457 | { 458 | return array_map(fn($tool) => $tool->schema, $this->tools); 459 | } 460 | 461 | /** @return array<string, Resource> */ 462 | public function getResources(): array 463 | { 464 | return array_map(fn($resource) => $resource->schema, $this->resources); 465 | } 466 | 467 | /** @return array<string, Prompt> */ 468 | public function getPrompts(): array 469 | { 470 | return array_map(fn($prompt) => $prompt->schema, $this->prompts); 471 | } 472 | 473 | /** @return array<string, ResourceTemplate> */ 474 | public function getResourceTemplates(): array 475 | { 476 | return array_map(fn($template) => $template->schema, $this->resourceTemplates); 477 | } 478 | } 479 | ``` -------------------------------------------------------------------------------- /src/Dispatcher.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server; 6 | 7 | use JsonException; 8 | use PhpMcp\Schema\JsonRpc\Request; 9 | use PhpMcp\Schema\JsonRpc\Notification; 10 | use PhpMcp\Schema\JsonRpc\Result; 11 | use PhpMcp\Schema\Notification\InitializedNotification; 12 | use PhpMcp\Schema\Request\CallToolRequest; 13 | use PhpMcp\Schema\Request\CompletionCompleteRequest; 14 | use PhpMcp\Schema\Request\GetPromptRequest; 15 | use PhpMcp\Schema\Request\InitializeRequest; 16 | use PhpMcp\Schema\Request\ListPromptsRequest; 17 | use PhpMcp\Schema\Request\ListResourcesRequest; 18 | use PhpMcp\Schema\Request\ListResourceTemplatesRequest; 19 | use PhpMcp\Schema\Request\ListToolsRequest; 20 | use PhpMcp\Schema\Request\PingRequest; 21 | use PhpMcp\Schema\Request\ReadResourceRequest; 22 | use PhpMcp\Schema\Request\ResourceSubscribeRequest; 23 | use PhpMcp\Schema\Request\ResourceUnsubscribeRequest; 24 | use PhpMcp\Schema\Request\SetLogLevelRequest; 25 | use PhpMcp\Server\Configuration; 26 | use PhpMcp\Server\Contracts\SessionInterface; 27 | use PhpMcp\Server\Exception\McpServerException; 28 | use PhpMcp\Schema\Content\TextContent; 29 | use PhpMcp\Schema\Result\CallToolResult; 30 | use PhpMcp\Schema\Result\CompletionCompleteResult; 31 | use PhpMcp\Schema\Result\EmptyResult; 32 | use PhpMcp\Schema\Result\GetPromptResult; 33 | use PhpMcp\Schema\Result\InitializeResult; 34 | use PhpMcp\Schema\Result\ListPromptsResult; 35 | use PhpMcp\Schema\Result\ListResourcesResult; 36 | use PhpMcp\Schema\Result\ListResourceTemplatesResult; 37 | use PhpMcp\Schema\Result\ListToolsResult; 38 | use PhpMcp\Schema\Result\ReadResourceResult; 39 | use PhpMcp\Server\Protocol; 40 | use PhpMcp\Server\Registry; 41 | use PhpMcp\Server\Session\SubscriptionManager; 42 | use PhpMcp\Server\Utils\SchemaValidator; 43 | use Psr\Container\ContainerInterface; 44 | use Psr\Log\LoggerInterface; 45 | use Throwable; 46 | 47 | class Dispatcher 48 | { 49 | protected ContainerInterface $container; 50 | protected LoggerInterface $logger; 51 | 52 | public function __construct( 53 | protected Configuration $configuration, 54 | protected Registry $registry, 55 | protected SubscriptionManager $subscriptionManager, 56 | protected ?SchemaValidator $schemaValidator = null, 57 | ) { 58 | $this->container = $this->configuration->container; 59 | $this->logger = $this->configuration->logger; 60 | 61 | $this->schemaValidator ??= new SchemaValidator($this->logger); 62 | } 63 | 64 | public function handleRequest(Request $request, Context $context): Result 65 | { 66 | switch ($request->method) { 67 | case 'initialize': 68 | $request = InitializeRequest::fromRequest($request); 69 | return $this->handleInitialize($request, $context->session); 70 | case 'ping': 71 | $request = PingRequest::fromRequest($request); 72 | return $this->handlePing($request); 73 | case 'tools/list': 74 | $request = ListToolsRequest::fromRequest($request); 75 | return $this->handleToolList($request); 76 | case 'tools/call': 77 | $request = CallToolRequest::fromRequest($request); 78 | return $this->handleToolCall($request, $context); 79 | case 'resources/list': 80 | $request = ListResourcesRequest::fromRequest($request); 81 | return $this->handleResourcesList($request); 82 | case 'resources/templates/list': 83 | $request = ListResourceTemplatesRequest::fromRequest($request); 84 | return $this->handleResourceTemplateList($request); 85 | case 'resources/read': 86 | $request = ReadResourceRequest::fromRequest($request); 87 | return $this->handleResourceRead($request, $context); 88 | case 'resources/subscribe': 89 | $request = ResourceSubscribeRequest::fromRequest($request); 90 | return $this->handleResourceSubscribe($request, $context->session); 91 | case 'resources/unsubscribe': 92 | $request = ResourceUnsubscribeRequest::fromRequest($request); 93 | return $this->handleResourceUnsubscribe($request, $context->session); 94 | case 'prompts/list': 95 | $request = ListPromptsRequest::fromRequest($request); 96 | return $this->handlePromptsList($request); 97 | case 'prompts/get': 98 | $request = GetPromptRequest::fromRequest($request); 99 | return $this->handlePromptGet($request, $context); 100 | case 'logging/setLevel': 101 | $request = SetLogLevelRequest::fromRequest($request); 102 | return $this->handleLoggingSetLevel($request, $context->session); 103 | case 'completion/complete': 104 | $request = CompletionCompleteRequest::fromRequest($request); 105 | return $this->handleCompletionComplete($request, $context->session); 106 | default: 107 | throw McpServerException::methodNotFound("Method '{$request->method}' not found."); 108 | } 109 | } 110 | 111 | public function handleNotification(Notification $notification, SessionInterface $session): void 112 | { 113 | switch ($notification->method) { 114 | case 'notifications/initialized': 115 | $notification = InitializedNotification::fromNotification($notification); 116 | $this->handleNotificationInitialized($notification, $session); 117 | } 118 | } 119 | 120 | public function handleInitialize(InitializeRequest $request, SessionInterface $session): InitializeResult 121 | { 122 | if (in_array($request->protocolVersion, Protocol::SUPPORTED_PROTOCOL_VERSIONS)) { 123 | $protocolVersion = $request->protocolVersion; 124 | } else { 125 | $protocolVersion = Protocol::LATEST_PROTOCOL_VERSION; 126 | } 127 | 128 | $session->set('client_info', $request->clientInfo->toArray()); 129 | $session->set('protocol_version', $protocolVersion); 130 | 131 | $serverInfo = $this->configuration->serverInfo; 132 | $capabilities = $this->configuration->capabilities; 133 | $instructions = $this->configuration->instructions; 134 | 135 | return new InitializeResult($protocolVersion, $capabilities, $serverInfo, $instructions); 136 | } 137 | 138 | public function handlePing(PingRequest $request): EmptyResult 139 | { 140 | return new EmptyResult(); 141 | } 142 | 143 | public function handleToolList(ListToolsRequest $request): ListToolsResult 144 | { 145 | $limit = $this->configuration->paginationLimit; 146 | $offset = $this->decodeCursor($request->cursor); 147 | $allItems = $this->registry->getTools(); 148 | $pagedItems = array_slice($allItems, $offset, $limit); 149 | $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); 150 | 151 | return new ListToolsResult(array_values($pagedItems), $nextCursor); 152 | } 153 | 154 | public function handleToolCall(CallToolRequest $request, Context $context): CallToolResult 155 | { 156 | $toolName = $request->name; 157 | $arguments = $request->arguments; 158 | 159 | $registeredTool = $this->registry->getTool($toolName); 160 | if (! $registeredTool) { 161 | throw McpServerException::methodNotFound("Tool '{$toolName}' not found."); 162 | } 163 | 164 | $inputSchema = $registeredTool->schema->inputSchema; 165 | 166 | $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema); 167 | 168 | if (! empty($validationErrors)) { 169 | $errorMessages = []; 170 | 171 | foreach ($validationErrors as $errorDetail) { 172 | $pointer = $errorDetail['pointer'] ?? ''; 173 | $message = $errorDetail['message'] ?? 'Unknown validation error'; 174 | $errorMessages[] = ($pointer !== '/' && $pointer !== '' ? "Property '{$pointer}': " : '') . $message; 175 | } 176 | 177 | $summaryMessage = "Invalid parameters for tool '{$toolName}': " . implode('; ', array_slice($errorMessages, 0, 3)); 178 | 179 | if (count($errorMessages) > 3) { 180 | $summaryMessage .= '; ...and more errors.'; 181 | } 182 | 183 | throw McpServerException::invalidParams($summaryMessage, data: ['validation_errors' => $validationErrors]); 184 | } 185 | 186 | try { 187 | $result = $registeredTool->call($this->container, $arguments, $context); 188 | 189 | return new CallToolResult($result, false); 190 | } catch (JsonException $e) { 191 | $this->logger->warning('Failed to JSON encode tool result.', ['tool' => $toolName, 'exception' => $e]); 192 | $errorMessage = "Failed to serialize tool result: {$e->getMessage()}"; 193 | 194 | return new CallToolResult([new TextContent($errorMessage)], true); 195 | } catch (Throwable $toolError) { 196 | $this->logger->error('Tool execution failed.', ['tool' => $toolName, 'exception' => $toolError]); 197 | $errorMessage = "Tool execution failed: {$toolError->getMessage()}"; 198 | 199 | return new CallToolResult([new TextContent($errorMessage)], true); 200 | } 201 | } 202 | 203 | public function handleResourcesList(ListResourcesRequest $request): ListResourcesResult 204 | { 205 | $limit = $this->configuration->paginationLimit; 206 | $offset = $this->decodeCursor($request->cursor); 207 | $allItems = $this->registry->getResources(); 208 | $pagedItems = array_slice($allItems, $offset, $limit); 209 | $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); 210 | 211 | return new ListResourcesResult(array_values($pagedItems), $nextCursor); 212 | } 213 | 214 | public function handleResourceTemplateList(ListResourceTemplatesRequest $request): ListResourceTemplatesResult 215 | { 216 | $limit = $this->configuration->paginationLimit; 217 | $offset = $this->decodeCursor($request->cursor); 218 | $allItems = $this->registry->getResourceTemplates(); 219 | $pagedItems = array_slice($allItems, $offset, $limit); 220 | $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); 221 | 222 | return new ListResourceTemplatesResult(array_values($pagedItems), $nextCursor); 223 | } 224 | 225 | public function handleResourceRead(ReadResourceRequest $request, Context $context): ReadResourceResult 226 | { 227 | $uri = $request->uri; 228 | 229 | $registeredResource = $this->registry->getResource($uri); 230 | 231 | if (! $registeredResource) { 232 | throw McpServerException::invalidParams("Resource URI '{$uri}' not found."); 233 | } 234 | 235 | try { 236 | $result = $registeredResource->read($this->container, $uri, $context); 237 | 238 | return new ReadResourceResult($result); 239 | } catch (JsonException $e) { 240 | $this->logger->warning('Failed to JSON encode resource content.', ['exception' => $e, 'uri' => $uri]); 241 | throw McpServerException::internalError("Failed to serialize resource content for '{$uri}'.", $e); 242 | } catch (McpServerException $e) { 243 | throw $e; 244 | } catch (Throwable $e) { 245 | $this->logger->error('Resource read failed.', ['uri' => $uri, 'exception' => $e]); 246 | throw McpServerException::resourceReadFailed($uri, $e); 247 | } 248 | } 249 | 250 | public function handleResourceSubscribe(ResourceSubscribeRequest $request, SessionInterface $session): EmptyResult 251 | { 252 | $this->subscriptionManager->subscribe($session->getId(), $request->uri); 253 | return new EmptyResult(); 254 | } 255 | 256 | public function handleResourceUnsubscribe(ResourceUnsubscribeRequest $request, SessionInterface $session): EmptyResult 257 | { 258 | $this->subscriptionManager->unsubscribe($session->getId(), $request->uri); 259 | return new EmptyResult(); 260 | } 261 | 262 | public function handlePromptsList(ListPromptsRequest $request): ListPromptsResult 263 | { 264 | $limit = $this->configuration->paginationLimit; 265 | $offset = $this->decodeCursor($request->cursor); 266 | $allItems = $this->registry->getPrompts(); 267 | $pagedItems = array_slice($allItems, $offset, $limit); 268 | $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); 269 | 270 | return new ListPromptsResult(array_values($pagedItems), $nextCursor); 271 | } 272 | 273 | public function handlePromptGet(GetPromptRequest $request, Context $context): GetPromptResult 274 | { 275 | $promptName = $request->name; 276 | $arguments = $request->arguments; 277 | 278 | $registeredPrompt = $this->registry->getPrompt($promptName); 279 | if (! $registeredPrompt) { 280 | throw McpServerException::invalidParams("Prompt '{$promptName}' not found."); 281 | } 282 | 283 | $arguments = (array) $arguments; 284 | 285 | foreach ($registeredPrompt->schema->arguments as $argDef) { 286 | if ($argDef->required && ! array_key_exists($argDef->name, $arguments)) { 287 | throw McpServerException::invalidParams("Missing required argument '{$argDef->name}' for prompt '{$promptName}'."); 288 | } 289 | } 290 | 291 | try { 292 | $result = $registeredPrompt->get($this->container, $arguments, $context); 293 | 294 | return new GetPromptResult($result, $registeredPrompt->schema->description); 295 | } catch (JsonException $e) { 296 | $this->logger->warning('Failed to JSON encode prompt messages.', ['exception' => $e, 'promptName' => $promptName]); 297 | throw McpServerException::internalError("Failed to serialize prompt messages for '{$promptName}'.", $e); 298 | } catch (McpServerException $e) { 299 | throw $e; 300 | } catch (Throwable $e) { 301 | $this->logger->error('Prompt generation failed.', ['promptName' => $promptName, 'exception' => $e]); 302 | throw McpServerException::promptGenerationFailed($promptName, $e); 303 | } 304 | } 305 | 306 | public function handleLoggingSetLevel(SetLogLevelRequest $request, SessionInterface $session): EmptyResult 307 | { 308 | $level = $request->level; 309 | 310 | $session->set('log_level', $level->value); 311 | 312 | $this->logger->info("Log level set to '{$level->value}'.", ['sessionId' => $session->getId()]); 313 | 314 | return new EmptyResult(); 315 | } 316 | 317 | public function handleCompletionComplete(CompletionCompleteRequest $request, SessionInterface $session): CompletionCompleteResult 318 | { 319 | $ref = $request->ref; 320 | $argumentName = $request->argument['name']; 321 | $currentValue = $request->argument['value']; 322 | 323 | $identifier = null; 324 | 325 | if ($ref->type === 'ref/prompt') { 326 | $identifier = $ref->name; 327 | $registeredPrompt = $this->registry->getPrompt($identifier); 328 | if (! $registeredPrompt) { 329 | throw McpServerException::invalidParams("Prompt '{$identifier}' not found."); 330 | } 331 | 332 | $foundArg = false; 333 | foreach ($registeredPrompt->schema->arguments as $arg) { 334 | if ($arg->name === $argumentName) { 335 | $foundArg = true; 336 | break; 337 | } 338 | } 339 | if (! $foundArg) { 340 | throw McpServerException::invalidParams("Argument '{$argumentName}' not found in prompt '{$identifier}'."); 341 | } 342 | 343 | return $registeredPrompt->complete($this->container, $argumentName, $currentValue, $session); 344 | } elseif ($ref->type === 'ref/resource') { 345 | $identifier = $ref->uri; 346 | $registeredResourceTemplate = $this->registry->getResourceTemplate($identifier); 347 | if (! $registeredResourceTemplate) { 348 | throw McpServerException::invalidParams("Resource template '{$identifier}' not found."); 349 | } 350 | 351 | $foundArg = false; 352 | foreach ($registeredResourceTemplate->getVariableNames() as $uriVariableName) { 353 | if ($uriVariableName === $argumentName) { 354 | $foundArg = true; 355 | break; 356 | } 357 | } 358 | 359 | if (! $foundArg) { 360 | throw McpServerException::invalidParams("URI variable '{$argumentName}' not found in resource template '{$identifier}'."); 361 | } 362 | 363 | return $registeredResourceTemplate->complete($this->container, $argumentName, $currentValue, $session); 364 | } else { 365 | throw McpServerException::invalidParams("Invalid ref type '{$ref->type}' for completion complete request."); 366 | } 367 | } 368 | 369 | public function handleNotificationInitialized(InitializedNotification $notification, SessionInterface $session): EmptyResult 370 | { 371 | $session->set('initialized', true); 372 | 373 | return new EmptyResult(); 374 | } 375 | 376 | private function decodeCursor(?string $cursor): int 377 | { 378 | if ($cursor === null) { 379 | return 0; 380 | } 381 | 382 | $decoded = base64_decode($cursor, true); 383 | if ($decoded === false) { 384 | $this->logger->warning('Received invalid pagination cursor (not base64)', ['cursor' => $cursor]); 385 | 386 | return 0; 387 | } 388 | 389 | if (preg_match('/^offset=(\d+)$/', $decoded, $matches)) { 390 | return (int) $matches[1]; 391 | } 392 | 393 | $this->logger->warning('Received invalid pagination cursor format', ['cursor' => $decoded]); 394 | 395 | return 0; 396 | } 397 | 398 | private function encodeNextCursor(int $currentOffset, int $returnedCount, int $totalCount, int $limit): ?string 399 | { 400 | $nextOffset = $currentOffset + $returnedCount; 401 | if ($returnedCount > 0 && $nextOffset < $totalCount) { 402 | return base64_encode("offset={$nextOffset}"); 403 | } 404 | 405 | return null; 406 | } 407 | } 408 | ```