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