#
tokens: 49302/50000 11/154 files (page 4/7)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 4/7FirstPrevNextLast