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