This is page 3 of 5. Use http://codebase.md/php-mcp/server?lines=false&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 -------------------------------------------------------------------------------- /src/Elements/RegisteredResourceTemplate.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Elements; use PhpMcp\Schema\Content\BlobResourceContents; use PhpMcp\Schema\Content\EmbeddedResource; use PhpMcp\Schema\Content\ResourceContents; use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\ResourceTemplate; use PhpMcp\Schema\Result\CompletionCompleteResult; use PhpMcp\Server\Context; use PhpMcp\Server\Contracts\SessionInterface; use Psr\Container\ContainerInterface; use Throwable; class RegisteredResourceTemplate extends RegisteredElement { protected array $variableNames; protected array $uriVariables; protected string $uriTemplateRegex; public function __construct( public readonly ResourceTemplate $schema, callable|array|string $handler, bool $isManual = false, public readonly array $completionProviders = [] ) { parent::__construct($handler, $isManual); $this->compileTemplate(); } public static function make(ResourceTemplate $schema, callable|array|string $handler, bool $isManual = false, array $completionProviders = []): self { return new self($schema, $handler, $isManual, $completionProviders); } /** * Gets the resource template. * * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects. */ public function read(ContainerInterface $container, string $uri, Context $context): array { $arguments = array_merge($this->uriVariables, ['uri' => $uri]); $result = $this->handle($container, $arguments, $context); return $this->formatResult($result, $uri, $this->schema->mimeType); } public function complete(ContainerInterface $container, string $argument, string $value, SessionInterface $session): CompletionCompleteResult { $providerClassOrInstance = $this->completionProviders[$argument] ?? null; if ($providerClassOrInstance === null) { return new CompletionCompleteResult([]); } if (is_string($providerClassOrInstance)) { if (! class_exists($providerClassOrInstance)) { throw new \RuntimeException("Completion provider class '{$providerClassOrInstance}' does not exist."); } $provider = $container->get($providerClassOrInstance); } else { $provider = $providerClassOrInstance; } $completions = $provider->getCompletions($value, $session); $total = count($completions); $hasMore = $total > 100; $pagedCompletions = array_slice($completions, 0, 100); return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); } public function getVariableNames(): array { return $this->variableNames; } public function matches(string $uri): bool { if (preg_match($this->uriTemplateRegex, $uri, $matches)) { $variables = []; foreach ($this->variableNames as $varName) { if (isset($matches[$varName])) { $variables[$varName] = $matches[$varName]; } } $this->uriVariables = $variables; return true; } return false; } private function compileTemplate(): void { $this->variableNames = []; $regexParts = []; $segments = preg_split('/(\{\w+\})/', $this->schema->uriTemplate, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); foreach ($segments as $segment) { if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) { $varName = $matches[1]; $this->variableNames[] = $varName; $regexParts[] = '(?P<' . $varName . '>[^/]+)'; } else { $regexParts[] = preg_quote($segment, '#'); } } $this->uriTemplateRegex = '#^' . implode('', $regexParts) . '$#'; } /** * Formats the raw result of a resource read operation into MCP ResourceContent items. * * @param mixed $readResult The raw result from the resource handler method. * @param string $uri The URI of the resource that was read. * @param ?string $defaultMimeType The default MIME type from the ResourceDefinition. * @return array<TextResourceContents|BlobResourceContents> Array of ResourceContents objects. * * @throws \RuntimeException If the result cannot be formatted. * * Supported result types: * - ResourceContent: Used as-is * - EmbeddedResource: Resource is extracted from the EmbeddedResource * - string: Converted to text content with guessed or provided MIME type * - stream resource: Read and converted to blob with provided MIME type * - array with 'blob' key: Used as blob content * - array with 'text' key: Used as text content * - SplFileInfo: Read and converted to blob * - array: Converted to JSON if MIME type is application/json or contains 'json' * For other MIME types, will try to convert to JSON with a warning */ protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array { if ($readResult instanceof ResourceContents) { return [$readResult]; } if ($readResult instanceof EmbeddedResource) { return [$readResult->resource]; } if (is_array($readResult)) { if (empty($readResult)) { return [TextResourceContents::make($uri, 'application/json', '[]')]; } $allAreResourceContents = true; $hasResourceContents = false; $allAreEmbeddedResource = true; $hasEmbeddedResource = false; foreach ($readResult as $item) { if ($item instanceof ResourceContents) { $hasResourceContents = true; $allAreEmbeddedResource = false; } elseif ($item instanceof EmbeddedResource) { $hasEmbeddedResource = true; $allAreResourceContents = false; } else { $allAreResourceContents = false; $allAreEmbeddedResource = false; } } if ($allAreResourceContents && $hasResourceContents) { return $readResult; } if ($allAreEmbeddedResource && $hasEmbeddedResource) { return array_map(fn($item) => $item->resource, $readResult); } if ($hasResourceContents || $hasEmbeddedResource) { $result = []; foreach ($readResult as $item) { if ($item instanceof ResourceContents) { $result[] = $item; } elseif ($item instanceof EmbeddedResource) { $result[] = $item->resource; } else { $result = array_merge($result, $this->formatResult($item, $uri, $mimeType)); } } return $result; } } if (is_string($readResult)) { $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); return [TextResourceContents::make($uri, $mimeType, $readResult)]; } if (is_resource($readResult) && get_resource_type($readResult) === 'stream') { $result = BlobResourceContents::fromStream( $uri, $readResult, $mimeType ?? 'application/octet-stream' ); @fclose($readResult); return [$result]; } if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])]; } if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; return [TextResourceContents::make($uri, $mimeType, $readResult['text'])]; } if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { if ($mimeType && str_contains(strtolower($mimeType), 'text')) { return [TextResourceContents::make($uri, $mimeType, file_get_contents($readResult->getPathname()))]; } return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)]; } if (is_array($readResult)) { if ($mimeType && (str_contains(strtolower($mimeType), 'json') || $mimeType === 'application/json')) { try { $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); return [TextResourceContents::make($uri, $mimeType, $jsonString)]; } catch (\JsonException $e) { throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); } } try { $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); $mimeType = $mimeType ?? 'application/json'; return [TextResourceContents::make($uri, $mimeType, $jsonString)]; } catch (\JsonException $e) { throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); } } throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult)); } /** Guesses MIME type from string content (very basic) */ private function guessMimeTypeFromString(string $content): string { $trimmed = ltrim($content); if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { if (str_contains($trimmed, '<html')) { return 'text/html'; } if (str_contains($trimmed, '<?xml')) { return 'application/xml'; } return 'text/plain'; } if (str_starts_with($trimmed, '{') && str_ends_with(rtrim($content), '}')) { return 'application/json'; } if (str_starts_with($trimmed, '[') && str_ends_with(rtrim($content), ']')) { return 'application/json'; } return 'text/plain'; } public function toArray(): array { $completionProviders = []; foreach ($this->completionProviders as $argument => $provider) { $completionProviders[$argument] = serialize($provider); } return [ 'schema' => $this->schema->toArray(), 'completionProviders' => $completionProviders, ...parent::toArray(), ]; } public static function fromArray(array $data): self|false { try { if (! isset($data['schema']) || ! isset($data['handler'])) { return false; } $completionProviders = []; foreach ($data['completionProviders'] ?? [] as $argument => $provider) { $completionProviders[$argument] = unserialize($provider); } return new self( ResourceTemplate::fromArray($data['schema']), $data['handler'], $data['isManual'] ?? false, $completionProviders, ); } catch (Throwable $e) { return false; } } } ``` -------------------------------------------------------------------------------- /tests/Unit/Elements/RegisteredResourceTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Elements; use Mockery; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use PhpMcp\Schema\Resource as ResourceSchema; use PhpMcp\Server\Context; use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Elements\RegisteredResource; use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\Content\BlobResourceContents; use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture; use Psr\Container\ContainerInterface; use PhpMcp\Server\Exception\McpServerException; uses(MockeryPHPUnitIntegration::class); beforeEach(function () { $this->container = Mockery::mock(ContainerInterface::class); $this->handlerInstance = new ResourceHandlerFixture(); $this->container->shouldReceive('get') ->with(ResourceHandlerFixture::class) ->andReturn($this->handlerInstance) ->byDefault(); $this->testUri = 'test://resource/item.txt'; $this->resourceSchema = ResourceSchema::make($this->testUri, 'test-resource', mimeType: 'text/plain'); $this->registeredResource = RegisteredResource::make( $this->resourceSchema, [ResourceHandlerFixture::class, 'returnStringText'] ); $this->context = new Context(Mockery::mock(SessionInterface::class)); }); afterEach(function () { if (ResourceHandlerFixture::$unlinkableSplFile && file_exists(ResourceHandlerFixture::$unlinkableSplFile)) { @unlink(ResourceHandlerFixture::$unlinkableSplFile); ResourceHandlerFixture::$unlinkableSplFile = null; } }); it('constructs correctly and exposes schema', function () { expect($this->registeredResource->schema)->toBe($this->resourceSchema); expect($this->registeredResource->handler)->toBe([ResourceHandlerFixture::class, 'returnStringText']); expect($this->registeredResource->isManual)->toBeFalse(); }); it('can be made as a manual registration', function () { $manualResource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnStringText'], true); expect($manualResource->isManual)->toBeTrue(); }); it('passes URI to handler if handler method expects it', function () { $resource = RegisteredResource::make( ResourceSchema::make($this->testUri, 'needs-uri'), [ResourceHandlerFixture::class, 'resourceHandlerNeedsUri'] ); $handlerMock = Mockery::mock(ResourceHandlerFixture::class); $handlerMock->shouldReceive('resourceHandlerNeedsUri') ->with($this->testUri) ->once() ->andReturn("Confirmed URI: {$this->testUri}"); $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock); $result = $resource->read($this->container, $this->testUri, $this->context); expect($result[0]->text)->toBe("Confirmed URI: {$this->testUri}"); }); it('does not require handler method to accept URI', function () { $resource = RegisteredResource::make( ResourceSchema::make($this->testUri, 'no-uri-param'), [ResourceHandlerFixture::class, 'resourceHandlerDoesNotNeedUri'] ); $handlerMock = Mockery::mock(ResourceHandlerFixture::class); $handlerMock->shouldReceive('resourceHandlerDoesNotNeedUri')->once()->andReturn("Success no URI"); $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock); $result = $resource->read($this->container, $this->testUri, $this->context); expect($result[0]->text)->toBe("Success no URI"); }); dataset('resource_handler_return_types', [ 'string_text' => ['returnStringText', 'text/plain', fn($text, $uri) => expect($text)->toBe("Plain string content for {$uri}"), null], 'string_json_guess' => ['returnStringJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_json'])->toBe($uri), null], 'string_html_guess' => ['returnStringHtml', 'text/html', fn($text, $uri) => expect($text)->toContain("<title>{$uri}</title>"), null], 'array_json_schema_mime' => ['returnArrayJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_array'])->toBe($uri), null], // schema has text/plain, overridden by array + JSON content 'empty_array' => ['returnEmptyArray', 'application/json', fn($text) => expect($text)->toBe('[]'), null], 'stream_octet' => ['returnStream', 'application/octet-stream', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Streamed content for {$uri}")], 'array_for_blob' => ['returnArrayForBlobSchema', 'application/x-custom-blob-array', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Blob for {$uri} via array")], 'array_for_text' => ['returnArrayForTextSchema', 'text/vnd.custom-array-text', fn($text, $uri) => expect($text)->toBe("Text from array for {$uri} via array"), null], 'direct_TextResourceContents' => ['returnTextResourceContents', 'text/special-contents', fn($text) => expect($text)->toBe('Direct TextResourceContents'), null], 'direct_BlobResourceContents' => ['returnBlobResourceContents', 'application/custom-blob-contents', null, fn($blob) => expect(base64_decode($blob ?? ''))->toBe('blobbycontents')], 'direct_EmbeddedResource' => ['returnEmbeddedResource', 'application/vnd.custom-embedded', fn($text) => expect($text)->toBe('Direct EmbeddedResource content'), null], ]); it('formats various handler return types correctly', function (string $handlerMethod, string $expectedMime, ?callable $textAssertion, ?callable $blobAssertion) { $schema = ResourceSchema::make($this->testUri, 'format-test'); $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, $handlerMethod]); $resultContents = $resource->read($this->container, $this->testUri, $this->context); expect($resultContents)->toBeArray()->toHaveCount(1); $content = $resultContents[0]; expect($content->uri)->toBe($this->testUri); expect($content->mimeType)->toBe($expectedMime); if ($textAssertion) { expect($content)->toBeInstanceOf(TextResourceContents::class); $textAssertion($content->text, $this->testUri); } if ($blobAssertion) { expect($content)->toBeInstanceOf(BlobResourceContents::class); $blobAssertion($content->blob, $this->testUri); } })->with('resource_handler_return_types'); it('formats SplFileInfo based on schema MIME type (text)', function () { $schema = ResourceSchema::make($this->testUri, 'spl-text', mimeType: 'text/markdown'); $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, 'returnSplFileInfo']); $result = $resource->read($this->container, $this->testUri, $this->context); expect($result[0])->toBeInstanceOf(TextResourceContents::class); expect($result[0]->mimeType)->toBe('text/markdown'); expect($result[0]->text)->toBe("Content from SplFileInfo for {$this->testUri}"); }); it('formats SplFileInfo based on schema MIME type (blob if not text like)', function () { $schema = ResourceSchema::make($this->testUri, 'spl-blob', mimeType: 'image/png'); $resource = RegisteredResource::make($schema, [ResourceHandlerFixture::class, 'returnSplFileInfo']); $result = $resource->read($this->container, $this->testUri, $this->context); expect($result[0])->toBeInstanceOf(BlobResourceContents::class); expect($result[0]->mimeType)->toBe('image/png'); expect(base64_decode($result[0]->blob ?? ''))->toBe("Content from SplFileInfo for {$this->testUri}"); }); it('formats array of ResourceContents as is', function () { $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnArrayOfResourceContents']); $results = $resource->read($this->container, $this->testUri, $this->context); expect($results)->toHaveCount(2); expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe('Part 1 of many RC'); expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('pngdata')); }); it('formats array of EmbeddedResources by extracting their inner resource', function () { $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnArrayOfEmbeddedResources']); $results = $resource->read($this->container, $this->testUri, $this->context); expect($results)->toHaveCount(2); expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe('<doc1/>'); expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('fontdata')); }); it('formats mixed array with ResourceContent/EmbeddedResource by processing each item', function () { $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnMixedArrayWithResourceTypes']); $results = $resource->read($this->container, $this->testUri, $this->context); expect($results)->toBeArray()->toHaveCount(4); expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe("A raw string piece"); expect($results[1])->toBeInstanceOf(TextResourceContents::class)->text->toBe("**Markdown!**"); expect($results[2])->toBeInstanceOf(TextResourceContents::class); expect(json_decode($results[2]->text, true))->toEqual(['nested_array_data' => 'value', 'for_uri' => $this->testUri]); expect($results[3])->toBeInstanceOf(TextResourceContents::class)->text->toBe("col1,col2"); }); it('propagates McpServerException from handler during read', function () { $resource = RegisteredResource::make( $this->resourceSchema, [ResourceHandlerFixture::class, 'resourceHandlerNeedsUri'] ); $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn( Mockery::mock(ResourceHandlerFixture::class, function (Mockery\MockInterface $mock) { $mock->shouldReceive('resourceHandlerNeedsUri')->andThrow(McpServerException::invalidParams("Test error")); }) ); $resource->read($this->container, $this->testUri, $this->context); })->throws(McpServerException::class, "Test error"); it('propagates other exceptions from handler during read', function () { $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'handlerThrowsException']); $resource->read($this->container, $this->testUri, $this->context); })->throws(\DomainException::class, "Cannot read resource"); it('throws RuntimeException for unformattable handler result', function () { $resource = RegisteredResource::make($this->resourceSchema, [ResourceHandlerFixture::class, 'returnUnformattableType']); $resource->read($this->container, $this->testUri, $this->context); })->throws(\RuntimeException::class, "Cannot format resource read result for URI"); it('can be serialized to array and deserialized', function () { $original = RegisteredResource::make( ResourceSchema::make( 'uri://test', 'my-resource', 'desc', 'app/foo', ), [ResourceHandlerFixture::class, 'getStaticText'], true ); $array = $original->toArray(); expect($array['schema']['uri'])->toBe('uri://test'); expect($array['schema']['name'])->toBe('my-resource'); expect($array['schema']['description'])->toBe('desc'); expect($array['schema']['mimeType'])->toBe('app/foo'); expect($array['handler'])->toBe([ResourceHandlerFixture::class, 'getStaticText']); expect($array['isManual'])->toBeTrue(); $rehydrated = RegisteredResource::fromArray($array); expect($rehydrated)->toBeInstanceOf(RegisteredResource::class); expect($rehydrated->schema->uri)->toEqual($original->schema->uri); expect($rehydrated->schema->name)->toEqual($original->schema->name); expect($rehydrated->isManual)->toBeTrue(); }); it('fromArray returns false on failure', function () { $badData = ['schema' => ['uri' => 'fail']]; expect(RegisteredResource::fromArray($badData))->toBeFalse(); }); ``` -------------------------------------------------------------------------------- /tests/Unit/Session/CacheSessionHandlerTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Session; use Mockery; use Mockery\MockInterface; use PhpMcp\Server\Session\CacheSessionHandler; use PhpMcp\Server\Contracts\SessionHandlerInterface; use Psr\SimpleCache\CacheInterface; use PhpMcp\Server\Tests\Mocks\Clock\FixedClock; const SESSION_ID_CACHE_1 = 'cache-session-id-1'; const SESSION_ID_CACHE_2 = 'cache-session-id-2'; const SESSION_ID_CACHE_3 = 'cache-session-id-3'; const SESSION_DATA_CACHE_1 = '{"id":"cs1","data":{"a":1,"b":"foo"}}'; const SESSION_DATA_CACHE_2 = '{"id":"cs2","data":{"x":true,"y":null}}'; const SESSION_DATA_CACHE_3 = '{"id":"cs3","data":"simple string data"}'; const DEFAULT_TTL_CACHE = 3600; const SESSION_INDEX_KEY_CACHE = 'mcp_session_index'; beforeEach(function () { $this->fixedClock = new FixedClock(); /** @var MockInterface&CacheInterface $cache */ $this->cache = Mockery::mock(CacheInterface::class); $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault(); $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true)->byDefault(); $this->handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); }); it('implements SessionHandlerInterface', function () { expect($this->handler)->toBeInstanceOf(SessionHandlerInterface::class); }); it('constructs with default TTL and SystemClock if no clock provided', function () { $cacheMock = Mockery::mock(CacheInterface::class); $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault(); $handler = new CacheSessionHandler($cacheMock); expect($handler->ttl)->toBe(DEFAULT_TTL_CACHE); $reflection = new \ReflectionClass($handler); $clockProp = $reflection->getProperty('clock'); $clockProp->setAccessible(true); expect($clockProp->getValue($handler))->toBeInstanceOf(\PhpMcp\Server\Defaults\SystemClock::class); }); it('constructs with a custom TTL and injected clock', function () { $customTtl = 7200; $clock = new FixedClock(); $cacheMock = Mockery::mock(CacheInterface::class); $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault(); $handler = new CacheSessionHandler($cacheMock, $customTtl, $clock); expect($handler->ttl)->toBe($customTtl); $reflection = new \ReflectionClass($handler); $clockProp = $reflection->getProperty('clock'); $clockProp->setAccessible(true); expect($clockProp->getValue($handler))->toBe($clock); }); it('loads session index from cache on construction', function () { $initialTimestamp = $this->fixedClock->now()->modify('-100 seconds')->getTimestamp(); $initialIndex = [SESSION_ID_CACHE_1 => $initialTimestamp]; $cacheMock = Mockery::mock(CacheInterface::class); $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn($initialIndex); new CacheSessionHandler($cacheMock, DEFAULT_TTL_CACHE, $this->fixedClock); }); it('reads session data from cache', function () { $sessionIndex = [SESSION_ID_CACHE_1 => $this->fixedClock->now()->modify('-100 seconds')->getTimestamp()]; $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn($sessionIndex); $this->cache->shouldReceive('get')->with(SESSION_ID_CACHE_1, false)->once()->andReturn(SESSION_DATA_CACHE_1); $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); $readData = $handler->read(SESSION_ID_CACHE_1); expect($readData)->toBe(SESSION_DATA_CACHE_1); }); it('returns false when reading non-existent session (cache get returns default)', function () { $this->cache->shouldReceive('get')->with('non-existent-id', false)->once()->andReturn(false); $readData = $this->handler->read('non-existent-id'); expect($readData)->toBeFalse(); }); it('writes session data to cache with correct key and TTL, and updates session index', function () { $expectedTimestamp = $this->fixedClock->now()->getTimestamp(); // 15:00:00 $this->cache->shouldReceive('set') ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_1 => $expectedTimestamp]) ->once()->andReturn(true); $this->cache->shouldReceive('set') ->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1) ->once()->andReturn(true); $writeResult = $this->handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1); expect($writeResult)->toBeTrue(); }); it('updates timestamp in session index for existing session on write', function () { $initialWriteTime = $this->fixedClock->now()->modify('-60 seconds')->getTimestamp(); // 14:59:00 $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([SESSION_ID_CACHE_1 => $initialWriteTime]); $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); $this->fixedClock->addSeconds(90); $expectedNewTimestamp = $this->fixedClock->now()->getTimestamp(); $this->cache->shouldReceive('set') ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_1 => $expectedNewTimestamp]) ->once()->andReturn(true); $this->cache->shouldReceive('set') ->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1) ->once()->andReturn(true); $handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1); }); it('returns false if cache set for session data fails', function () { $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true); $this->cache->shouldReceive('set')->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1) ->once()->andReturn(false); $writeResult = $this->handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1); expect($writeResult)->toBeFalse(); }); it('destroys session by removing from cache and updating index', function () { $initialTimestamp = $this->fixedClock->now()->getTimestamp(); $initialIndex = [SESSION_ID_CACHE_1 => $initialTimestamp, SESSION_ID_CACHE_2 => $initialTimestamp]; $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); $this->cache->shouldReceive('set') ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_2 => $initialTimestamp]) ->once()->andReturn(true); $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(true); $handler->destroy(SESSION_ID_CACHE_1); }); it('destroy returns true if session ID not in index (cache delete still called)', function () { $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([]); // Empty index $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE); $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true); // Index remains empty $this->cache->shouldReceive('delete')->with('non-existent-id')->once()->andReturn(true); // Cache delete for data $destroyResult = $handler->destroy('non-existent-id'); expect($destroyResult)->toBeTrue(); }); it('destroy returns false if cache delete for session data fails', function () { $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([SESSION_ID_CACHE_1 => time()]); $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE); $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true); // Index update $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(false); // Data delete fails $destroyResult = $handler->destroy(SESSION_ID_CACHE_1); expect($destroyResult)->toBeFalse(); }); it('garbage collects only sessions older than maxLifetime from cache and index', function () { $maxLifetime = 120; $initialIndex = [ SESSION_ID_CACHE_1 => $this->fixedClock->now()->modify('-60 seconds')->getTimestamp(), SESSION_ID_CACHE_2 => $this->fixedClock->now()->modify("-{$maxLifetime} seconds -10 seconds")->getTimestamp(), SESSION_ID_CACHE_3 => $this->fixedClock->now()->modify('-1000 seconds')->getTimestamp(), ]; $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_2)->once()->andReturn(true); $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_3)->once()->andReturn(true); $this->cache->shouldNotReceive('delete')->with(SESSION_ID_CACHE_1); $expectedFinalIndex = [SESSION_ID_CACHE_1 => $initialIndex[SESSION_ID_CACHE_1]]; $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $expectedFinalIndex)->once()->andReturn(true); $deletedSessionIds = $handler->gc($maxLifetime); expect($deletedSessionIds)->toBeArray()->toHaveCount(2) ->and($deletedSessionIds)->toContain(SESSION_ID_CACHE_2) ->and($deletedSessionIds)->toContain(SESSION_ID_CACHE_3); }); it('garbage collection respects maxLifetime precisely for cache handler', function () { $maxLifetime = 60; $sessionTimestamp = $this->fixedClock->now()->modify("-{$maxLifetime} seconds")->getTimestamp(); $initialIndex = [SESSION_ID_CACHE_1 => $sessionTimestamp]; $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); $this->cache->shouldNotReceive('delete'); $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $initialIndex)->once()->andReturn(true); $deleted = $handler->gc($maxLifetime); expect($deleted)->toBeEmpty(); $this->fixedClock->addSeconds(1); $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); $handlerAfterTimeAdvance = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(true); $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true); $deleted2 = $handlerAfterTimeAdvance->gc($maxLifetime); expect($deleted2)->toEqual([SESSION_ID_CACHE_1]); }); it('garbage collection handles an empty session index', function () { $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([]); $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE); $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true); $this->cache->shouldNotReceive('delete'); $deletedSessions = $handler->gc(DEFAULT_TTL_CACHE); expect($deletedSessions)->toBeArray()->toBeEmpty(); }); it('garbage collection continues updating index even if a cache delete fails', function () { $maxLifetime = 60; $initialIndex = [ 'expired_deleted_ok' => $this->fixedClock->now()->modify("-70 seconds")->getTimestamp(), 'expired_delete_fails' => $this->fixedClock->now()->modify("-80 seconds")->getTimestamp(), 'survivor' => $this->fixedClock->now()->modify('-30 seconds')->getTimestamp(), ]; $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); $this->cache->shouldReceive('delete')->with('expired_deleted_ok')->once()->andReturn(true); $this->cache->shouldReceive('delete')->with('expired_delete_fails')->once()->andReturn(false); $expectedFinalIndex = ['survivor' => $initialIndex['survivor']]; $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $expectedFinalIndex)->once()->andReturn(true); $deletedSessionIds = $handler->gc($maxLifetime); expect($deletedSessionIds)->toHaveCount(2)->toContain('expired_deleted_ok')->toContain('expired_delete_fails'); }); ``` -------------------------------------------------------------------------------- /tests/Fixtures/Utils/SchemaGeneratorFixture.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Fixtures\Utils; use PhpMcp\Server\Attributes\Schema; use PhpMcp\Server\Context; use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum; use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum; use stdClass; /** * Comprehensive fixture for testing SchemaGenerator with various scenarios. */ class SchemaGeneratorFixture { // ===== BASIC SCENARIOS ===== public function noParams(): void { } /** * Type hints only - no Schema attributes. */ public function typeHintsOnly(string $name, int $age, bool $active, array $tags, ?stdClass $config = null): void { } /** * DocBlock types only - no PHP type hints, no Schema attributes. * @param string $username The username * @param int $count Number of items * @param bool $enabled Whether enabled * @param array $data Some data */ public function docBlockOnly($username, $count, $enabled, $data): void { } /** * Type hints with DocBlock descriptions. * @param string $email User email address * @param int $score User score * @param bool $verified Whether user is verified */ public function typeHintsWithDocBlock(string $email, int $score, bool $verified): void { } public function contextParameter(Context $context): void { } // ===== METHOD-LEVEL SCHEMA SCENARIOS ===== /** * Method-level Schema with complete definition. */ #[Schema(definition: [ 'type' => 'object', 'description' => 'Creates a custom filter with complete definition', 'properties' => [ 'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']], 'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']], 'value' => ['description' => 'Value to filter by, type depends on field and operator'] ], 'required' => ['field', 'operator', 'value'], 'if' => [ 'properties' => ['field' => ['const' => 'date']] ], 'then' => [ 'properties' => ['value' => ['type' => 'string', 'format' => 'date']] ] ])] public function methodLevelCompleteDefinition(string $field, string $operator, mixed $value): array { return compact('field', 'operator', 'value'); } /** * Method-level Schema defining properties. */ #[Schema( description: "Creates a new user with detailed information.", properties: [ 'username' => ['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'], 'email' => ['type' => 'string', 'format' => 'email'], 'age' => ['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'], 'isActive' => ['type' => 'boolean', 'default' => true] ], required: ['username', 'email'] )] public function methodLevelWithProperties(string $username, string $email, int $age, bool $isActive = true): array { return compact('username', 'email', 'age', 'isActive'); } /** * Method-level Schema for complex array argument. */ #[Schema( properties: [ 'profiles' => [ 'type' => 'array', 'description' => 'An array of user profiles to update.', 'minItems' => 1, 'items' => [ 'type' => 'object', 'properties' => [ 'id' => ['type' => 'integer'], 'data' => ['type' => 'object', 'additionalProperties' => true] ], 'required' => ['id', 'data'] ] ] ], required: ['profiles'] )] public function methodLevelArrayArgument(array $profiles): array { return ['updated_count' => count($profiles)]; } // ===== PARAMETER-LEVEL SCHEMA SCENARIOS ===== /** * Parameter-level Schema attributes only. */ public function parameterLevelOnly( #[Schema(description: "Recipient ID", pattern: "^user_")] string $recipientId, #[Schema(maxLength: 1024)] string $messageBody, #[Schema(type: 'integer', enum: [1, 2, 5])] int $priority = 1, #[Schema( type: 'object', properties: [ 'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']], 'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'] ], required: ['type'] )] ?array $notificationConfig = null ): array { return compact('recipientId', 'messageBody', 'priority', 'notificationConfig'); } /** * Parameter-level Schema with string constraints. */ public function parameterStringConstraints( #[Schema(format: 'email')] string $email, #[Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$')] string $password, string $regularString ): void { } /** * Parameter-level Schema with numeric constraints. */ public function parameterNumericConstraints( #[Schema(minimum: 18, maximum: 120)] int $age, #[Schema(minimum: 0, maximum: 5, exclusiveMaximum: true)] float $rating, #[Schema(multipleOf: 10)] int $count ): void { } /** * Parameter-level Schema with array constraints. */ public function parameterArrayConstraints( #[Schema(type: 'array', items: ['type' => 'string'], minItems: 1, uniqueItems: true)] array $tags, #[Schema(type: 'array', items: ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], minItems: 1, maxItems: 5)] array $scores ): void { } // ===== COMBINED SCENARIOS ===== /** * Method-level + Parameter-level Schema combination. * @param string $settingKey The key of the setting * @param mixed $newValue The new value for the setting */ #[Schema( properties: [ 'settingKey' => ['type' => 'string', 'description' => 'The key of the setting.'], 'newValue' => ['description' => 'The new value for the setting (any type).'] ], required: ['settingKey', 'newValue'] )] public function methodAndParameterLevel( string $settingKey, #[Schema(description: "The specific new boolean value.", type: 'boolean')] mixed $newValue ): array { return compact('settingKey', 'newValue'); } /** * Type hints + DocBlock + Parameter-level Schema. * @param string $username The user's name * @param int $priority Task priority level */ public function typeHintDocBlockAndParameterSchema( #[Schema(minLength: 3, pattern: '^[a-zA-Z0-9_]+$')] string $username, #[Schema(minimum: 1, maximum: 10)] int $priority ): void { } // ===== ENUM SCENARIOS ===== /** * Various enum parameter types. * @param BackedStringEnum $stringEnum Backed string enum * @param BackedIntEnum $intEnum Backed int enum * @param UnitEnum $unitEnum Unit enum */ public function enumParameters( BackedStringEnum $stringEnum, BackedIntEnum $intEnum, UnitEnum $unitEnum, ?BackedStringEnum $nullableEnum = null, BackedIntEnum $enumWithDefault = BackedIntEnum::First ): void { } // ===== ARRAY TYPE SCENARIOS ===== /** * Various array type scenarios. * @param array $genericArray Generic array * @param string[] $stringArray Array of strings * @param int[] $intArray Array of integers * @param array<string, mixed> $mixedMap Mixed array map * @param array{name: string, age: int} $objectLikeArray Object-like array * @param array{user: array{id: int, name: string}, items: int[]} $nestedObjectArray Nested object array */ public function arrayTypeScenarios( array $genericArray, array $stringArray, array $intArray, array $mixedMap, array $objectLikeArray, array $nestedObjectArray ): void { } // ===== NULLABLE AND OPTIONAL SCENARIOS ===== /** * Nullable and optional parameter scenarios. * @param string|null $nullableString Nullable string * @param int|null $nullableInt Nullable integer */ public function nullableAndOptional( ?string $nullableString, ?int $nullableInt = null, string $optionalString = 'default', bool $optionalBool = true, array $optionalArray = [] ): void { } // ===== UNION TYPE SCENARIOS ===== /** * Union type parameters. * @param string|int $stringOrInt String or integer * @param bool|string|null $multiUnion Bool, string or null */ public function unionTypes( string|int $stringOrInt, bool|string|null $multiUnion ): void { } // ===== VARIADIC SCENARIOS ===== /** * Variadic parameter scenarios. * @param string ...$items Variadic strings */ public function variadicStrings(string ...$items): void { } /** * Variadic with Schema constraints. * @param int ...$numbers Variadic integers */ public function variadicWithConstraints( #[Schema(items: ['type' => 'integer', 'minimum' => 0])] int ...$numbers ): void { } // ===== MIXED TYPE SCENARIOS ===== /** * Mixed type scenarios. * @param mixed $anyValue Any value * @param mixed $optionalAny Optional any value */ public function mixedTypes( mixed $anyValue, mixed $optionalAny = 'default' ): void { } // ===== COMPLEX NESTED SCENARIOS ===== /** * Complex nested Schema constraints. */ public function complexNestedSchema( #[Schema( type: 'object', properties: [ 'customer' => [ 'type' => 'object', 'properties' => [ 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], 'name' => ['type' => 'string', 'minLength' => 2], 'email' => ['type' => 'string', 'format' => 'email'] ], 'required' => ['id', 'name'] ], 'items' => [ 'type' => 'array', 'minItems' => 1, 'items' => [ 'type' => 'object', 'properties' => [ 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], 'quantity' => ['type' => 'integer', 'minimum' => 1], 'price' => ['type' => 'number', 'minimum' => 0] ], 'required' => ['product_id', 'quantity', 'price'] ] ], 'metadata' => [ 'type' => 'object', 'additionalProperties' => true ] ], required: ['customer', 'items'] )] array $order ): array { return ['order_id' => uniqid()]; } // ===== TYPE PRECEDENCE SCENARIOS ===== /** * Testing type precedence between PHP, DocBlock, and Schema. * @param integer $numericString DocBlock says integer despite string type hint * @param string $stringWithConstraints String with Schema constraints * @param array<string> $arrayWithItems Array with Schema item overrides */ public function typePrecedenceTest( string $numericString, #[Schema(format: 'email', minLength: 5)] string $stringWithConstraints, #[Schema(items: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])] array $arrayWithItems ): void { } // ===== ERROR EDGE CASES ===== /** * Method with no parameters but Schema description. */ #[Schema(description: "Gets server status. Takes no arguments.", properties: [])] public function noParamsWithSchema(): array { return ['status' => 'OK']; } /** * Parameter with Schema but inferred type. */ public function parameterSchemaInferredType( #[Schema(description: "Some parameter", minLength: 3)] $inferredParam ): void { } /** * Parameter with complete custom definition via #[Schema(definition: ...)] */ public function parameterWithRawDefinition( #[Schema(definition: [ 'description' => 'Custom-defined schema', 'type' => 'string', 'format' => 'uuid' ])] string $custom ): void { } } ``` -------------------------------------------------------------------------------- /src/Elements/RegisteredPrompt.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Elements; use PhpMcp\Schema\Content\AudioContent; use PhpMcp\Schema\Content\BlobResourceContents; use PhpMcp\Schema\Content\Content; use PhpMcp\Schema\Content\EmbeddedResource; use PhpMcp\Schema\Content\ImageContent; use PhpMcp\Schema\Prompt; use PhpMcp\Schema\Content\PromptMessage; use PhpMcp\Schema\Content\TextContent; use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\Enum\Role; use PhpMcp\Schema\Result\CompletionCompleteResult; use PhpMcp\Server\Context; use PhpMcp\Server\Contracts\CompletionProviderInterface; use PhpMcp\Server\Contracts\SessionInterface; use Psr\Container\ContainerInterface; use Throwable; class RegisteredPrompt extends RegisteredElement { public function __construct( public readonly Prompt $schema, callable|array|string $handler, bool $isManual = false, public readonly array $completionProviders = [] ) { parent::__construct($handler, $isManual); } public static function make(Prompt $schema, callable|array|string $handler, bool $isManual = false, array $completionProviders = []): self { return new self($schema, $handler, $isManual, $completionProviders); } /** * Gets the prompt messages. * * @param ContainerInterface $container * @param array $arguments * @return PromptMessage[] */ public function get(ContainerInterface $container, array $arguments, Context $context): array { $result = $this->handle($container, $arguments, $context); return $this->formatResult($result); } public function complete(ContainerInterface $container, string $argument, string $value, SessionInterface $session): CompletionCompleteResult { $providerClassOrInstance = $this->completionProviders[$argument] ?? null; if ($providerClassOrInstance === null) { return new CompletionCompleteResult([]); } if (is_string($providerClassOrInstance)) { if (! class_exists($providerClassOrInstance)) { throw new \RuntimeException("Completion provider class '{$providerClassOrInstance}' does not exist."); } $provider = $container->get($providerClassOrInstance); } else { $provider = $providerClassOrInstance; } $completions = $provider->getCompletions($value, $session); $total = count($completions); $hasMore = $total > 100; $pagedCompletions = array_slice($completions, 0, 100); return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); } /** * Formats the raw result of a prompt generator into an array of MCP PromptMessages. * * @param mixed $promptGenerationResult Expected: array of message structures. * @return PromptMessage[] Array of PromptMessage objects. * * @throws \RuntimeException If the result cannot be formatted. * @throws \JsonException If JSON encoding fails. */ protected function formatResult(mixed $promptGenerationResult): array { if ($promptGenerationResult instanceof PromptMessage) { return [$promptGenerationResult]; } if (! is_array($promptGenerationResult)) { throw new \RuntimeException('Prompt generator method must return an array of messages.'); } if (empty($promptGenerationResult)) { return []; } if (is_array($promptGenerationResult)) { $allArePromptMessages = true; $hasPromptMessages = false; foreach ($promptGenerationResult as $item) { if ($item instanceof PromptMessage) { $hasPromptMessages = true; } else { $allArePromptMessages = false; } } if ($allArePromptMessages && $hasPromptMessages) { return $promptGenerationResult; } if ($hasPromptMessages) { $result = []; foreach ($promptGenerationResult as $index => $item) { if ($item instanceof PromptMessage) { $result[] = $item; } else { $result = array_merge($result, $this->formatResult($item)); } } return $result; } if (! array_is_list($promptGenerationResult)) { if (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) { $result = []; if (isset($promptGenerationResult['user'])) { $userContent = $this->formatContent($promptGenerationResult['user']); $result[] = PromptMessage::make(Role::User, $userContent); } if (isset($promptGenerationResult['assistant'])) { $assistantContent = $this->formatContent($promptGenerationResult['assistant']); $result[] = PromptMessage::make(Role::Assistant, $assistantContent); } return $result; } if (isset($promptGenerationResult['role']) && isset($promptGenerationResult['content'])) { return [$this->formatMessage($promptGenerationResult)]; } throw new \RuntimeException('Associative array must contain either role/content keys or user/assistant keys.'); } $formattedMessages = []; foreach ($promptGenerationResult as $index => $message) { if ($message instanceof PromptMessage) { $formattedMessages[] = $message; } else { $formattedMessages[] = $this->formatMessage($message, $index); } } return $formattedMessages; } throw new \RuntimeException('Invalid prompt generation result format.'); } /** * Formats a single message into a PromptMessage. */ private function formatMessage(mixed $message, ?int $index = null): PromptMessage { $indexStr = $index !== null ? " at index {$index}" : ''; if (! is_array($message) || ! array_key_exists('role', $message) || ! array_key_exists('content', $message)) { throw new \RuntimeException("Invalid message format{$indexStr}. Expected an array with 'role' and 'content' keys."); } $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']); if ($role === null) { throw new \RuntimeException("Invalid role '{$message['role']}' in prompt message{$indexStr}. Only 'user' or 'assistant' are supported."); } $content = $this->formatContent($message['content'], $index); return new PromptMessage($role, $content); } /** * Formats content into a proper Content object. */ private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource { $indexStr = $index !== null ? " at index {$index}" : ''; if ($content instanceof Content) { if ( $content instanceof TextContent || $content instanceof ImageContent || $content instanceof AudioContent || $content instanceof EmbeddedResource ) { return $content; } throw new \RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource."); } if (is_string($content)) { return TextContent::make($content); } if (is_array($content) && isset($content['type'])) { return $this->formatTypedContent($content, $index); } if (is_scalar($content) || $content === null) { $stringContent = $content === null ? '(null)' : (is_bool($content) ? ($content ? 'true' : 'false') : (string)$content); return TextContent::make($stringContent); } $jsonContent = json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); return TextContent::make($jsonContent); } /** * Formats typed content arrays into Content objects. */ private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource { $indexStr = $index !== null ? " at index {$index}" : ''; $type = $content['type']; return match ($type) { 'text' => $this->formatTextContent($content, $indexStr), 'image' => $this->formatImageContent($content, $indexStr), 'audio' => $this->formatAudioContent($content, $indexStr), 'resource' => $this->formatResourceContent($content, $indexStr), default => throw new \RuntimeException("Invalid content type '{$type}'{$indexStr}.") }; } private function formatTextContent(array $content, string $indexStr): TextContent { if (! isset($content['text']) || ! is_string($content['text'])) { throw new \RuntimeException("Invalid 'text' content{$indexStr}: Missing or invalid 'text' string."); } return TextContent::make($content['text']); } private function formatImageContent(array $content, string $indexStr): ImageContent { if (! isset($content['data']) || ! is_string($content['data'])) { throw new \RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'data' string (base64)."); } if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { throw new \RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'mimeType' string."); } return ImageContent::make($content['data'], $content['mimeType']); } private function formatAudioContent(array $content, string $indexStr): AudioContent { if (! isset($content['data']) || ! is_string($content['data'])) { throw new \RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'data' string (base64)."); } if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { throw new \RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'mimeType' string."); } return AudioContent::make($content['data'], $content['mimeType']); } private function formatResourceContent(array $content, string $indexStr): EmbeddedResource { if (! isset($content['resource']) || ! is_array($content['resource'])) { throw new \RuntimeException("Invalid 'resource' content{$indexStr}: Missing or invalid 'resource' object."); } $resource = $content['resource']; if (! isset($resource['uri']) || ! is_string($resource['uri'])) { throw new \RuntimeException("Invalid resource{$indexStr}: Missing or invalid 'uri'."); } if (isset($resource['text']) && is_string($resource['text'])) { $resourceObj = TextResourceContents::make($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); } elseif (isset($resource['blob']) && is_string($resource['blob'])) { $resourceObj = BlobResourceContents::make( $resource['uri'], $resource['mimeType'] ?? 'application/octet-stream', $resource['blob'] ); } else { throw new \RuntimeException("Invalid resource{$indexStr}: Must contain 'text' or 'blob'."); } return new EmbeddedResource($resourceObj); } public function toArray(): array { $completionProviders = []; foreach ($this->completionProviders as $argument => $provider) { $completionProviders[$argument] = serialize($provider); } return [ 'schema' => $this->schema->toArray(), 'completionProviders' => $completionProviders, ...parent::toArray(), ]; } public static function fromArray(array $data): self|false { try { if (! isset($data['schema']) || ! isset($data['handler'])) { return false; } $completionProviders = []; foreach ($data['completionProviders'] ?? [] as $argument => $provider) { $completionProviders[$argument] = unserialize($provider); } return new self( Prompt::fromArray($data['schema']), $data['handler'], $data['isManual'] ?? false, $completionProviders, ); } catch (Throwable $e) { return false; } } } ``` -------------------------------------------------------------------------------- /src/Utils/SchemaValidator.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Utils; use InvalidArgumentException; use JsonException; use Opis\JsonSchema\Errors\ValidationError; use Opis\JsonSchema\Validator; use Psr\Log\LoggerInterface; use Throwable; /** * Validates data against JSON Schema definitions using opis/json-schema. */ class SchemaValidator { private ?Validator $jsonSchemaValidator = null; private LoggerInterface $logger; public function __construct(LoggerInterface $logger) { $this->logger = $logger; } /** * Validates data against a JSON schema. * * @param mixed $data The data to validate (should generally be decoded JSON). * @param array|object $schema The JSON Schema definition (as PHP array or object). * @return list<array{pointer: string, keyword: string, message: string}> Array of validation errors, empty if valid. */ public function validateAgainstJsonSchema(mixed $data, array|object $schema): array { if (is_array($data) && empty($data)) { $data = new \stdClass(); } try { // --- Schema Preparation --- if (is_array($schema)) { $schemaJson = json_encode($schema, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); $schemaObject = json_decode($schemaJson, false, 512, JSON_THROW_ON_ERROR); } elseif (is_object($schema)) { // This might be overly cautious but safer against varied inputs. $schemaJson = json_encode($schema, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); $schemaObject = json_decode($schemaJson, false, 512, JSON_THROW_ON_ERROR); } else { throw new InvalidArgumentException('Schema must be an array or object.'); } // --- Data Preparation --- // Opis Validator generally prefers objects for object validation $dataToValidate = $this->convertDataForValidator($data); } catch (JsonException $e) { $this->logger->error('MCP SDK: Invalid schema structure provided for validation (JSON conversion failed).', ['exception' => $e]); return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Invalid schema definition provided (JSON error).']]; } catch (InvalidArgumentException $e) { $this->logger->error('MCP SDK: Invalid schema structure provided for validation.', ['exception' => $e]); return [['pointer' => '', 'keyword' => 'internal', 'message' => $e->getMessage()]]; } catch (Throwable $e) { $this->logger->error('MCP SDK: Error preparing data/schema for validation.', ['exception' => $e]); return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Internal validation preparation error.']]; } $validator = $this->getJsonSchemaValidator(); try { $result = $validator->validate($dataToValidate, $schemaObject); } catch (Throwable $e) { $this->logger->error('MCP SDK: JSON Schema validation failed internally.', [ 'exception_message' => $e->getMessage(), 'exception_trace' => $e->getTraceAsString(), 'data' => json_encode($dataToValidate), 'schema' => json_encode($schemaObject), ]); return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: ' . $e->getMessage()]]; } if ($result->isValid()) { return []; } $formattedErrors = []; $topError = $result->error(); if ($topError) { $this->collectSubErrors($topError, $formattedErrors); } if (empty($formattedErrors) && $topError) { // Fallback $formattedErrors[] = [ 'pointer' => $this->formatJsonPointerPath($topError->data()?->path()), 'keyword' => $topError->keyword(), 'message' => $this->formatValidationError($topError), ]; } return $formattedErrors; } /** * Get or create the JSON Schema validator instance. */ private function getJsonSchemaValidator(): Validator { if ($this->jsonSchemaValidator === null) { $this->jsonSchemaValidator = new Validator(); // Potentially configure resolver here if needed later } return $this->jsonSchemaValidator; } /** * Recursively converts associative arrays to stdClass objects for validator compatibility. */ private function convertDataForValidator(mixed $data): mixed { if (is_array($data)) { // Check if it's an associative array (keys are not sequential numbers 0..N-1) if (! empty($data) && array_keys($data) !== range(0, count($data) - 1)) { $obj = new \stdClass(); foreach ($data as $key => $value) { $obj->{$key} = $this->convertDataForValidator($value); } return $obj; } else { // It's a list (sequential array), convert items recursively return array_map([$this, 'convertDataForValidator'], $data); } } elseif (is_object($data) && $data instanceof \stdClass) { // Deep copy/convert stdClass objects as well $obj = new \stdClass(); foreach (get_object_vars($data) as $key => $value) { $obj->{$key} = $this->convertDataForValidator($value); } return $obj; } // Leave other objects and scalar types as they are return $data; } /** * Recursively collects leaf validation errors. */ private function collectSubErrors(ValidationError $error, array &$collectedErrors): void { $subErrors = $error->subErrors(); if (empty($subErrors)) { $collectedErrors[] = [ 'pointer' => $this->formatJsonPointerPath($error->data()?->path()), 'keyword' => $error->keyword(), 'message' => $this->formatValidationError($error), ]; } else { foreach ($subErrors as $subError) { $this->collectSubErrors($subError, $collectedErrors); } } } /** * Formats the path array into a JSON Pointer string. */ private function formatJsonPointerPath(?array $pathComponents): string { if ($pathComponents === null || empty($pathComponents)) { return '/'; } $escapedComponents = array_map(function ($component) { $componentStr = (string) $component; return str_replace(['~', '/'], ['~0', '~1'], $componentStr); }, $pathComponents); return '/' . implode('/', $escapedComponents); } /** * Formats an Opis SchemaValidationError into a user-friendly message. */ private function formatValidationError(ValidationError $error): string { $keyword = $error->keyword(); $args = $error->args(); $message = "Constraint `{$keyword}` failed."; switch (strtolower($keyword)) { case 'required': $missing = $args['missing'] ?? []; $formattedMissing = implode(', ', array_map(fn($p) => "`{$p}`", $missing)); $message = "Missing required properties: {$formattedMissing}."; break; case 'type': $expected = implode('|', (array) ($args['expected'] ?? [])); $used = $args['used'] ?? 'unknown'; $message = "Invalid type. Expected `{$expected}`, but received `{$used}`."; break; case 'enum': $schemaData = $error->schema()?->info()?->data(); $allowedValues = []; if (is_object($schemaData) && property_exists($schemaData, 'enum') && is_array($schemaData->enum)) { $allowedValues = $schemaData->enum; } elseif (is_array($schemaData) && isset($schemaData['enum']) && is_array($schemaData['enum'])) { $allowedValues = $schemaData['enum']; } else { $this->logger->warning("MCP SDK: Could not retrieve 'enum' values from schema info for error.", ['error_args' => $args]); } if (empty($allowedValues)) { $message = 'Value does not match the allowed enumeration.'; } else { $formattedAllowed = array_map(function ($v) { /* ... formatting logic ... */ if (is_string($v)) { return '"' . $v . '"'; } if (is_bool($v)) { return $v ? 'true' : 'false'; } if ($v === null) { return 'null'; } return (string) $v; }, $allowedValues); $message = 'Value must be one of the allowed values: ' . implode(', ', $formattedAllowed) . '.'; } break; case 'const': $expected = json_encode($args['expected'] ?? 'null', JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $message = "Value must be equal to the constant value: {$expected}."; break; case 'minLength': // Corrected casing $min = $args['min'] ?? '?'; $message = "String must be at least {$min} characters long."; break; case 'maxLength': // Corrected casing $max = $args['max'] ?? '?'; $message = "String must not be longer than {$max} characters."; break; case 'pattern': $pattern = $args['pattern'] ?? '?'; $message = "String does not match the required pattern: `{$pattern}`."; break; case 'minimum': $min = $args['min'] ?? '?'; $message = "Number must be greater than or equal to {$min}."; break; case 'maximum': $max = $args['max'] ?? '?'; $message = "Number must be less than or equal to {$max}."; break; case 'exclusiveMinimum': // Corrected casing $min = $args['min'] ?? '?'; $message = "Number must be strictly greater than {$min}."; break; case 'exclusiveMaximum': // Corrected casing $max = $args['max'] ?? '?'; $message = "Number must be strictly less than {$max}."; break; case 'multipleOf': // Corrected casing $value = $args['value'] ?? '?'; $message = "Number must be a multiple of {$value}."; break; case 'minItems': // Corrected casing $min = $args['min'] ?? '?'; $message = "Array must contain at least {$min} items."; break; case 'maxItems': // Corrected casing $max = $args['max'] ?? '?'; $message = "Array must contain no more than {$max} items."; break; case 'uniqueItems': // Corrected casing $message = 'Array items must be unique.'; break; case 'minProperties': // Corrected casing $min = $args['min'] ?? '?'; $message = "Object must have at least {$min} properties."; break; case 'maxProperties': // Corrected casing $max = $args['max'] ?? '?'; $message = "Object must have no more than {$max} properties."; break; case 'additionalProperties': // Corrected casing $unexpected = $args['properties'] ?? []; $formattedUnexpected = implode(', ', array_map(fn($p) => "`{$p}`", $unexpected)); $message = "Object contains unexpected additional properties: {$formattedUnexpected}."; break; case 'format': $format = $args['format'] ?? 'unknown'; $message = "Value does not match the required format: `{$format}`."; break; default: $builtInMessage = $error->message(); if ($builtInMessage && $builtInMessage !== 'The data must match the schema') { $placeholders = $args ?? []; $builtInMessage = preg_replace_callback('/\{(\w+)\}/', function ($match) use ($placeholders) { $key = $match[1]; $value = $placeholders[$key] ?? '{' . $key . '}'; return is_array($value) ? json_encode($value) : (string) $value; }, $builtInMessage); $message = $builtInMessage; } break; } return $message; } } ``` -------------------------------------------------------------------------------- /src/Transports/HttpServerTransport.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server\Transports; use Evenement\EventEmitterTrait; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\TransportException; use PhpMcp\Schema\JsonRpc\Message; use PhpMcp\Schema\JsonRpc\Error; use PhpMcp\Schema\JsonRpc\Parser; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\Http\HttpServer; use React\Http\Message\Response; use React\Promise\Deferred; use React\Promise\PromiseInterface; use React\Socket\SocketServer; use React\Stream\ThroughStream; use React\Stream\WritableStreamInterface; use Throwable; use function React\Promise\resolve; use function React\Promise\reject; /** * Implementation of the HTTP+SSE server transport using ReactPHP components. * * Listens for HTTP connections, manages SSE streams, and emits events. */ class HttpServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface { use EventEmitterTrait; protected LoggerInterface $logger; protected LoopInterface $loop; protected ?SocketServer $socket = null; protected ?HttpServer $http = null; /** @var array<string, ThroughStream> sessionId => SSE Stream */ private array $activeSseStreams = []; protected bool $listening = false; protected bool $closing = false; protected string $ssePath; protected string $messagePath; /** * @param string $host Host to bind to (e.g., '127.0.0.1', '0.0.0.0'). * @param int $port Port to listen on (e.g., 8080). * @param string $mcpPathPrefix URL prefix for MCP endpoints (e.g., 'mcp'). * @param array|null $sslContext Optional SSL context options for React SocketServer (for HTTPS). * @param array<callable(\Psr\Http\Message\ServerRequestInterface, callable): (\Psr\Http\Message\ResponseInterface|\React\Promise\PromiseInterface)> $middlewares Middlewares to be applied to the HTTP server. */ public function __construct( private readonly string $host = '127.0.0.1', private readonly int $port = 8080, private readonly string $mcpPathPrefix = 'mcp', private readonly ?array $sslContext = null, private array $middlewares = [] ) { $this->logger = new NullLogger(); $this->loop = Loop::get(); $this->ssePath = '/' . trim($mcpPathPrefix, '/') . '/sse'; $this->messagePath = '/' . trim($mcpPathPrefix, '/') . '/message'; foreach ($this->middlewares as $mw) { if (!is_callable($mw)) { throw new \InvalidArgumentException('All provided middlewares must be callable.'); } } } public function setLogger(LoggerInterface $logger): void { $this->logger = $logger; } public function setLoop(LoopInterface $loop): void { $this->loop = $loop; } protected function generateId(): string { return bin2hex(random_bytes(16)); // 32 hex characters } /** * Starts the HTTP server listener. * * @throws TransportException If port binding fails. */ public function listen(): void { if ($this->listening) { throw new TransportException('Http transport is already listening.'); } if ($this->closing) { throw new TransportException('Cannot listen, transport is closing/closed.'); } $listenAddress = "{$this->host}:{$this->port}"; $protocol = $this->sslContext ? 'https' : 'http'; try { $this->socket = new SocketServer( $listenAddress, $this->sslContext ?? [], $this->loop ); $handlers = array_merge($this->middlewares, [$this->createRequestHandler()]); $this->http = new HttpServer($this->loop, ...$handlers); $this->http->listen($this->socket); $this->socket->on('error', function (Throwable $error) { $this->logger->error('Socket server error.', ['error' => $error->getMessage()]); $this->emit('error', [new TransportException("Socket server error: {$error->getMessage()}", 0, $error)]); $this->close(); }); $this->logger->info("Server is up and listening on {$protocol}://{$listenAddress} 🚀"); $this->logger->info("SSE Endpoint: {$protocol}://{$listenAddress}{$this->ssePath}"); $this->logger->info("Message Endpoint: {$protocol}://{$listenAddress}{$this->messagePath}"); $this->listening = true; $this->closing = false; $this->emit('ready'); } catch (Throwable $e) { $this->logger->error("Failed to start listener on {$listenAddress}", ['exception' => $e]); throw new TransportException("Failed to start HTTP listener on {$listenAddress}: {$e->getMessage()}", 0, $e); } } /** Creates the main request handling callback for ReactPHP HttpServer */ protected function createRequestHandler(): callable { return function (ServerRequestInterface $request) { $path = $request->getUri()->getPath(); $method = $request->getMethod(); $this->logger->debug('Received request', ['method' => $method, 'path' => $path]); if ($method === 'GET' && $path === $this->ssePath) { return $this->handleSseRequest($request); } if ($method === 'POST' && $path === $this->messagePath) { return $this->handleMessagePostRequest($request); } $this->logger->debug('404 Not Found', ['method' => $method, 'path' => $path]); return new Response(404, ['Content-Type' => 'text/plain'], 'Not Found'); }; } /** Handles a new SSE connection request */ protected function handleSseRequest(ServerRequestInterface $request): Response { $sessionId = $this->generateId(); $this->logger->info('New SSE connection', ['sessionId' => $sessionId]); $sseStream = new ThroughStream(); $sseStream->on('close', function () use ($sessionId) { $this->logger->info('SSE stream closed', ['sessionId' => $sessionId]); unset($this->activeSseStreams[$sessionId]); $this->emit('client_disconnected', [$sessionId, 'SSE stream closed']); }); $sseStream->on('error', function (Throwable $error) use ($sessionId) { $this->logger->warning('SSE stream error', ['sessionId' => $sessionId, 'error' => $error->getMessage()]); unset($this->activeSseStreams[$sessionId]); $this->emit('error', [new TransportException("SSE Stream Error: {$error->getMessage()}", 0, $error), $sessionId]); $this->emit('client_disconnected', [$sessionId, 'SSE stream error']); }); $this->activeSseStreams[$sessionId] = $sseStream; $this->loop->futureTick(function () use ($sessionId, $request, $sseStream) { if (! isset($this->activeSseStreams[$sessionId]) || ! $sseStream->isWritable()) { $this->logger->warning('Cannot send initial endpoint event, stream closed/invalid early.', ['sessionId' => $sessionId]); return; } try { $baseUri = $request->getUri()->withPath($this->messagePath)->withQuery('')->withFragment(''); $postEndpointWithId = (string) $baseUri->withQuery("clientId={$sessionId}"); $this->sendSseEvent($sseStream, 'endpoint', $postEndpointWithId, "init-{$sessionId}"); $this->emit('client_connected', [$sessionId]); } catch (Throwable $e) { $this->logger->error('Error sending initial endpoint event', ['sessionId' => $sessionId, 'exception' => $e]); $sseStream->close(); } }); return new Response( 200, [ 'Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'Connection' => 'keep-alive', 'X-Accel-Buffering' => 'no', 'Access-Control-Allow-Origin' => '*', ], $sseStream ); } /** Handles incoming POST requests with messages */ protected function handleMessagePostRequest(ServerRequestInterface $request): Response { $queryParams = $request->getQueryParams(); $sessionId = $queryParams['clientId'] ?? null; $jsonEncodeFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; if (! $sessionId || ! is_string($sessionId)) { $this->logger->warning('Received POST without valid clientId query parameter.'); $error = Error::forInvalidRequest('Missing or invalid clientId query parameter'); return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } if (! isset($this->activeSseStreams[$sessionId])) { $this->logger->warning('Received POST for unknown or disconnected sessionId.', ['sessionId' => $sessionId]); $error = Error::forInvalidRequest('Session ID not found or disconnected'); return new Response(404, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } if (! str_contains(strtolower($request->getHeaderLine('Content-Type')), 'application/json')) { $error = Error::forInvalidRequest('Content-Type must be application/json'); return new Response(415, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } $body = $request->getBody()->getContents(); if (empty($body)) { $this->logger->warning('Received empty POST body', ['sessionId' => $sessionId]); $error = Error::forInvalidRequest('Empty request body'); return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } try { $message = Parser::parse($body); } catch (Throwable $e) { $this->logger->error('Error parsing message', ['sessionId' => $sessionId, 'exception' => $e]); $error = Error::forParseError('Invalid JSON-RPC message: ' . $e->getMessage()); return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } $context = [ 'request' => $request, ]; $this->emit('message', [$message, $sessionId, $context]); return new Response(202, ['Content-Type' => 'text/plain'], 'Accepted'); } /** * Sends a raw JSON-RPC message frame to a specific client via SSE. */ public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface { if (! isset($this->activeSseStreams[$sessionId])) { return reject(new TransportException("Cannot send message: Client '{$sessionId}' not connected via SSE.")); } $stream = $this->activeSseStreams[$sessionId]; if (! $stream->isWritable()) { return reject(new TransportException("Cannot send message: SSE stream for client '{$sessionId}' is not writable.")); } $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($json === '') { return resolve(null); } $deferred = new Deferred(); $written = $this->sendSseEvent($stream, 'message', $json); if ($written) { $deferred->resolve(null); } else { $this->logger->debug('SSE stream buffer full, waiting for drain.', ['sessionId' => $sessionId]); $stream->once('drain', function () use ($deferred, $sessionId) { $this->logger->debug('SSE stream drained.', ['sessionId' => $sessionId]); $deferred->resolve(null); }); } return $deferred->promise(); } /** Helper to format and write an SSE event */ private function sendSseEvent(WritableStreamInterface $stream, string $event, string $data, ?string $id = null): bool { if (! $stream->isWritable()) { return false; } $frame = "event: {$event}\n"; if ($id !== null) { $frame .= "id: {$id}\n"; } $lines = explode("\n", $data); foreach ($lines as $line) { $frame .= "data: {$line}\n"; } $frame .= "\n"; // End of event $this->logger->debug('Sending SSE event', ['event' => $event, 'frame' => $frame]); return $stream->write($frame); } /** * Stops the HTTP server and closes all connections. */ public function close(): void { if ($this->closing) { return; } $this->closing = true; $this->listening = false; $this->logger->info('Closing transport...'); if ($this->socket) { $this->socket->close(); $this->socket = null; } $activeStreams = $this->activeSseStreams; $this->activeSseStreams = []; foreach ($activeStreams as $sessionId => $stream) { $this->logger->debug('Closing active SSE stream', ['sessionId' => $sessionId]); unset($this->activeSseStreams[$sessionId]); $stream->close(); } $this->emit('close', ['HttpTransport closed.']); $this->removeAllListeners(); } } ``` -------------------------------------------------------------------------------- /tests/Unit/Elements/RegisteredElementTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Elements; use Mockery; use PhpMcp\Server\Context; use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Elements\RegisteredElement; use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum; use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum; use PhpMcp\Server\Tests\Fixtures\General\VariousTypesHandler; use Psr\Container\ContainerInterface; use Psr\Http\Message\ServerRequestInterface; use stdClass; // --- Test Fixtures for Handler Types --- class MyInvokableTestHandler { public function __invoke(string $name): string { return "Hello, {$name}!"; } } class MyStaticMethodTestHandler { public static function myStaticMethod(int $a, int $b): int { return $a + $b; } } function my_global_test_function(bool $flag): string { return $flag ? 'on' : 'off'; } beforeEach(function () { $this->container = Mockery::mock(ContainerInterface::class); $this->container->shouldReceive('get')->with(VariousTypesHandler::class)->andReturn(new VariousTypesHandler()); $this->context = new Context(Mockery::mock(SessionInterface::class)); }); it('can be constructed as manual or discovered', function () { $handler = [VariousTypesHandler::class, 'noArgsMethod']; $elManual = new RegisteredElement($handler, true); $elDiscovered = new RegisteredElement($handler, false); expect($elManual->isManual)->toBeTrue(); expect($elDiscovered->isManual)->toBeFalse(); expect($elDiscovered->handler)->toBe($handler); }); it('prepares arguments in correct order for simple required types', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'simpleRequiredArgs']); $args = ['pString' => 'hello', 'pBool' => true, 'pInt' => 123]; $result = $element->handle($this->container, $args, $this->context); $expectedResult = ['pString' => 'hello', 'pInt' => 123, 'pBool' => true]; expect($result)->toBe($expectedResult); }); it('uses default values for missing optional arguments', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'optionalArgsWithDefaults']); $result1 = $element->handle($this->container, ['pString' => 'override'], $this->context); expect($result1['pString'])->toBe('override'); expect($result1['pInt'])->toBe(100); expect($result1['pNullableBool'])->toBeTrue(); expect($result1['pFloat'])->toBe(3.14); $result2 = $element->handle($this->container, [], $this->context); expect($result2['pString'])->toBe('default_string'); expect($result2['pInt'])->toBe(100); expect($result2['pNullableBool'])->toBeTrue(); expect($result2['pFloat'])->toBe(3.14); }); it('passes null for nullable arguments if not provided', function () { $elementNoDefaults = new RegisteredElement([VariousTypesHandler::class, 'nullableArgsWithoutDefaults']); $result2 = $elementNoDefaults->handle($this->container, [], $this->context); expect($result2['pString'])->toBeNull(); expect($result2['pInt'])->toBeNull(); expect($result2['pArray'])->toBeNull(); }); it('passes null explicitly for nullable arguments', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'nullableArgsWithoutDefaults']); $result = $element->handle($this->container, ['pString' => null, 'pInt' => null, 'pArray' => null], $this->context); expect($result['pString'])->toBeNull(); expect($result['pInt'])->toBeNull(); expect($result['pArray'])->toBeNull(); }); it('handles mixed type arguments', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'mixedTypeArg']); $obj = new stdClass(); $testValues = [ 'a string', 123, true, null, ['an', 'array'], $obj ]; foreach ($testValues as $value) { $result = $element->handle($this->container, ['pMixed' => $value], $this->context); expect($result['pMixed'])->toBe($value); } }); it('throws McpServerException for missing required argument', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'simpleRequiredArgs']); $element->handle($this->container, ['pString' => 'hello', 'pInt' => 123], $this->context); })->throws(McpServerException::class, 'Missing required argument `pBool`'); dataset('valid_type_casts', [ 'string_from_int' => ['strParam', 123, '123'], 'int_from_valid_string' => ['intParam', '456', 456], 'int_from_neg_string' => ['intParam', '-10', -10], 'int_from_float_whole' => ['intParam', 77.0, 77], 'bool_from_int_1' => ['boolProp', 1, true], 'bool_from_string_true' => ['boolProp', 'true', true], 'bool_from_string_TRUE' => ['boolProp', 'TRUE', true], 'bool_from_int_0' => ['boolProp', 0, false], 'bool_from_string_false' => ['boolProp', 'false', false], 'bool_from_string_FALSE' => ['boolProp', 'FALSE', false], 'float_from_valid_string' => ['floatParam', '7.89', 7.89], 'float_from_int' => ['floatParam', 10, 10.0], 'array_passthrough' => ['arrayParam', ['x', 'y'], ['x', 'y']], 'object_passthrough' => ['objectParam', (object)['a' => 1], (object)['a' => 1]], 'string_for_int_cast_specific' => ['stringForIntCast', '999', 999], 'string_for_float_cast_specific' => ['stringForFloatCast', '123.45', 123.45], 'string_for_bool_true_cast_specific' => ['stringForBoolTrueCast', '1', true], 'string_for_bool_false_cast_specific' => ['stringForBoolFalseCast', '0', false], 'int_for_string_cast_specific' => ['intForStringCast', 55, '55'], 'int_for_float_cast_specific' => ['intForFloatCast', 66, 66.0], 'bool_for_string_cast_specific' => ['boolForStringCast', true, '1'], 'backed_string_enum_valid_val' => ['backedStringEnumParam', 'A', BackedStringEnum::OptionA], 'backed_int_enum_valid_val' => ['backedIntEnumParam', 1, BackedIntEnum::First], 'unit_enum_valid_val' => ['unitEnumParam', 'Yes', UnitEnum::Yes], ]); it('casts argument types correctly for valid inputs (comprehensive)', function (string $paramName, mixed $inputValue, mixed $expectedValue) { $element = new RegisteredElement([VariousTypesHandler::class, 'comprehensiveArgumentTest']); $allArgs = [ 'strParam' => 'default string', 'intParam' => 0, 'boolProp' => false, 'floatParam' => 0.0, 'arrayParam' => [], 'backedStringEnumParam' => BackedStringEnum::OptionA, 'backedIntEnumParam' => BackedIntEnum::First, 'unitEnumParam' => 'Yes', 'nullableStringParam' => null, 'mixedParam' => 'default mixed', 'objectParam' => new stdClass(), 'stringForIntCast' => '0', 'stringForFloatCast' => '0.0', 'stringForBoolTrueCast' => 'false', 'stringForBoolFalseCast' => 'true', 'intForStringCast' => 0, 'intForFloatCast' => 0, 'boolForStringCast' => false, 'valueForBackedStringEnum' => 'A', 'valueForBackedIntEnum' => 1, ]; $testArgs = array_merge($allArgs, [$paramName => $inputValue]); $result = $element->handle($this->container, $testArgs, $this->context); expect($result[$paramName])->toEqual($expectedValue); })->with('valid_type_casts'); dataset('invalid_type_casts', [ 'int_from_alpha_string' => ['intParam', 'abc', '/Cannot cast value to integer/i'], 'int_from_float_non_whole' => ['intParam', 12.3, '/Cannot cast value to integer/i'], 'bool_from_string_random' => ['boolProp', 'random', '/Cannot cast value to boolean/i'], 'bool_from_int_invalid' => ['boolProp', 2, '/Cannot cast value to boolean/i'], 'float_from_alpha_string' => ['floatParam', 'xyz', '/Cannot cast value to float/i'], 'array_from_string' => ['arrayParam', 'not_an_array', '/Cannot cast value to array/i'], 'backed_string_enum_invalid_val' => ['backedStringEnumParam', 'Z', "/Invalid value 'Z' for backed enum .*BackedStringEnum/i"], 'backed_int_enum_invalid_val' => ['backedIntEnumParam', 99, "/Invalid value '99' for backed enum .*BackedIntEnum/i"], 'unit_enum_invalid_string_val' => ['unitEnumParam', 'Maybe', "/Invalid value 'Maybe' for unit enum .*UnitEnum/i"], ]); it('throws McpServerException for invalid type casting', function (string $paramName, mixed $invalidValue, string $expectedMsgRegex) { $element = new RegisteredElement([VariousTypesHandler::class, 'comprehensiveArgumentTest']); $allArgs = [ /* fill with defaults as in valid_type_casts */ 'strParam' => 's', 'intParam' => 1, 'boolProp' => true, 'floatParam' => 1.1, 'arrayParam' => [], 'backedStringEnumParam' => BackedStringEnum::OptionA, 'backedIntEnumParam' => BackedIntEnum::First, 'unitEnumParam' => UnitEnum::Yes, 'nullableStringParam' => null, 'mixedParam' => 'mix', 'objectParam' => new stdClass(), 'stringForIntCast' => '0', 'stringForFloatCast' => '0.0', 'stringForBoolTrueCast' => 'false', 'stringForBoolFalseCast' => 'true', 'intForStringCast' => 0, 'intForFloatCast' => 0, 'boolForStringCast' => false, 'valueForBackedStringEnum' => 'A', 'valueForBackedIntEnum' => 1, ]; $testArgs = array_merge($allArgs, [$paramName => $invalidValue]); try { $element->handle($this->container, $testArgs, $this->context); } catch (McpServerException $e) { expect($e->getMessage())->toMatch($expectedMsgRegex); } })->with('invalid_type_casts'); it('casts to BackedStringEnum correctly', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 1], $this->context); expect($result['pBackedString'])->toBe(BackedStringEnum::OptionA); }); it('throws for invalid BackedStringEnum value', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); $element->handle($this->container, ['pBackedString' => 'Invalid', 'pBackedInt' => 1], $this->context); })->throws(McpServerException::class, "Invalid value 'Invalid' for backed enum"); it('casts to BackedIntEnum correctly', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 2], $this->context); expect($result['pBackedInt'])->toBe(BackedIntEnum::Second); }); it('throws for invalid BackedIntEnum value', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'backedEnumArgs']); $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 999], $this->context); })->throws(McpServerException::class, "Invalid value '999' for backed enum"); it('casts to UnitEnum correctly', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'unitEnumArg']); $result = $element->handle($this->container, ['pUnitEnum' => 'Yes'], $this->context); expect($result['pUnitEnum'])->toBe(UnitEnum::Yes); }); it('throws for invalid UnitEnum value', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'unitEnumArg']); $element->handle($this->container, ['pUnitEnum' => 'Invalid'], $this->context); })->throws(McpServerException::class, "Invalid value 'Invalid' for unit enum"); it('throws ReflectionException if handler method does not exist', function () { $element = new RegisteredElement([VariousTypesHandler::class, 'nonExistentMethod']); $element->handle($this->container, [], $this->context); })->throws(\ReflectionException::class, "VariousTypesHandler::nonExistentMethod() does not exist"); it('passes Context object', function() { $sessionMock = Mockery::mock(SessionInterface::class); $sessionMock->expects('get')->with('testKey')->andReturn('testValue'); $requestMock = Mockery::mock(ServerRequestInterface::class); $requestMock->expects('getHeaderLine')->with('testHeader')->andReturn('testHeaderValue'); $context = new Context($sessionMock, $requestMock); $element = new RegisteredElement([VariousTypesHandler::class, 'contextArg']); $result = $element->handle($this->container, [], $context); expect($result)->toBe([ 'session' => 'testValue', 'request' => 'testHeaderValue' ]); }); describe('Handler Types', function () { it('handles invokable class handler', function () { $this->container->shouldReceive('get') ->with(MyInvokableTestHandler::class) ->andReturn(new MyInvokableTestHandler()); $element = new RegisteredElement(MyInvokableTestHandler::class); $result = $element->handle($this->container, ['name' => 'World'], $this->context); expect($result)->toBe('Hello, World!'); }); it('handles closure handler', function () { $closure = function (string $a, string $b) { return $a . $b; }; $element = new RegisteredElement($closure); $result = $element->handle($this->container, ['a' => 'foo', 'b' => 'bar'], $this->context); expect($result)->toBe('foobar'); }); it('handles static method handler', function () { $handler = [MyStaticMethodTestHandler::class, 'myStaticMethod']; $element = new RegisteredElement($handler); $result = $element->handle($this->container, ['a' => 5, 'b' => 10], $this->context); expect($result)->toBe(15); }); it('handles global function name handler', function () { $handler = 'PhpMcp\Server\Tests\Unit\Elements\my_global_test_function'; $element = new RegisteredElement($handler); $result = $element->handle($this->container, ['flag' => true], $this->context); expect($result)->toBe('on'); }); }); ``` -------------------------------------------------------------------------------- /examples/08-schema-showcase-streamable/SchemaShowcaseElements.php: -------------------------------------------------------------------------------- ```php <?php namespace Mcp\SchemaShowcaseExample; use DateTime; use DateInterval; use PhpMcp\Server\Attributes\McpTool; use PhpMcp\Server\Attributes\Schema; class SchemaShowcaseElements { /** * Validates and formats text with string constraints. * Demonstrates: minLength, maxLength, pattern validation. */ #[McpTool( name: 'format_text', description: 'Formats text with validation constraints. Text must be 5-100 characters and contain only letters, numbers, spaces, and basic punctuation.' )] public function formatText( #[Schema( type: 'string', description: 'The text to format', minLength: 5, maxLength: 100, pattern: '^[a-zA-Z0-9\s\.,!?\-]+$' )] string $text, #[Schema( type: 'string', description: 'Format style', enum: ['uppercase', 'lowercase', 'title', 'sentence'] )] string $format = 'sentence' ): array { fwrite(STDERR, "Format text tool called: text='$text', format='$format'\n"); $formatted = match ($format) { 'uppercase' => strtoupper($text), 'lowercase' => strtolower($text), 'title' => ucwords(strtolower($text)), 'sentence' => ucfirst(strtolower($text)), default => $text }; return [ 'original' => $text, 'formatted' => $formatted, 'length' => strlen($text), 'format_applied' => $format ]; } /** * Performs mathematical operations with numeric constraints. * * Demonstrates: METHOD-LEVEL Schema */ #[McpTool(name: 'calculate_range')] #[Schema( type: 'object', properties: [ 'first' => [ 'type' => 'number', 'description' => 'First number (must be between 0 and 1000)', 'minimum' => 0, 'maximum' => 1000 ], 'second' => [ 'type' => 'number', 'description' => 'Second number (must be between 0 and 1000)', 'minimum' => 0, 'maximum' => 1000 ], 'operation' => [ 'type' => 'string', 'description' => 'Operation to perform', 'enum' => ['add', 'subtract', 'multiply', 'divide', 'power'] ], 'precision' => [ 'type' => 'integer', 'description' => 'Decimal precision (must be multiple of 2, between 0-10)', 'minimum' => 0, 'maximum' => 10, 'multipleOf' => 2 ] ], required: ['first', 'second', 'operation'], )] public function calculateRange(float $first, float $second, string $operation, int $precision = 2): array { fwrite(STDERR, "Calculate range tool called: $first $operation $second (precision: $precision)\n"); $result = match ($operation) { 'add' => $first + $second, 'subtract' => $first - $second, 'multiply' => $first * $second, 'divide' => $second != 0 ? $first / $second : null, 'power' => pow($first, $second), default => null }; if ($result === null) { return [ 'error' => $operation === 'divide' ? 'Division by zero' : 'Invalid operation', 'inputs' => compact('first', 'second', 'operation', 'precision') ]; } return [ 'result' => round($result, $precision), 'operation' => "$first $operation $second", 'precision' => $precision, 'within_bounds' => $result >= 0 && $result <= 1000000 ]; } /** * Processes user profile data with object schema validation. * Demonstrates: object properties, required fields, additionalProperties. */ #[McpTool( name: 'validate_profile', description: 'Validates and processes user profile data with strict schema requirements.' )] public function validateProfile( #[Schema( type: 'object', description: 'User profile information', properties: [ 'name' => [ 'type' => 'string', 'minLength' => 2, 'maxLength' => 50, 'description' => 'Full name' ], 'email' => [ 'type' => 'string', 'format' => 'email', 'description' => 'Valid email address' ], 'age' => [ 'type' => 'integer', 'minimum' => 13, 'maximum' => 120, 'description' => 'Age in years' ], 'role' => [ 'type' => 'string', 'enum' => ['user', 'admin', 'moderator', 'guest'], 'description' => 'User role' ], 'preferences' => [ 'type' => 'object', 'properties' => [ 'notifications' => ['type' => 'boolean'], 'theme' => ['type' => 'string', 'enum' => ['light', 'dark', 'auto']] ], 'additionalProperties' => false ] ], required: ['name', 'email', 'age'], additionalProperties: true )] array $profile ): array { fwrite(STDERR, "Validate profile tool called with: " . json_encode($profile) . "\n"); $errors = []; $warnings = []; // Additional business logic validation if (isset($profile['age']) && $profile['age'] < 18 && ($profile['role'] ?? 'user') === 'admin') { $errors[] = 'Admin role requires age 18 or older'; } if (isset($profile['email']) && !filter_var($profile['email'], FILTER_VALIDATE_EMAIL)) { $errors[] = 'Invalid email format'; } if (!isset($profile['role'])) { $warnings[] = 'No role specified, defaulting to "user"'; $profile['role'] = 'user'; } return [ 'valid' => empty($errors), 'profile' => $profile, 'errors' => $errors, 'warnings' => $warnings, 'processed_at' => date('Y-m-d H:i:s') ]; } /** * Manages a list of items with array constraints. * Demonstrates: array items, minItems, maxItems, uniqueItems. */ #[McpTool( name: 'manage_list', description: 'Manages a list of items with size and uniqueness constraints.' )] public function manageList( #[Schema( type: 'array', description: 'List of items to manage (2-10 unique strings)', items: [ 'type' => 'string', 'minLength' => 1, 'maxLength' => 30 ], minItems: 2, maxItems: 10, uniqueItems: true )] array $items, #[Schema( type: 'string', description: 'Action to perform on the list', enum: ['sort', 'reverse', 'shuffle', 'deduplicate', 'filter_short', 'filter_long'] )] string $action = 'sort' ): array { fwrite(STDERR, "Manage list tool called with " . count($items) . " items, action: $action\n"); $original = $items; $processed = $items; switch ($action) { case 'sort': sort($processed); break; case 'reverse': $processed = array_reverse($processed); break; case 'shuffle': shuffle($processed); break; case 'deduplicate': $processed = array_unique($processed); break; case 'filter_short': $processed = array_filter($processed, fn($item) => strlen($item) <= 10); break; case 'filter_long': $processed = array_filter($processed, fn($item) => strlen($item) > 10); break; } return [ 'original_count' => count($original), 'processed_count' => count($processed), 'action' => $action, 'original' => $original, 'processed' => array_values($processed), // Re-index array 'stats' => [ 'average_length' => count($processed) > 0 ? round(array_sum(array_map('strlen', $processed)) / count($processed), 2) : 0, 'shortest' => count($processed) > 0 ? min(array_map('strlen', $processed)) : 0, 'longest' => count($processed) > 0 ? max(array_map('strlen', $processed)) : 0, ] ]; } /** * Generates configuration with format validation. * Demonstrates: format constraints (date-time, uri, etc). */ #[McpTool( name: 'generate_config', description: 'Generates configuration with format-validated inputs.' )] public function generateConfig( #[Schema( type: 'string', description: 'Application name (alphanumeric with hyphens)', pattern: '^[a-zA-Z0-9\-]+$', minLength: 3, maxLength: 20 )] string $appName, #[Schema( type: 'string', description: 'Valid URL for the application', format: 'uri' )] string $baseUrl, #[Schema( type: 'string', description: 'Environment type', enum: ['development', 'staging', 'production'] )] string $environment = 'development', #[Schema( type: 'boolean', description: 'Enable debug mode' )] bool $debug = true, #[Schema( type: 'integer', description: 'Port number (1024-65535)', minimum: 1024, maximum: 65535 )] int $port = 8080 ): array { fwrite(STDERR, "Generate config tool called for app: $appName\n"); $config = [ 'app' => [ 'name' => $appName, 'env' => $environment, 'debug' => $debug, 'url' => $baseUrl, 'port' => $port, ], 'generated_at' => date('c'), // ISO 8601 format 'version' => '1.0.0', 'features' => [ 'logging' => $environment !== 'production' || $debug, 'caching' => $environment === 'production', 'analytics' => $environment === 'production', 'rate_limiting' => $environment !== 'development', ] ]; return [ 'success' => true, 'config' => $config, 'validation' => [ 'app_name_valid' => preg_match('/^[a-zA-Z0-9\-]+$/', $appName) === 1, 'url_valid' => filter_var($baseUrl, FILTER_VALIDATE_URL) !== false, 'port_in_range' => $port >= 1024 && $port <= 65535, ] ]; } /** * Processes time-based data with date-time format validation. * Demonstrates: date-time format, exclusiveMinimum, exclusiveMaximum. */ #[McpTool( name: 'schedule_event', description: 'Schedules an event with time validation and constraints.' )] public function scheduleEvent( #[Schema( type: 'string', description: 'Event title (3-50 characters)', minLength: 3, maxLength: 50 )] string $title, #[Schema( type: 'string', description: 'Event start time in ISO 8601 format', format: 'date-time' )] string $startTime, #[Schema( type: 'number', description: 'Duration in hours (minimum 0.5, maximum 24)', minimum: 0.5, maximum: 24, multipleOf: 0.5 )] float $durationHours, #[Schema( type: 'string', description: 'Event priority level', enum: ['low', 'medium', 'high', 'urgent'] )] string $priority = 'medium', #[Schema( type: 'array', description: 'List of attendee email addresses', items: [ 'type' => 'string', 'format' => 'email' ], maxItems: 20 )] array $attendees = [] ): array { fwrite(STDERR, "Schedule event tool called: $title at $startTime\n"); $start = DateTime::createFromFormat(DateTime::ISO8601, $startTime); if (!$start) { $start = DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $startTime); } if (!$start) { return [ 'success' => false, 'error' => 'Invalid date-time format. Use ISO 8601 format.', 'example' => '2024-01-15T14:30:00Z' ]; } $end = clone $start; $end->add(new DateInterval('PT' . ($durationHours * 60) . 'M')); $event = [ 'id' => uniqid('event_'), 'title' => $title, 'start_time' => $start->format('c'), 'end_time' => $end->format('c'), 'duration_hours' => $durationHours, 'priority' => $priority, 'attendees' => $attendees, 'created_at' => date('c') ]; return [ 'success' => true, 'event' => $event, 'info' => [ 'attendee_count' => count($attendees), 'is_all_day' => $durationHours >= 24, 'is_future' => $start > new DateTime(), 'timezone_note' => 'Times are in UTC' ] ]; } } ``` -------------------------------------------------------------------------------- /tests/Unit/Elements/RegisteredPromptTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Elements; use Mockery; use PhpMcp\Schema\Prompt as PromptSchema; use PhpMcp\Schema\PromptArgument; use PhpMcp\Server\Context; use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Elements\RegisteredPrompt; use PhpMcp\Schema\Content\PromptMessage; use PhpMcp\Schema\Enum\Role; use PhpMcp\Schema\Content\TextContent; use PhpMcp\Schema\Content\ImageContent; use PhpMcp\Schema\Content\AudioContent; use PhpMcp\Schema\Content\EmbeddedResource; use PhpMcp\Server\Tests\Fixtures\Enums\StatusEnum; use PhpMcp\Server\Tests\Fixtures\General\PromptHandlerFixture; use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture; use PhpMcp\Server\Tests\Unit\Attributes\TestEnum; use Psr\Container\ContainerInterface; beforeEach(function () { $this->container = Mockery::mock(ContainerInterface::class); $this->container->shouldReceive('get') ->with(PromptHandlerFixture::class) ->andReturn(new PromptHandlerFixture()) ->byDefault(); $this->promptSchema = PromptSchema::make( 'test-greeting-prompt', 'Generates a greeting.', [PromptArgument::make('name', 'The name to greet.', true)] ); $this->context = new Context(Mockery::mock(SessionInterface::class)); }); it('constructs correctly with schema, handler, and completion providers', function () { $providers = ['name' => CompletionProviderFixture::class]; $prompt = RegisteredPrompt::make( $this->promptSchema, [PromptHandlerFixture::class, 'promptWithArgumentCompletion'], false, $providers ); expect($prompt->schema)->toBe($this->promptSchema); expect($prompt->handler)->toBe([PromptHandlerFixture::class, 'promptWithArgumentCompletion']); expect($prompt->isManual)->toBeFalse(); expect($prompt->completionProviders)->toEqual($providers); expect($prompt->completionProviders['name'])->toBe(CompletionProviderFixture::class); expect($prompt->completionProviders)->not->toHaveKey('nonExistentArg'); }); it('can be made as a manual registration', function () { $manualPrompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'generateSimpleGreeting'], true); expect($manualPrompt->isManual)->toBeTrue(); }); it('calls handler with prepared arguments via get()', function () { $handlerMock = Mockery::mock(PromptHandlerFixture::class); $handlerMock->shouldReceive('generateSimpleGreeting') ->with('Alice', 'warm') ->once() ->andReturn([['role' => 'user', 'content' => 'Warm greeting for Alice.']]); $this->container->shouldReceive('get')->with(PromptHandlerFixture::class)->andReturn($handlerMock); $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'generateSimpleGreeting']); $messages = $prompt->get($this->container, ['name' => 'Alice', 'style' => 'warm'], $this->context); expect($messages[0]->content->text)->toBe('Warm greeting for Alice.'); }); it('formats single PromptMessage object from handler', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnSinglePromptMessageObject']); $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toBeArray()->toHaveCount(1); expect($messages[0])->toBeInstanceOf(PromptMessage::class); expect($messages[0]->content->text)->toBe("Single PromptMessage object."); }); it('formats array of PromptMessage objects from handler as is', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnArrayOfPromptMessageObjects']); $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toBeArray()->toHaveCount(2); expect($messages[0]->content->text)->toBe("First message object."); expect($messages[1]->content)->toBeInstanceOf(ImageContent::class); }); it('formats empty array from handler as empty array', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnEmptyArrayForPrompt']); $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toBeArray()->toBeEmpty(); }); it('formats simple user/assistant map from handler', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnSimpleUserAssistantMap']); $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toHaveCount(2); expect($messages[0]->role)->toBe(Role::User); expect($messages[0]->content->text)->toBe("This is the user's turn."); expect($messages[1]->role)->toBe(Role::Assistant); expect($messages[1]->content->text)->toBe("And this is the assistant's reply."); }); it('formats user/assistant map with Content objects', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithContentObjects']); $messages = $prompt->get($this->container, [], $this->context); expect($messages[0]->role)->toBe(Role::User); expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User text content object."); expect($messages[1]->role)->toBe(Role::Assistant); expect($messages[1]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("asst_img_data"); }); it('formats user/assistant map with mixed content (string and Content object)', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithMixedContent']); $messages = $prompt->get($this->container, [], $this->context); expect($messages[0]->role)->toBe(Role::User); expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("Plain user string."); expect($messages[1]->role)->toBe(Role::Assistant); expect($messages[1]->content)->toBeInstanceOf(AudioContent::class)->data->toBe("aud_data"); }); it('formats user/assistant map with array content', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnUserAssistantMapWithArrayContent']); $messages = $prompt->get($this->container, [], $this->context); expect($messages[0]->role)->toBe(Role::User); expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User array content"); expect($messages[1]->role)->toBe(Role::Assistant); expect($messages[1]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("asst_arr_img_data"); }); it('formats list of raw message arrays with various content types', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnListOfRawMessageArrays']); $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toHaveCount(6); expect($messages[0]->content->text)->toBe("First raw message string."); expect($messages[1]->content)->toBeInstanceOf(TextContent::class); expect($messages[2]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("raw_img_data"); expect($messages[3]->content)->toBeInstanceOf(AudioContent::class)->data->toBe("raw_aud_data"); expect($messages[4]->content)->toBeInstanceOf(EmbeddedResource::class); expect($messages[4]->content->resource->blob)->toBe(base64_encode('pdf-data')); expect($messages[5]->content)->toBeInstanceOf(EmbeddedResource::class); expect($messages[5]->content->resource->text)->toBe('{"theme":"dark"}'); }); it('formats list of raw message arrays with scalar or array content (becoming JSON TextContent)', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnListOfRawMessageArraysWithScalars']); $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toHaveCount(5); expect($messages[0]->content->text)->toBe("123"); expect($messages[1]->content->text)->toBe("true"); expect($messages[2]->content->text)->toBe("(null)"); expect($messages[3]->content->text)->toBe("3.14"); expect($messages[4]->content->text)->toBe(json_encode(['key' => 'value'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); }); it('formats mixed array of PromptMessage objects and raw message arrays', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'returnMixedArrayOfPromptMessagesAndRaw']); $messages = $prompt->get($this->container, [], $this->context); expect($messages)->toHaveCount(4); expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("This is a PromptMessage object."); expect($messages[1]->content)->toBeInstanceOf(TextContent::class)->text->toBe("This is a raw message array."); expect($messages[2]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("pm_img"); expect($messages[3]->content)->toBeInstanceOf(TextContent::class)->text->toBe("Raw message with typed content."); }); dataset('prompt_format_errors', [ 'non_array_return' => ['promptReturnsNonArray', '/Prompt generator method must return an array/'], 'invalid_role_in_array' => ['promptReturnsInvalidRole', "/Invalid role 'system'/"], 'invalid_content_structure_in_array' => ['promptReturnsArrayWithInvalidContentStructure', "/Invalid message format at index 0. Expected an array with 'role' and 'content' keys./"], // More specific from formatMessage 'invalid_typed_content_in_array' => ['promptReturnsArrayWithInvalidTypedContent', "/Invalid 'image' content at index 0: Missing or invalid 'data' string/"], 'invalid_resource_content_in_array' => ['promptReturnsArrayWithInvalidResourceContent', "/Invalid resource at index 0: Must contain 'text' or 'blob'./"], ]); it('throws RuntimeException for invalid prompt result formats', function (string|callable $handlerMethodOrCallable, string $expectedErrorPattern) { $methodName = is_string($handlerMethodOrCallable) ? $handlerMethodOrCallable : 'customReturn'; $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, $methodName]); if (is_callable($handlerMethodOrCallable)) { $this->container->shouldReceive('get')->with(PromptHandlerFixture::class)->andReturn( Mockery::mock(PromptHandlerFixture::class, [$methodName => $handlerMethodOrCallable()]) ); } try { $prompt->get($this->container, [], $this->context); } catch (\RuntimeException $e) { expect($e->getMessage())->toMatch($expectedErrorPattern); } expect($prompt->toArray())->toBeArray(); })->with('prompt_format_errors'); it('propagates exceptions from handler during get()', function () { $prompt = RegisteredPrompt::make($this->promptSchema, [PromptHandlerFixture::class, 'promptHandlerThrows']); $prompt->get($this->container, [], $this->context); })->throws(\LogicException::class, "Prompt generation failed inside handler."); it('can be serialized to array and deserialized with completion providers', function () { $schema = PromptSchema::make( 'serialize-prompt', 'Test SerDe', [PromptArgument::make('arg1', required: true), PromptArgument::make('arg2', 'description for arg2')] ); $providers = ['arg1' => CompletionProviderFixture::class]; $serializedProviders = ['arg1' => serialize(CompletionProviderFixture::class)]; $original = RegisteredPrompt::make( $schema, [PromptHandlerFixture::class, 'generateSimpleGreeting'], true, $providers ); $array = $original->toArray(); expect($array['schema']['name'])->toBe('serialize-prompt'); expect($array['schema']['arguments'])->toHaveCount(2); expect($array['handler'])->toBe([PromptHandlerFixture::class, 'generateSimpleGreeting']); expect($array['isManual'])->toBeTrue(); expect($array['completionProviders'])->toEqual($serializedProviders); $rehydrated = RegisteredPrompt::fromArray($array); expect($rehydrated)->toBeInstanceOf(RegisteredPrompt::class); expect($rehydrated->schema->name)->toEqual($original->schema->name); expect($rehydrated->isManual)->toBeTrue(); expect($rehydrated->completionProviders)->toEqual($providers); }); it('fromArray returns false on failure for prompt', function () { $badData = ['schema' => ['name' => 'fail']]; expect(RegisteredPrompt::fromArray($badData))->toBeFalse(); }); it('can be serialized with ListCompletionProvider instances', function () { $schema = PromptSchema::make( 'list-prompt', 'Test list completion', [PromptArgument::make('status')] ); $listProvider = new \PhpMcp\Server\Defaults\ListCompletionProvider(['draft', 'published', 'archived']); $providers = ['status' => $listProvider]; $original = RegisteredPrompt::make( $schema, [PromptHandlerFixture::class, 'generateSimpleGreeting'], true, $providers ); $array = $original->toArray(); expect($array['completionProviders']['status'])->toBeString(); // Serialized instance $rehydrated = RegisteredPrompt::fromArray($array); expect($rehydrated->completionProviders['status'])->toBeInstanceOf(\PhpMcp\Server\Defaults\ListCompletionProvider::class); }); it('can be serialized with EnumCompletionProvider instances', function () { $schema = PromptSchema::make( 'enum-prompt', 'Test enum completion', [PromptArgument::make('priority')] ); $enumProvider = new \PhpMcp\Server\Defaults\EnumCompletionProvider(StatusEnum::class); $providers = ['priority' => $enumProvider]; $original = RegisteredPrompt::make( $schema, [PromptHandlerFixture::class, 'generateSimpleGreeting'], true, $providers ); $array = $original->toArray(); expect($array['completionProviders']['priority'])->toBeString(); // Serialized instance $rehydrated = RegisteredPrompt::fromArray($array); expect($rehydrated->completionProviders['priority'])->toBeInstanceOf(\PhpMcp\Server\Defaults\EnumCompletionProvider::class); }); ``` -------------------------------------------------------------------------------- /tests/Integration/StdioServerTransportTest.php: -------------------------------------------------------------------------------- ```php <?php use PhpMcp\Server\Protocol; use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture; use React\ChildProcess\Process; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; use function React\Async\await; const STDIO_SERVER_SCRIPT_PATH = __DIR__ . '/../Fixtures/ServerScripts/StdioTestServer.php'; const PROCESS_TIMEOUT_SECONDS = 5; function sendRequestToServer(Process $process, string $requestId, string $method, array $params = []): void { $request = json_encode([ 'jsonrpc' => '2.0', 'id' => $requestId, 'method' => $method, 'params' => $params, ]); $process->stdin->write($request . "\n"); } function sendNotificationToServer(Process $process, string $method, array $params = []): void { $notification = json_encode([ 'jsonrpc' => '2.0', 'method' => $method, 'params' => $params, ]); $process->stdin->write($notification . "\n"); } function readResponseFromServer(Process $process, string $expectedRequestId, LoopInterface $loop): PromiseInterface { $deferred = new Deferred(); $buffer = ''; $dataListener = function ($chunk) use (&$buffer, $deferred, $expectedRequestId, $process, &$dataListener) { $buffer .= $chunk; if (str_contains($buffer, "\n")) { $lines = explode("\n", $buffer); $buffer = array_pop($lines); foreach ($lines as $line) { if (empty(trim($line))) { continue; } try { $response = json_decode(trim($line), true); if (array_key_exists('id', $response) && $response['id'] == $expectedRequestId) { $process->stdout->removeListener('data', $dataListener); $deferred->resolve($response); return; } elseif (isset($response['method']) && str_starts_with($response['method'], 'notifications/')) { // It's a notification, log it or handle if necessary for a specific test, but don't resolve } } catch (\JsonException $e) { $process->stdout->removeListener('data', $dataListener); $deferred->reject(new \RuntimeException("Failed to decode JSON response: " . $line, 0, $e)); return; } } } }; $process->stdout->on('data', $dataListener); return timeout($deferred->promise(), PROCESS_TIMEOUT_SECONDS, $loop) ->catch(function ($reason) use ($expectedRequestId) { if ($reason instanceof \RuntimeException && str_contains($reason->getMessage(), 'Timed out after')) { throw new \RuntimeException("Timeout waiting for response to request ID '{$expectedRequestId}'"); } throw $reason; }) ->finally(function () use ($process, $dataListener) { $process->stdout->removeListener('data', $dataListener); }); } beforeEach(function () { $this->loop = Loop::get(); if (!is_executable(STDIO_SERVER_SCRIPT_PATH)) { chmod(STDIO_SERVER_SCRIPT_PATH, 0755); } $phpPath = PHP_BINARY ?: 'php'; $command = escapeshellarg($phpPath) . ' ' . escapeshellarg(STDIO_SERVER_SCRIPT_PATH); $this->process = new Process($command); $this->process->start($this->loop); $this->processErrorOutput = ''; $this->process->stderr->on('data', function ($chunk) { $this->processErrorOutput .= $chunk; }); }); afterEach(function () { if ($this->process instanceof Process && $this->process->isRunning()) { if ($this->process->stdin->isWritable()) { $this->process->stdin->end(); } $this->process->stdout->close(); $this->process->stdin->close(); $this->process->stderr->close(); $this->process->terminate(SIGTERM); await(delay(0.05, $this->loop)); if ($this->process->isRunning()) { $this->process->terminate(SIGKILL); } } $this->process = null; }); it('starts the stdio server, initializes, calls a tool, and closes', function () { // 1. Initialize Request sendRequestToServer($this->process, 'init-1', 'initialize', [ 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'PestTestClient', 'version' => '1.0'], 'capabilities' => [] ]); $initResponse = await(readResponseFromServer($this->process, 'init-1', $this->loop)); expect($initResponse['id'])->toBe('init-1'); expect($initResponse)->not->toHaveKey('error'); expect($initResponse['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); expect($initResponse['result']['serverInfo']['name'])->toBe('StdioIntegrationTestServer'); // 2. Initialized Notification sendNotificationToServer($this->process, 'notifications/initialized'); await(delay(0.05, $this->loop)); // 3. Call a tool sendRequestToServer($this->process, 'tool-call-1', 'tools/call', [ 'name' => 'greet_stdio_tool', 'arguments' => ['name' => 'Integration Tester'] ]); $toolResponse = await(readResponseFromServer($this->process, 'tool-call-1', $this->loop)); expect($toolResponse['id'])->toBe('tool-call-1'); expect($toolResponse)->not->toHaveKey('error'); expect($toolResponse['result']['content'][0]['text'])->toBe('Hello, Integration Tester!'); expect($toolResponse['result']['isError'])->toBeFalse(); $this->process->stdin->end(); })->group('integration', 'stdio_transport'); it('can handle invalid JSON request from client', function () { $this->process->stdin->write("this is not json\n"); $response = await(readResponseFromServer($this->process, '', $this->loop)); expect($response['id'])->toBe(''); expect($response['error']['code'])->toBe(-32700); expect($response['error']['message'])->toContain('Invalid JSON'); $this->process->stdin->end(); })->group('integration', 'stdio_transport'); it('handles request for non-existent method', function () { sendRequestToServer($this->process, 'init-err', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); await(readResponseFromServer($this->process, 'init-err', $this->loop)); sendNotificationToServer($this->process, 'notifications/initialized'); await(delay(0.05, $this->loop)); sendRequestToServer($this->process, 'err-meth-1', 'non/existentMethod', []); $response = await(readResponseFromServer($this->process, 'err-meth-1', $this->loop)); expect($response['id'])->toBe('err-meth-1'); expect($response['error']['code'])->toBe(-32601); expect($response['error']['message'])->toContain("Method 'non/existentMethod' not found"); $this->process->stdin->end(); })->group('integration', 'stdio_transport'); it('can handle batch requests correctly', function () { // 1. Initialize sendRequestToServer($this->process, 'init-batch', 'initialize', [ 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'BatchClient', 'version' => '1.0'], 'capabilities' => [] ]); await(readResponseFromServer($this->process, 'init-batch', $this->loop)); sendNotificationToServer($this->process, 'notifications/initialized'); await(delay(0.05, $this->loop)); // 2. Send Batch Request $batchRequests = [ ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_stdio_tool', 'arguments' => ['name' => 'Batch Item 1']]], ['jsonrpc' => '2.0', 'method' => 'notifications/something'], ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'greet_stdio_tool', 'arguments' => ['name' => 'Batch Item 2']]], ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] ]; $rawBatchRequest = json_encode($batchRequests); $this->process->stdin->write($rawBatchRequest . "\n"); // 3. Read Batch Response $batchResponsePromise = new Deferred(); $fullBuffer = ''; $batchDataListener = function ($chunk) use (&$fullBuffer, $batchResponsePromise, &$batchDataListener) { $fullBuffer .= $chunk; if (str_contains($fullBuffer, "\n")) { $line = trim($fullBuffer); $fullBuffer = ''; try { $decoded = json_decode($line, true); if (is_array($decoded)) { // Batch response is an array $this->process->stdout->removeListener('data', $batchDataListener); $batchResponsePromise->resolve($decoded); } } catch (\JsonException $e) { $this->process->stdout->removeListener('data', $batchDataListener); $batchResponsePromise->reject(new \RuntimeException("Batch JSON decode failed: " . $line, 0, $e)); } } }; $this->process->stdout->on('data', $batchDataListener); $batchResponseArray = await(timeout($batchResponsePromise->promise(), PROCESS_TIMEOUT_SECONDS, $this->loop)); expect($batchResponseArray)->toBeArray()->toHaveCount(3); // greet1, greet2, error $response1 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-1'))[0] ?? null; $response2 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-2'))[0] ?? null; $response3 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-3'))[0] ?? null; expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); expect($response2['result']['content'][0]['text'])->toBe('Hello, Batch Item 2!'); expect($response3['error']['code'])->toBe(-32601); expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); $this->process->stdin->end(); })->group('integration', 'stdio_transport'); it('can passes an empty context', function () { sendRequestToServer($this->process, 'init-context', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); await(readResponseFromServer($this->process, 'init-context', $this->loop)); sendNotificationToServer($this->process, 'notifications/initialized'); await(delay(0.05, $this->loop)); sendRequestToServer($this->process, 'tool-context-1', 'tools/call', [ 'name' => 'tool_reads_context', 'arguments' => [] ]); $toolResponse = await(readResponseFromServer($this->process, 'tool-context-1', $this->loop)); expect($toolResponse['id'])->toBe('tool-context-1'); expect($toolResponse)->not->toHaveKey('error'); expect($toolResponse['result']['content'][0]['text'])->toBe('No request instance present'); expect($toolResponse['result']['isError'])->toBeFalse(); $this->process->stdin->end(); })->group('integration', 'stdio_transport'); it('can handle tool list request', function () { sendRequestToServer($this->process, 'init-tool-list', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); await(readResponseFromServer($this->process, 'init-tool-list', $this->loop)); sendNotificationToServer($this->process, 'notifications/initialized'); await(delay(0.05, $this->loop)); sendRequestToServer($this->process, 'tool-list-1', 'tools/list', []); $toolListResponse = await(readResponseFromServer($this->process, 'tool-list-1', $this->loop)); expect($toolListResponse['id'])->toBe('tool-list-1'); expect($toolListResponse)->not->toHaveKey('error'); expect($toolListResponse['result']['tools'])->toBeArray()->toHaveCount(2); expect($toolListResponse['result']['tools'][0]['name'])->toBe('greet_stdio_tool'); expect($toolListResponse['result']['tools'][1]['name'])->toBe('tool_reads_context'); $this->process->stdin->end(); })->group('integration', 'stdio_transport'); it('can read a registered resource', function () { sendRequestToServer($this->process, 'init-res', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); await(readResponseFromServer($this->process, 'init-res', $this->loop)); sendNotificationToServer($this->process, 'notifications/initialized'); await(delay(0.05, $this->loop)); sendRequestToServer($this->process, 'res-read-1', 'resources/read', ['uri' => 'test://stdio/static']); $resourceResponse = await(readResponseFromServer($this->process, 'res-read-1', $this->loop)); expect($resourceResponse['id'])->toBe('res-read-1'); expect($resourceResponse)->not->toHaveKey('error'); expect($resourceResponse['result']['contents'])->toBeArray()->toHaveCount(1); expect($resourceResponse['result']['contents'][0]['uri'])->toBe('test://stdio/static'); expect($resourceResponse['result']['contents'][0]['text'])->toBe(ResourceHandlerFixture::$staticTextContent); expect($resourceResponse['result']['contents'][0]['mimeType'])->toBe('text/plain'); $this->process->stdin->end(); })->group('integration', 'stdio_transport'); it('can get a registered prompt', function () { sendRequestToServer($this->process, 'init-prompt', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); await(readResponseFromServer($this->process, 'init-prompt', $this->loop)); sendNotificationToServer($this->process, 'notifications/initialized'); await(delay(0.05, $this->loop)); sendRequestToServer($this->process, 'prompt-get-1', 'prompts/get', [ 'name' => 'simple_stdio_prompt', 'arguments' => ['name' => 'StdioPromptUser'] ]); $promptResponse = await(readResponseFromServer($this->process, 'prompt-get-1', $this->loop)); expect($promptResponse['id'])->toBe('prompt-get-1'); expect($promptResponse)->not->toHaveKey('error'); expect($promptResponse['result']['messages'])->toBeArray()->toHaveCount(1); expect($promptResponse['result']['messages'][0]['role'])->toBe('user'); expect($promptResponse['result']['messages'][0]['content']['text'])->toBe('Craft a friendly greeting for StdioPromptUser.'); $this->process->stdin->end(); })->group('integration', 'stdio_transport'); it('handles client not sending initialized notification before other requests', function () { sendRequestToServer($this->process, 'init-no-ack', 'initialize', [ 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'ForgetfulClient', 'version' => '1.0'], 'capabilities' => [] ]); await(readResponseFromServer($this->process, 'init-no-ack', $this->loop)); // Client "forgets" to send notifications/initialized sendRequestToServer($this->process, 'tool-call-no-ack', 'tools/call', [ 'name' => 'greet_stdio_tool', 'arguments' => ['name' => 'NoAckUser'] ]); $toolResponse = await(readResponseFromServer($this->process, 'tool-call-no-ack', $this->loop)); expect($toolResponse['id'])->toBe('tool-call-no-ack'); expect($toolResponse['error']['code'])->toBe(-32600); expect($toolResponse['error']['message'])->toContain('Client session not initialized'); $this->process->stdin->end(); })->group('integration', 'stdio_transport'); ``` -------------------------------------------------------------------------------- /tests/Unit/Utils/SchemaValidatorTest.php: -------------------------------------------------------------------------------- ```php <?php namespace PhpMcp\Server\Tests\Unit\Utils; use Mockery; use PhpMcp\Server\Utils\SchemaValidator; use Psr\Log\LoggerInterface; use PhpMcp\Server\Attributes\Schema; use PhpMcp\Server\Attributes\Schema\ArrayItems; use PhpMcp\Server\Attributes\Schema\Format; use PhpMcp\Server\Attributes\Schema\Property; // --- Setup --- beforeEach(function () { /** @var \Mockery\MockInterface&\Psr\Log\LoggerInterface */ $this->loggerMock = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); $this->validator = new SchemaValidator($this->loggerMock); }); // --- Helper Data & Schemas --- function getSimpleSchema(): array { return [ 'type' => 'object', 'properties' => [ 'name' => ['type' => 'string', 'description' => 'The name'], 'age' => ['type' => 'integer', 'minimum' => 0], 'active' => ['type' => 'boolean'], 'score' => ['type' => 'number'], 'items' => ['type' => 'array', 'items' => ['type' => 'string']], 'nullableValue' => ['type' => ['string', 'null']], 'optionalValue' => ['type' => 'string'], // Not required ], 'required' => ['name', 'age', 'active', 'score', 'items', 'nullableValue'], 'additionalProperties' => false, ]; } function getValidData(): array { return [ 'name' => 'Tester', 'age' => 30, 'active' => true, 'score' => 99.5, 'items' => ['a', 'b'], 'nullableValue' => null, 'optionalValue' => 'present', ]; } // --- Basic Validation Tests --- test('valid data passes validation', function () { $schema = getSimpleSchema(); $data = getValidData(); $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toBeEmpty(); }); test('invalid type generates type error', function () { $schema = getSimpleSchema(); $data = getValidData(); $data['age'] = 'thirty'; // Invalid type $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toHaveCount(1) ->and($errors[0]['pointer'])->toBe('/age') ->and($errors[0]['keyword'])->toBe('type') ->and($errors[0]['message'])->toContain('Expected `integer`'); }); test('missing required property generates required error', function () { $schema = getSimpleSchema(); $data = getValidData(); unset($data['name']); // Missing required $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toHaveCount(1) ->and($errors[0]['keyword'])->toBe('required') ->and($errors[0]['message'])->toContain('Missing required properties: `name`'); }); test('additional property generates additionalProperties error', function () { $schema = getSimpleSchema(); $data = getValidData(); $data['extra'] = 'not allowed'; // Additional property $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toHaveCount(1) ->and($errors[0]['pointer'])->toBe('/') // Error reported at the object root ->and($errors[0]['keyword'])->toBe('additionalProperties') ->and($errors[0]['message'])->toContain('Additional object properties are not allowed: ["extra"]'); }); // --- Keyword Constraint Tests --- test('enum constraint violation', function () { $schema = ['type' => 'string', 'enum' => ['A', 'B']]; $data = 'C'; $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toHaveCount(1) ->and($errors[0]['keyword'])->toBe('enum') ->and($errors[0]['message'])->toContain('must be one of the allowed values: "A", "B"'); }); test('minimum constraint violation', function () { $schema = ['type' => 'integer', 'minimum' => 10]; $data = 5; $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toHaveCount(1) ->and($errors[0]['keyword'])->toBe('minimum') ->and($errors[0]['message'])->toContain('must be greater than or equal to 10'); }); test('maxLength constraint violation', function () { $schema = ['type' => 'string', 'maxLength' => 5]; $data = 'toolong'; $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toHaveCount(1) ->and($errors[0]['keyword'])->toBe('maxLength') ->and($errors[0]['message'])->toContain('Maximum string length is 5, found 7'); }); test('pattern constraint violation', function () { $schema = ['type' => 'string', 'pattern' => '^[a-z]+$']; $data = '123'; $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toHaveCount(1) ->and($errors[0]['keyword'])->toBe('pattern') ->and($errors[0]['message'])->toContain('does not match the required pattern: `^[a-z]+$`'); }); test('minItems constraint violation', function () { $schema = ['type' => 'array', 'minItems' => 2]; $data = ['one']; $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toHaveCount(1) ->and($errors[0]['keyword'])->toBe('minItems') ->and($errors[0]['message'])->toContain('Array should have at least 2 items, 1 found'); }); test('uniqueItems constraint violation', function () { $schema = ['type' => 'array', 'uniqueItems' => true]; $data = ['a', 'b', 'a']; $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toHaveCount(1) ->and($errors[0]['keyword'])->toBe('uniqueItems') ->and($errors[0]['message'])->toContain('Array must have unique items'); }); // --- Nested Structures and Pointers --- test('nested object validation error pointer', function () { $schema = [ 'type' => 'object', 'properties' => [ 'user' => [ 'type' => 'object', 'properties' => ['id' => ['type' => 'integer']], 'required' => ['id'], ], ], 'required' => ['user'], ]; $data = ['user' => ['id' => 'abc']]; // Invalid nested type $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toHaveCount(1) ->and($errors[0]['pointer'])->toBe('/user/id'); }); test('array item validation error pointer', function () { $schema = [ 'type' => 'array', 'items' => ['type' => 'integer'], ]; $data = [1, 2, 'three', 4]; // Invalid item type $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toHaveCount(1) ->and($errors[0]['pointer'])->toBe('/2'); // Pointer to the index of the invalid item }); // --- Data Conversion Tests --- test('validates data passed as stdClass object', function () { $schema = getSimpleSchema(); $dataObj = json_decode(json_encode(getValidData())); // Convert to stdClass $errors = $this->validator->validateAgainstJsonSchema($dataObj, $schema); expect($errors)->toBeEmpty(); }); test('validates data with nested associative arrays correctly', function () { $schema = [ 'type' => 'object', 'properties' => [ 'nested' => [ 'type' => 'object', 'properties' => ['key' => ['type' => 'string']], 'required' => ['key'], ], ], 'required' => ['nested'], ]; $data = ['nested' => ['key' => 'value']]; // Nested assoc array $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toBeEmpty(); }); // --- Edge Cases --- test('handles invalid schema structure gracefully', function () { $schema = ['type' => 'object', 'properties' => ['name' => ['type' => 123]]]; // Invalid type value $data = ['name' => 'test']; $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->toHaveCount(1) ->and($errors[0]['keyword'])->toBe('internal') ->and($errors[0]['message'])->toContain('Schema validation process failed'); }); test('handles empty data object against schema requiring properties', function () { $schema = getSimpleSchema(); // Requires name, age etc. $data = []; // Empty data $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->not->toBeEmpty() ->and($errors[0]['keyword'])->toBe('required'); }); test('handles empty schema (allows anything)', function () { $schema = []; // Empty schema object/array implies no constraints $data = ['anything' => [1, 2], 'goes' => true]; $errors = $this->validator->validateAgainstJsonSchema($data, $schema); expect($errors)->not->toBeEmpty() ->and($errors[0]['keyword'])->toBe('internal') ->and($errors[0]['message'])->toContain('Invalid schema'); }); test('validates schema with string format constraints from Schema attribute', function () { $emailSchema = (new Schema(format: 'email'))->toArray(); // Valid email $validErrors = $this->validator->validateAgainstJsonSchema('[email protected]', $emailSchema); expect($validErrors)->toBeEmpty(); // Invalid email $invalidErrors = $this->validator->validateAgainstJsonSchema('not-an-email', $emailSchema); expect($invalidErrors)->not->toBeEmpty() ->and($invalidErrors[0]['keyword'])->toBe('format') ->and($invalidErrors[0]['message'])->toContain('email'); }); test('validates schema with string length constraints from Schema attribute', function () { $passwordSchema = (new Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$'))->toArray(); // Valid password (meets length and pattern) $validErrors = $this->validator->validateAgainstJsonSchema('Password123', $passwordSchema); expect($validErrors)->toBeEmpty(); // Invalid - too short $shortErrors = $this->validator->validateAgainstJsonSchema('Pass1', $passwordSchema); expect($shortErrors)->not->toBeEmpty() ->and($shortErrors[0]['keyword'])->toBe('minLength'); // Invalid - no digit $noDigitErrors = $this->validator->validateAgainstJsonSchema('PasswordXYZ', $passwordSchema); expect($noDigitErrors)->not->toBeEmpty() ->and($noDigitErrors[0]['keyword'])->toBe('pattern'); }); test('validates schema with numeric constraints from Schema attribute', function () { $ageSchema = (new Schema(minimum: 18, maximum: 120))->toArray(); // Valid age $validErrors = $this->validator->validateAgainstJsonSchema(25, $ageSchema); expect($validErrors)->toBeEmpty(); // Invalid - too low $tooLowErrors = $this->validator->validateAgainstJsonSchema(15, $ageSchema); expect($tooLowErrors)->not->toBeEmpty() ->and($tooLowErrors[0]['keyword'])->toBe('minimum'); // Invalid - too high $tooHighErrors = $this->validator->validateAgainstJsonSchema(150, $ageSchema); expect($tooHighErrors)->not->toBeEmpty() ->and($tooHighErrors[0]['keyword'])->toBe('maximum'); }); test('validates schema with array constraints from Schema attribute', function () { $tagsSchema = (new Schema(uniqueItems: true, minItems: 2))->toArray(); // Valid tags array $validErrors = $this->validator->validateAgainstJsonSchema(['php', 'javascript', 'python'], $tagsSchema); expect($validErrors)->toBeEmpty(); // Invalid - duplicate items $duplicateErrors = $this->validator->validateAgainstJsonSchema(['php', 'php', 'javascript'], $tagsSchema); expect($duplicateErrors)->not->toBeEmpty() ->and($duplicateErrors[0]['keyword'])->toBe('uniqueItems'); // Invalid - too few items $tooFewErrors = $this->validator->validateAgainstJsonSchema(['php'], $tagsSchema); expect($tooFewErrors)->not->toBeEmpty() ->and($tooFewErrors[0]['keyword'])->toBe('minItems'); }); test('validates schema with object constraints from Schema attribute', function () { $userSchema = (new Schema( properties: [ 'name' => ['type' => 'string', 'minLength' => 2], 'email' => ['type' => 'string', 'format' => 'email'], 'age' => ['type' => 'integer', 'minimum' => 18] ], required: ['name', 'email'] ))->toArray(); // Valid user object $validUser = [ 'name' => 'John', 'email' => '[email protected]', 'age' => 25 ]; $validErrors = $this->validator->validateAgainstJsonSchema($validUser, $userSchema); expect($validErrors)->toBeEmpty(); // Invalid - missing required email $missingEmailUser = [ 'name' => 'John', 'age' => 25 ]; $missingErrors = $this->validator->validateAgainstJsonSchema($missingEmailUser, $userSchema); expect($missingErrors)->not->toBeEmpty() ->and($missingErrors[0]['keyword'])->toBe('required'); // Invalid - name too short $shortNameUser = [ 'name' => 'J', 'email' => '[email protected]', 'age' => 25 ]; $nameErrors = $this->validator->validateAgainstJsonSchema($shortNameUser, $userSchema); expect($nameErrors)->not->toBeEmpty() ->and($nameErrors[0]['keyword'])->toBe('minLength'); // Invalid - age too low $youngUser = [ 'name' => 'John', 'email' => '[email protected]', 'age' => 15 ]; $ageErrors = $this->validator->validateAgainstJsonSchema($youngUser, $userSchema); expect($ageErrors)->not->toBeEmpty() ->and($ageErrors[0]['keyword'])->toBe('minimum'); }); test('validates schema with nested constraints from Schema attribute', function () { $orderSchema = (new Schema( properties: [ 'customer' => [ 'type' => 'object', 'properties' => [ 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], 'name' => ['type' => 'string', 'minLength' => 2] ], ], 'items' => [ 'type' => 'array', 'minItems' => 1, 'items' => [ 'type' => 'object', 'properties' => [ 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], 'quantity' => ['type' => 'integer', 'minimum' => 1] ], 'required' => ['product_id', 'quantity'] ] ] ], required: ['customer', 'items'] ))->toArray(); // Valid order $validOrder = [ 'customer' => [ 'id' => 'CUS-123456', 'name' => 'John' ], 'items' => [ [ 'product_id' => 'PRD-1234', 'quantity' => 2 ] ] ]; $validErrors = $this->validator->validateAgainstJsonSchema($validOrder, $orderSchema); expect($validErrors)->toBeEmpty(); // Invalid - bad customer ID format $badCustomerIdOrder = [ 'customer' => [ 'id' => 'CUST-123', // Wrong format 'name' => 'John' ], 'items' => [ [ 'product_id' => 'PRD-1234', 'quantity' => 2 ] ] ]; $customerIdErrors = $this->validator->validateAgainstJsonSchema($badCustomerIdOrder, $orderSchema); expect($customerIdErrors)->not->toBeEmpty() ->and($customerIdErrors[0]['keyword'])->toBe('pattern'); // Invalid - empty items array $emptyItemsOrder = [ 'customer' => [ 'id' => 'CUS-123456', 'name' => 'John' ], 'items' => [] ]; $emptyItemsErrors = $this->validator->validateAgainstJsonSchema($emptyItemsOrder, $orderSchema); expect($emptyItemsErrors)->not->toBeEmpty() ->and($emptyItemsErrors[0]['keyword'])->toBe('minItems'); // Invalid - missing required property in items $missingProductIdOrder = [ 'customer' => [ 'id' => 'CUS-123456', 'name' => 'John' ], 'items' => [ [ // Missing product_id 'quantity' => 2 ] ] ]; $missingProductIdErrors = $this->validator->validateAgainstJsonSchema($missingProductIdOrder, $orderSchema); expect($missingProductIdErrors)->not->toBeEmpty() ->and($missingProductIdErrors[0]['keyword'])->toBe('required'); }); ``` -------------------------------------------------------------------------------- /src/Registry.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server; use Evenement\EventEmitterInterface; use Evenement\EventEmitterTrait; use PhpMcp\Schema\Prompt; use PhpMcp\Schema\Resource; use PhpMcp\Schema\ResourceTemplate; use PhpMcp\Schema\Tool; use PhpMcp\Server\Elements\RegisteredPrompt; use PhpMcp\Server\Elements\RegisteredResource; use PhpMcp\Server\Elements\RegisteredResourceTemplate; use PhpMcp\Server\Elements\RegisteredTool; use PhpMcp\Server\Exception\DefinitionException; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException; use Throwable; class Registry implements EventEmitterInterface { use EventEmitterTrait; private const DISCOVERED_ELEMENTS_CACHE_KEY = 'mcp_server_discovered_elements'; /** @var array<string, RegisteredTool> */ private array $tools = []; /** @var array<string, RegisteredResource> */ private array $resources = []; /** @var array<string, RegisteredPrompt> */ private array $prompts = []; /** @var array<string, RegisteredResourceTemplate> */ private array $resourceTemplates = []; private array $listHashes = [ 'tools' => '', 'resources' => '', 'resource_templates' => '', 'prompts' => '', ]; private bool $notificationsEnabled = true; public function __construct( protected LoggerInterface $logger, protected ?CacheInterface $cache = null, ) { $this->load(); $this->computeAllHashes(); } /** * Compute hashes for all lists for change detection */ private function computeAllHashes(): void { $this->listHashes['tools'] = $this->computeHash($this->tools); $this->listHashes['resources'] = $this->computeHash($this->resources); $this->listHashes['resource_templates'] = $this->computeHash($this->resourceTemplates); $this->listHashes['prompts'] = $this->computeHash($this->prompts); } /** * Compute a stable hash for a collection */ private function computeHash(array $collection): string { if (empty($collection)) { return ''; } ksort($collection); return md5(json_encode($collection)); } public function load(): void { if ($this->cache === null) { return; } $this->clear(false); try { $cached = $this->cache->get(self::DISCOVERED_ELEMENTS_CACHE_KEY); if (!is_array($cached)) { $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); return; } $loadCount = 0; foreach ($cached['tools'] ?? [] as $toolData) { $cachedTool = RegisteredTool::fromArray(json_decode($toolData, true)); if ($cachedTool === false) { $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); continue; } $toolName = $cachedTool->schema->name; $existingTool = $this->tools[$toolName] ?? null; if ($existingTool && $existingTool->isManual) { $this->logger->debug("Skipping cached tool '{$toolName}' as manual version exists."); continue; } $this->tools[$toolName] = $cachedTool; $loadCount++; } foreach ($cached['resources'] ?? [] as $resourceData) { $cachedResource = RegisteredResource::fromArray(json_decode($resourceData, true)); if ($cachedResource === false) { $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); continue; } $uri = $cachedResource->schema->uri; $existingResource = $this->resources[$uri] ?? null; if ($existingResource && $existingResource->isManual) { $this->logger->debug("Skipping cached resource '{$uri}' as manual version exists."); continue; } $this->resources[$uri] = $cachedResource; $loadCount++; } foreach ($cached['prompts'] ?? [] as $promptData) { $cachedPrompt = RegisteredPrompt::fromArray(json_decode($promptData, true)); if ($cachedPrompt === false) { $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); continue; } $promptName = $cachedPrompt->schema->name; $existingPrompt = $this->prompts[$promptName] ?? null; if ($existingPrompt && $existingPrompt->isManual) { $this->logger->debug("Skipping cached prompt '{$promptName}' as manual version exists."); continue; } $this->prompts[$promptName] = $cachedPrompt; $loadCount++; } foreach ($cached['resourceTemplates'] ?? [] as $templateData) { $cachedTemplate = RegisteredResourceTemplate::fromArray(json_decode($templateData, true)); if ($cachedTemplate === false) { $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); continue; } $uriTemplate = $cachedTemplate->schema->uriTemplate; $existingTemplate = $this->resourceTemplates[$uriTemplate] ?? null; if ($existingTemplate && $existingTemplate->isManual) { $this->logger->debug("Skipping cached template '{$uriTemplate}' as manual version exists."); continue; } $this->resourceTemplates[$uriTemplate] = $cachedTemplate; $loadCount++; } $this->logger->debug("Loaded {$loadCount} elements from cache."); } catch (CacheInvalidArgumentException $e) { $this->logger->error('Invalid registry cache key used.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); } catch (Throwable $e) { $this->logger->error('Unexpected error loading from cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); } } public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void { $toolName = $tool->name; $existing = $this->tools[$toolName] ?? null; if ($existing && ! $isManual && $existing->isManual) { $this->logger->debug("Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one."); return; } $this->tools[$toolName] = RegisteredTool::make($tool, $handler, $isManual); $this->checkAndEmitChange('tools', $this->tools); } public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void { $uri = $resource->uri; $existing = $this->resources[$uri] ?? null; if ($existing && ! $isManual && $existing->isManual) { $this->logger->debug("Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one."); return; } $this->resources[$uri] = RegisteredResource::make($resource, $handler, $isManual); $this->checkAndEmitChange('resources', $this->resources); } public function registerResourceTemplate( ResourceTemplate $template, callable|array|string $handler, array $completionProviders = [], bool $isManual = false, ): void { $uriTemplate = $template->uriTemplate; $existing = $this->resourceTemplates[$uriTemplate] ?? null; if ($existing && ! $isManual && $existing->isManual) { $this->logger->debug("Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one."); return; } $this->resourceTemplates[$uriTemplate] = RegisteredResourceTemplate::make($template, $handler, $isManual, $completionProviders); $this->checkAndEmitChange('resource_templates', $this->resourceTemplates); } public function registerPrompt( Prompt $prompt, callable|array|string $handler, array $completionProviders = [], bool $isManual = false, ): void { $promptName = $prompt->name; $existing = $this->prompts[$promptName] ?? null; if ($existing && ! $isManual && $existing->isManual) { $this->logger->debug("Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one."); return; } $this->prompts[$promptName] = RegisteredPrompt::make($prompt, $handler, $isManual, $completionProviders); $this->checkAndEmitChange('prompts', $this->prompts); } public function enableNotifications(): void { $this->notificationsEnabled = true; } public function disableNotifications(): void { $this->notificationsEnabled = false; } /** * Check if a list has changed and emit event if needed */ private function checkAndEmitChange(string $listType, array $collection): void { if (! $this->notificationsEnabled) { return; } $newHash = $this->computeHash($collection); if ($newHash !== $this->listHashes[$listType]) { $this->listHashes[$listType] = $newHash; $this->emit('list_changed', [$listType]); } } public function save(): bool { if ($this->cache === null) { return false; } $discoveredData = [ 'tools' => [], 'resources' => [], 'prompts' => [], 'resourceTemplates' => [], ]; foreach ($this->tools as $name => $tool) { if (! $tool->isManual) { if ($tool->handler instanceof \Closure) { $this->logger->warning("Skipping closure tool from cache: {$name}"); continue; } $discoveredData['tools'][$name] = json_encode($tool); } } foreach ($this->resources as $uri => $resource) { if (! $resource->isManual) { if ($resource->handler instanceof \Closure) { $this->logger->warning("Skipping closure resource from cache: {$uri}"); continue; } $discoveredData['resources'][$uri] = json_encode($resource); } } foreach ($this->prompts as $name => $prompt) { if (! $prompt->isManual) { if ($prompt->handler instanceof \Closure) { $this->logger->warning("Skipping closure prompt from cache: {$name}"); continue; } $discoveredData['prompts'][$name] = json_encode($prompt); } } foreach ($this->resourceTemplates as $uriTemplate => $template) { if (! $template->isManual) { if ($template->handler instanceof \Closure) { $this->logger->warning("Skipping closure template from cache: {$uriTemplate}"); continue; } $discoveredData['resourceTemplates'][$uriTemplate] = json_encode($template); } } try { $success = $this->cache->set(self::DISCOVERED_ELEMENTS_CACHE_KEY, $discoveredData); if ($success) { $this->logger->debug('Registry elements saved to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); } else { $this->logger->warning('Registry cache set operation returned false.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); } return $success; } catch (CacheInvalidArgumentException $e) { $this->logger->error('Invalid cache key or value during save.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); return false; } catch (Throwable $e) { $this->logger->error('Unexpected error saving to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); return false; } } /** Checks if any elements (manual or discovered) are currently registered. */ public function hasElements(): bool { return ! empty($this->tools) || ! empty($this->resources) || ! empty($this->prompts) || ! empty($this->resourceTemplates); } /** * Clear discovered elements from registry * * @param bool $includeCache Whether to clear the cache as well (default: true) */ public function clear(bool $includeCache = true): void { if ($includeCache && $this->cache !== null) { try { $this->cache->delete(self::DISCOVERED_ELEMENTS_CACHE_KEY); $this->logger->debug('Registry cache cleared.'); } catch (Throwable $e) { $this->logger->error('Error clearing registry cache.', ['exception' => $e]); } } $clearCount = 0; foreach ($this->tools as $name => $tool) { if (! $tool->isManual) { unset($this->tools[$name]); $clearCount++; } } foreach ($this->resources as $uri => $resource) { if (! $resource->isManual) { unset($this->resources[$uri]); $clearCount++; } } foreach ($this->prompts as $name => $prompt) { if (! $prompt->isManual) { unset($this->prompts[$name]); $clearCount++; } } foreach ($this->resourceTemplates as $uriTemplate => $template) { if (! $template->isManual) { unset($this->resourceTemplates[$uriTemplate]); $clearCount++; } } if ($clearCount > 0) { $this->logger->debug("Removed {$clearCount} discovered elements from internal registry."); } } /** @return RegisteredTool|null */ public function getTool(string $name): ?RegisteredTool { return $this->tools[$name] ?? null; } /** @return RegisteredResource|RegisteredResourceTemplate|null */ public function getResource(string $uri, bool $includeTemplates = true): RegisteredResource|RegisteredResourceTemplate|null { $registration = $this->resources[$uri] ?? null; if ($registration) { return $registration; } if (! $includeTemplates) { return null; } foreach ($this->resourceTemplates as $template) { if ($template->matches($uri)) { return $template; } } $this->logger->debug('No resource matched URI.', ['uri' => $uri]); return null; } /** @return RegisteredResourceTemplate|null */ public function getResourceTemplate(string $uriTemplate): ?RegisteredResourceTemplate { return $this->resourceTemplates[$uriTemplate] ?? null; } /** @return RegisteredPrompt|null */ public function getPrompt(string $name): ?RegisteredPrompt { return $this->prompts[$name] ?? null; } /** @return array<string, Tool> */ public function getTools(): array { return array_map(fn($tool) => $tool->schema, $this->tools); } /** @return array<string, Resource> */ public function getResources(): array { return array_map(fn($resource) => $resource->schema, $this->resources); } /** @return array<string, Prompt> */ public function getPrompts(): array { return array_map(fn($prompt) => $prompt->schema, $this->prompts); } /** @return array<string, ResourceTemplate> */ public function getResourceTemplates(): array { return array_map(fn($template) => $template->schema, $this->resourceTemplates); } } ``` -------------------------------------------------------------------------------- /src/Dispatcher.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace PhpMcp\Server; use JsonException; use PhpMcp\Schema\JsonRpc\Request; use PhpMcp\Schema\JsonRpc\Notification; use PhpMcp\Schema\JsonRpc\Result; use PhpMcp\Schema\Notification\InitializedNotification; use PhpMcp\Schema\Request\CallToolRequest; use PhpMcp\Schema\Request\CompletionCompleteRequest; use PhpMcp\Schema\Request\GetPromptRequest; use PhpMcp\Schema\Request\InitializeRequest; use PhpMcp\Schema\Request\ListPromptsRequest; use PhpMcp\Schema\Request\ListResourcesRequest; use PhpMcp\Schema\Request\ListResourceTemplatesRequest; use PhpMcp\Schema\Request\ListToolsRequest; use PhpMcp\Schema\Request\PingRequest; use PhpMcp\Schema\Request\ReadResourceRequest; use PhpMcp\Schema\Request\ResourceSubscribeRequest; use PhpMcp\Schema\Request\ResourceUnsubscribeRequest; use PhpMcp\Schema\Request\SetLogLevelRequest; use PhpMcp\Server\Configuration; use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Schema\Content\TextContent; use PhpMcp\Schema\Result\CallToolResult; use PhpMcp\Schema\Result\CompletionCompleteResult; use PhpMcp\Schema\Result\EmptyResult; use PhpMcp\Schema\Result\GetPromptResult; use PhpMcp\Schema\Result\InitializeResult; use PhpMcp\Schema\Result\ListPromptsResult; use PhpMcp\Schema\Result\ListResourcesResult; use PhpMcp\Schema\Result\ListResourceTemplatesResult; use PhpMcp\Schema\Result\ListToolsResult; use PhpMcp\Schema\Result\ReadResourceResult; use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; use PhpMcp\Server\Session\SubscriptionManager; use PhpMcp\Server\Utils\SchemaValidator; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Throwable; class Dispatcher { protected ContainerInterface $container; protected LoggerInterface $logger; public function __construct( protected Configuration $configuration, protected Registry $registry, protected SubscriptionManager $subscriptionManager, protected ?SchemaValidator $schemaValidator = null, ) { $this->container = $this->configuration->container; $this->logger = $this->configuration->logger; $this->schemaValidator ??= new SchemaValidator($this->logger); } public function handleRequest(Request $request, Context $context): Result { switch ($request->method) { case 'initialize': $request = InitializeRequest::fromRequest($request); return $this->handleInitialize($request, $context->session); case 'ping': $request = PingRequest::fromRequest($request); return $this->handlePing($request); case 'tools/list': $request = ListToolsRequest::fromRequest($request); return $this->handleToolList($request); case 'tools/call': $request = CallToolRequest::fromRequest($request); return $this->handleToolCall($request, $context); case 'resources/list': $request = ListResourcesRequest::fromRequest($request); return $this->handleResourcesList($request); case 'resources/templates/list': $request = ListResourceTemplatesRequest::fromRequest($request); return $this->handleResourceTemplateList($request); case 'resources/read': $request = ReadResourceRequest::fromRequest($request); return $this->handleResourceRead($request, $context); case 'resources/subscribe': $request = ResourceSubscribeRequest::fromRequest($request); return $this->handleResourceSubscribe($request, $context->session); case 'resources/unsubscribe': $request = ResourceUnsubscribeRequest::fromRequest($request); return $this->handleResourceUnsubscribe($request, $context->session); case 'prompts/list': $request = ListPromptsRequest::fromRequest($request); return $this->handlePromptsList($request); case 'prompts/get': $request = GetPromptRequest::fromRequest($request); return $this->handlePromptGet($request, $context); case 'logging/setLevel': $request = SetLogLevelRequest::fromRequest($request); return $this->handleLoggingSetLevel($request, $context->session); case 'completion/complete': $request = CompletionCompleteRequest::fromRequest($request); return $this->handleCompletionComplete($request, $context->session); default: throw McpServerException::methodNotFound("Method '{$request->method}' not found."); } } public function handleNotification(Notification $notification, SessionInterface $session): void { switch ($notification->method) { case 'notifications/initialized': $notification = InitializedNotification::fromNotification($notification); $this->handleNotificationInitialized($notification, $session); } } public function handleInitialize(InitializeRequest $request, SessionInterface $session): InitializeResult { if (in_array($request->protocolVersion, Protocol::SUPPORTED_PROTOCOL_VERSIONS)) { $protocolVersion = $request->protocolVersion; } else { $protocolVersion = Protocol::LATEST_PROTOCOL_VERSION; } $session->set('client_info', $request->clientInfo->toArray()); $session->set('protocol_version', $protocolVersion); $serverInfo = $this->configuration->serverInfo; $capabilities = $this->configuration->capabilities; $instructions = $this->configuration->instructions; return new InitializeResult($protocolVersion, $capabilities, $serverInfo, $instructions); } public function handlePing(PingRequest $request): EmptyResult { return new EmptyResult(); } public function handleToolList(ListToolsRequest $request): ListToolsResult { $limit = $this->configuration->paginationLimit; $offset = $this->decodeCursor($request->cursor); $allItems = $this->registry->getTools(); $pagedItems = array_slice($allItems, $offset, $limit); $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); return new ListToolsResult(array_values($pagedItems), $nextCursor); } public function handleToolCall(CallToolRequest $request, Context $context): CallToolResult { $toolName = $request->name; $arguments = $request->arguments; $registeredTool = $this->registry->getTool($toolName); if (! $registeredTool) { throw McpServerException::methodNotFound("Tool '{$toolName}' not found."); } $inputSchema = $registeredTool->schema->inputSchema; $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema); if (! empty($validationErrors)) { $errorMessages = []; foreach ($validationErrors as $errorDetail) { $pointer = $errorDetail['pointer'] ?? ''; $message = $errorDetail['message'] ?? 'Unknown validation error'; $errorMessages[] = ($pointer !== '/' && $pointer !== '' ? "Property '{$pointer}': " : '') . $message; } $summaryMessage = "Invalid parameters for tool '{$toolName}': " . implode('; ', array_slice($errorMessages, 0, 3)); if (count($errorMessages) > 3) { $summaryMessage .= '; ...and more errors.'; } throw McpServerException::invalidParams($summaryMessage, data: ['validation_errors' => $validationErrors]); } try { $result = $registeredTool->call($this->container, $arguments, $context); return new CallToolResult($result, false); } catch (JsonException $e) { $this->logger->warning('Failed to JSON encode tool result.', ['tool' => $toolName, 'exception' => $e]); $errorMessage = "Failed to serialize tool result: {$e->getMessage()}"; return new CallToolResult([new TextContent($errorMessage)], true); } catch (Throwable $toolError) { $this->logger->error('Tool execution failed.', ['tool' => $toolName, 'exception' => $toolError]); $errorMessage = "Tool execution failed: {$toolError->getMessage()}"; return new CallToolResult([new TextContent($errorMessage)], true); } } public function handleResourcesList(ListResourcesRequest $request): ListResourcesResult { $limit = $this->configuration->paginationLimit; $offset = $this->decodeCursor($request->cursor); $allItems = $this->registry->getResources(); $pagedItems = array_slice($allItems, $offset, $limit); $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); return new ListResourcesResult(array_values($pagedItems), $nextCursor); } public function handleResourceTemplateList(ListResourceTemplatesRequest $request): ListResourceTemplatesResult { $limit = $this->configuration->paginationLimit; $offset = $this->decodeCursor($request->cursor); $allItems = $this->registry->getResourceTemplates(); $pagedItems = array_slice($allItems, $offset, $limit); $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); return new ListResourceTemplatesResult(array_values($pagedItems), $nextCursor); } public function handleResourceRead(ReadResourceRequest $request, Context $context): ReadResourceResult { $uri = $request->uri; $registeredResource = $this->registry->getResource($uri); if (! $registeredResource) { throw McpServerException::invalidParams("Resource URI '{$uri}' not found."); } try { $result = $registeredResource->read($this->container, $uri, $context); return new ReadResourceResult($result); } catch (JsonException $e) { $this->logger->warning('Failed to JSON encode resource content.', ['exception' => $e, 'uri' => $uri]); throw McpServerException::internalError("Failed to serialize resource content for '{$uri}'.", $e); } catch (McpServerException $e) { throw $e; } catch (Throwable $e) { $this->logger->error('Resource read failed.', ['uri' => $uri, 'exception' => $e]); throw McpServerException::resourceReadFailed($uri, $e); } } public function handleResourceSubscribe(ResourceSubscribeRequest $request, SessionInterface $session): EmptyResult { $this->subscriptionManager->subscribe($session->getId(), $request->uri); return new EmptyResult(); } public function handleResourceUnsubscribe(ResourceUnsubscribeRequest $request, SessionInterface $session): EmptyResult { $this->subscriptionManager->unsubscribe($session->getId(), $request->uri); return new EmptyResult(); } public function handlePromptsList(ListPromptsRequest $request): ListPromptsResult { $limit = $this->configuration->paginationLimit; $offset = $this->decodeCursor($request->cursor); $allItems = $this->registry->getPrompts(); $pagedItems = array_slice($allItems, $offset, $limit); $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); return new ListPromptsResult(array_values($pagedItems), $nextCursor); } public function handlePromptGet(GetPromptRequest $request, Context $context): GetPromptResult { $promptName = $request->name; $arguments = $request->arguments; $registeredPrompt = $this->registry->getPrompt($promptName); if (! $registeredPrompt) { throw McpServerException::invalidParams("Prompt '{$promptName}' not found."); } $arguments = (array) $arguments; foreach ($registeredPrompt->schema->arguments as $argDef) { if ($argDef->required && ! array_key_exists($argDef->name, $arguments)) { throw McpServerException::invalidParams("Missing required argument '{$argDef->name}' for prompt '{$promptName}'."); } } try { $result = $registeredPrompt->get($this->container, $arguments, $context); return new GetPromptResult($result, $registeredPrompt->schema->description); } catch (JsonException $e) { $this->logger->warning('Failed to JSON encode prompt messages.', ['exception' => $e, 'promptName' => $promptName]); throw McpServerException::internalError("Failed to serialize prompt messages for '{$promptName}'.", $e); } catch (McpServerException $e) { throw $e; } catch (Throwable $e) { $this->logger->error('Prompt generation failed.', ['promptName' => $promptName, 'exception' => $e]); throw McpServerException::promptGenerationFailed($promptName, $e); } } public function handleLoggingSetLevel(SetLogLevelRequest $request, SessionInterface $session): EmptyResult { $level = $request->level; $session->set('log_level', $level->value); $this->logger->info("Log level set to '{$level->value}'.", ['sessionId' => $session->getId()]); return new EmptyResult(); } public function handleCompletionComplete(CompletionCompleteRequest $request, SessionInterface $session): CompletionCompleteResult { $ref = $request->ref; $argumentName = $request->argument['name']; $currentValue = $request->argument['value']; $identifier = null; if ($ref->type === 'ref/prompt') { $identifier = $ref->name; $registeredPrompt = $this->registry->getPrompt($identifier); if (! $registeredPrompt) { throw McpServerException::invalidParams("Prompt '{$identifier}' not found."); } $foundArg = false; foreach ($registeredPrompt->schema->arguments as $arg) { if ($arg->name === $argumentName) { $foundArg = true; break; } } if (! $foundArg) { throw McpServerException::invalidParams("Argument '{$argumentName}' not found in prompt '{$identifier}'."); } return $registeredPrompt->complete($this->container, $argumentName, $currentValue, $session); } elseif ($ref->type === 'ref/resource') { $identifier = $ref->uri; $registeredResourceTemplate = $this->registry->getResourceTemplate($identifier); if (! $registeredResourceTemplate) { throw McpServerException::invalidParams("Resource template '{$identifier}' not found."); } $foundArg = false; foreach ($registeredResourceTemplate->getVariableNames() as $uriVariableName) { if ($uriVariableName === $argumentName) { $foundArg = true; break; } } if (! $foundArg) { throw McpServerException::invalidParams("URI variable '{$argumentName}' not found in resource template '{$identifier}'."); } return $registeredResourceTemplate->complete($this->container, $argumentName, $currentValue, $session); } else { throw McpServerException::invalidParams("Invalid ref type '{$ref->type}' for completion complete request."); } } public function handleNotificationInitialized(InitializedNotification $notification, SessionInterface $session): EmptyResult { $session->set('initialized', true); return new EmptyResult(); } private function decodeCursor(?string $cursor): int { if ($cursor === null) { return 0; } $decoded = base64_decode($cursor, true); if ($decoded === false) { $this->logger->warning('Received invalid pagination cursor (not base64)', ['cursor' => $cursor]); return 0; } if (preg_match('/^offset=(\d+)$/', $decoded, $matches)) { return (int) $matches[1]; } $this->logger->warning('Received invalid pagination cursor format', ['cursor' => $decoded]); return 0; } private function encodeNextCursor(int $currentOffset, int $returnedCount, int $totalCount, int $limit): ?string { $nextOffset = $currentOffset + $returnedCount; if ($returnedCount > 0 && $nextOffset < $totalCount) { return base64_encode("offset={$nextOffset}"); } return null; } } ```