diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e34dab..d7880dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added support for Resources + - Support for `resources/list`, `resources/templates/list` and `resources/read` JSON-RPC methods + - Added `AsResource` attribute to register resources + - Added events for resource management (`ResourceReadEvent`, `ResourceReadResultEvent`) + - Added tests to cover the resource functionality + +### Updated + +- Updated MCP Protocol to version `2025-06-18` +- Updated the configuration to include the `title` value, used in the initialization phase (JSON-RPC `initialize` method) +- Updated the `initialize` JSON-RPC method to return the `title` value from the configuration +- Updated the README to include the new resource functionality and examples + ## [1.1.0] - 2025-06-19 ### Added diff --git a/README.md b/README.md index 2b19e5a..8d9bc6b 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,20 @@ _Read the [official MCP specification](https://modelcontextprotocol.io/docs/conc - [Tool Events](#tool-events) - [Input Schema Management](#input-schema-management) - [JSON-RPC Integration](#json-rpc-integration) +- [Resources](#resources) + - [Creating Resources](#creating-resources) + - [Static Resources](#static-resources) + - [Templated Resources](#templated-resources) + - [Multiple Parameters](#multiple-parameters) + - [Parameter Type Casting](#parameter-type-casting) + - [Resource Results](#resource-results) + - [Resource Events](#resource-events) + - [JSON-RPC Integration](#json-rpc-integration-1) - [Prompts](#prompts) - [Creating Prompts](#creating-prompts) - [Prompt Results](#prompt-results) - [Prompt Events](#prompt-events) - - [JSON-RPC Integration](#json-rpc-integration-1) + - [JSON-RPC Integration](#json-rpc-integration-2) - [JSON-RPC Methods](#json-rpc-methods) - [Built-in Methods](#built-in-methods) - [Custom Methods](#custom-methods) @@ -273,6 +282,228 @@ This ensures that your tool handlers always receive properly validated and sanit - **`tools/list`**: Lists all available tools and their definitions. - **`tools/call`**: Executes a tool by name, with the provided input data. +## Resources + +Resources are data sources that can be accessed by clients via their URI. +They can represent files, database records, or any other data that can be identified by a URI. + +### Creating Resources + +1. Create a new class that will handle your resource logic +2. Use the `#[AsResource]` attribute to register your resource +3. Define the URI pattern for your resource (static or templated) +4. Implement the `__invoke` method to handle the resource logic and return a `ResourceResult` + +_As Resource classes are services within the Symfony application, any dependency can be injected in it, using the constructor, like any other service._ + +#### Static Resources + +Static resources have a fixed URI that doesn't change. They are useful for resources that don't require parameters. + +Example: +```php +entityManager->find(User::class, $id); + if ($user === null) { + throw new \RuntimeException('User not found'); + } + + $stringifiedUserData = $this->serializer->serialize($user, 'json'); + + return new ResourceResult([ + new TextResource( + uri: 'database://user/' . $id, + mimeType: 'application/json', + text: $stringifiedUserData, + ), + ]); + } +} +``` + +In this example: +- The URI template `database://user/{id}` defines a parameter named `id` +- When a client requests `database://user/123`, the parameter `123` is extracted +- The `__invoke` method receives `123` as an `int` parameter (automatic type casting is performed) +- The resource returns user data for ID 123 + +#### Multiple Parameters + +You can define multiple parameters in a single URI template: + +```php +#[AsResource( + uri: 'api://users/{userId}/posts/{postId}', + name: 'user_post', + title: 'Get User Post', + description: 'Retrieves a specific post by a user.', + mimeType: 'application/json', +)] +class UserPostResource +{ + public function __invoke(int $userId, int $postId): ResourceResult + { + // Your logic here... + $post = $this->postRepository->findByUserAndPost($userId, $postId); + + return new ResourceResult([ + new TextResource( + uri: "api://users/{$userId}/posts/{$postId}", + mimeType: 'application/json', + text: json_encode($post), + ), + ]); + } +} +``` + +#### Parameter Type Casting + +The bundle automatically casts URI parameters to the appropriate types based on the method signature: + +- `int` parameters are cast to integers +- `float` parameters are cast to floats +- `bool` parameters are cast to booleans +- `string` parameters remain as strings +- `array` parameters are JSON-decoded into arrays using `json_decode` if they are JSON strings + +### Resource Results + +The [MCP specification](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#reading-resources) states that resource results should consist of an array of resource objects. +The bundle provides several result types that can be combined in a single `ResourceResult` object: + +- `TextResource`: For text-based content (JSON, XML, plain text, etc.) +- `BinaryResource`: For binary content (images, audio, video, files, etc.), should be base-64 encoded + +All resource results must be wrapped in a `ResourceResult` object, which can contain multiple resources. + +Example: +```php + 'success']); + $imageData = base64_encode(file_get_contents('image.jpg')); + + return new ResourceResult([ + new TextResource( + uri: 'api://data/status', + mimeType: 'application/json', + text: $jsonData, + ), + new BinaryResource( + uri: 'file://image.jpg', + mimeType: 'image/jpeg', + blob: $imageData, + ), + ]); + } +} +``` + +The `ResourceResult` class provides the following features: +- Combine multiple resources of different types +- Automatic serialization to the correct format +- Type safety for all resources + +### Resource Events + +The bundle provides several events that you can listen to: + +- `ResourceReadEvent`: Dispatched before a resource is read, contains the URI +- `ResourceReadResultEvent`: Dispatched after a resource has been read, contains the URI and results + +Example of event listener: +```php +uri, '{') && str_contains($this->uri, '}'); + } +} diff --git a/src/Command/DebugResourceCommand.php b/src/Command/DebugResourceCommand.php new file mode 100644 index 0000000..400e1df --- /dev/null +++ b/src/Command/DebugResourceCommand.php @@ -0,0 +1,109 @@ +addArgument('resource', InputArgument::OPTIONAL, 'Resource name'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $resourceName = $input->getArgument('resource'); + + if ($resourceName !== null) { + return $this->displaySingleResourceInformation($io, $resourceName); + } + + return $this->displayAllResourcesInformation($io); + } + + private function displaySingleResourceInformation(SymfonyStyle $io, string $resourceName): int + { + $resourceDefinition = $this->resourceRegistry->getResourceDefinition($resourceName); + + if ($resourceDefinition === null) { + $io->error(\sprintf('Resource "%s" not found.', $resourceName)); + + return self::FAILURE; + } + + $io->table( + ['Name', 'URI', 'Title', 'Description', 'MimeType', 'Size'], + [ + [ + $resourceDefinition->name, + $resourceDefinition->uri, + $resourceDefinition->title, + $resourceDefinition->description, + $resourceDefinition->mimeType, + $resourceDefinition->size ?? null, + ], + ], + ); + + return self::SUCCESS; + } + + private function displayAllResourcesInformation(SymfonyStyle $io): int + { + $io->title('MCP Resources Debug Information'); + + $resourceDefinitions = $this->resourceRegistry->getResourceDefinitions(); + + if (empty($resourceDefinitions) === true) { + $io->warning('No resources found.'); + + return self::SUCCESS; + } + + $io->table( + ['Name', 'URI', 'Title', 'Description', 'MimeType', 'Size'], + array_map(static function (AbstractResourceDefinition $resourceDefinition) { + return [ + $resourceDefinition->name, + $resourceDefinition->uri, + $resourceDefinition->title, + $resourceDefinition->description, + $resourceDefinition->mimeType, + $resourceDefinition->size ?? null, + ]; + }, $resourceDefinitions), + ); + + return self::SUCCESS; + } +} diff --git a/src/Contract/Resource/ResourceResultInterface.php b/src/Contract/Resource/ResourceResultInterface.php new file mode 100644 index 0000000..01636a7 --- /dev/null +++ b/src/Contract/Resource/ResourceResultInterface.php @@ -0,0 +1,10 @@ +getDefinition(ResourceRegistry::class); + + foreach ($container->getDefinitions() as $definition) { + $class = $definition->getClass(); + + try { + if ($class === null || class_exists($class) === false) { + continue; + } + } catch (\Throwable) { + continue; + } + + $refClass = new \ReflectionClass($class); + + $attributes = $refClass->getAttributes(AsResource::class); + if (\count($attributes) === 0) { + continue; + } + + if (\count($attributes) > 1) { + throw new \LogicException(\sprintf( + 'Multiple AsResource attributes found on class "%s". Only one is allowed.', + $class, + )); + } + + if ($refClass->hasMethod('__invoke') === false) { + throw new \LogicException(\sprintf( + 'Class "%s" must implement the __invoke method to be used as a resource.', + $class, + )); + } + + /** @var AsResource|null $attr */ + $attr = $attributes[0]->newInstance(); + if ($attr === null) { + throw new \LogicException(\sprintf( + 'Failed to instantiate AsResource attribute for class "%s".', + $class, + )); + } + + $returnType = $refClass->getMethod('__invoke')->getReturnType(); + if ($returnType?->getName() !== ResourceResult::class) { // @phpstan-ignore method.notFound + throw new \LogicException(\sprintf( + 'The __invoke method in class "%s" must return an instance of %s.', + $class, + ResourceResult::class, + )); + } + + $definition + ->addTag(name: 'mcp_server.resource', attributes: [ + 'uri' => $attr->uri, + ]); + + if ($attr->isTemplatedResource() === true) { + $promptRegistry->addMethodCall(method: 'addTemplateResourceDefinition', arguments: [ + $attr->name, + $attr->uri, + $attr->title, + $attr->description, + $attr->mimeType, + $attr->size, + ]); + } else { + $promptRegistry->addMethodCall(method: 'addDirectResourceDefinition', arguments: [ + $attr->name, + $attr->uri, + $attr->title, + $attr->description, + $attr->mimeType, + ]); + } + } + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 1c96f0e..2aea3d6 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -14,6 +14,7 @@ class Configuration implements ConfigurationInterface { private const string DEFAULT_NAME = 'MCP Server'; + private const string DEFAULT_TITLE = self::DEFAULT_NAME; private const string DEFAULT_VERSION = '1.0.0'; public function getConfigTreeBuilder(): TreeBuilder @@ -29,6 +30,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->addDefaultsIfNotSet() ->children() ->scalarNode('name')->defaultValue(self::DEFAULT_NAME)->end() + ->scalarNode('title')->defaultValue(self::DEFAULT_TITLE)->end() ->scalarNode('version')->defaultValue(self::DEFAULT_VERSION)->end() ->end() ->end() diff --git a/src/DependencyInjection/McpServerBundleExtension.php b/src/DependencyInjection/McpServerBundleExtension.php index 2d0400d..9be8e37 100644 --- a/src/DependencyInjection/McpServerBundleExtension.php +++ b/src/DependencyInjection/McpServerBundleExtension.php @@ -32,7 +32,7 @@ public function load(array $configs, ContainerBuilder $container): void $configuration = $this->getConfiguration($configs, $container); /** - * @var array{server: array{name: string, version: string}} $config + * @var array{server: array{name: string, title: string, version: string}} $config */ $config = $this->processConfiguration($configuration, $configs); @@ -41,6 +41,7 @@ public function load(array $configs, ContainerBuilder $container): void $container->getDefinition(InitializeMethodHandler::class) ->setArgument('$serverName', $config['server']['name']) + ->setArgument('$serverTitle', $config['server']['title']) ->setArgument('$serverVersion', $config['server']['version']); } } diff --git a/src/Event/Resource/AbstractResourceEvent.php b/src/Event/Resource/AbstractResourceEvent.php new file mode 100644 index 0000000..694883c --- /dev/null +++ b/src/Event/Resource/AbstractResourceEvent.php @@ -0,0 +1,18 @@ +uri; + } +} diff --git a/src/Event/Resource/ResourceReadEvent.php b/src/Event/Resource/ResourceReadEvent.php new file mode 100644 index 0000000..0b51692 --- /dev/null +++ b/src/Event/Resource/ResourceReadEvent.php @@ -0,0 +1,9 @@ +resourceResult; + } +} diff --git a/src/IO/Resource/BinaryResource.php b/src/IO/Resource/BinaryResource.php new file mode 100644 index 0000000..0f2dceb --- /dev/null +++ b/src/IO/Resource/BinaryResource.php @@ -0,0 +1,30 @@ + $this->uri, + 'name' => $this->name, + 'title' => $this->title, + 'mimeType' => $this->mimeType, + 'blob' => $this->blob, + ]; + } +} diff --git a/src/IO/Resource/ResourceResult.php b/src/IO/Resource/ResourceResult.php new file mode 100644 index 0000000..43080ca --- /dev/null +++ b/src/IO/Resource/ResourceResult.php @@ -0,0 +1,40 @@ +resources = $resources; + } + + /** + * @return array[] + */ + public function toArray(): array + { + return array_map(function (ResourceResultInterface $resource) { + return $resource->toArray(); + }, $this->resources); + } +} diff --git a/src/IO/Resource/TextResource.php b/src/IO/Resource/TextResource.php new file mode 100644 index 0000000..fbabeb1 --- /dev/null +++ b/src/IO/Resource/TextResource.php @@ -0,0 +1,30 @@ + $this->uri, + 'name' => $this->name, + 'title' => $this->title, + 'mimeType' => $this->mimeType, + 'text' => $this->text, + ]; + } +} diff --git a/src/McpServerBundle.php b/src/McpServerBundle.php index 947a852..592526f 100644 --- a/src/McpServerBundle.php +++ b/src/McpServerBundle.php @@ -6,6 +6,7 @@ use Ecourty\McpServerBundle\DependencyInjection\CompilerPass\MethodHandlerPass; use Ecourty\McpServerBundle\DependencyInjection\CompilerPass\PromptPass; +use Ecourty\McpServerBundle\DependencyInjection\CompilerPass\ResourcePass; use Ecourty\McpServerBundle\DependencyInjection\CompilerPass\ToolPass; use Ecourty\McpServerBundle\DependencyInjection\McpServerBundleExtension; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -31,5 +32,6 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new MethodHandlerPass()); $container->addCompilerPass(new ToolPass()); $container->addCompilerPass(new PromptPass()); + $container->addCompilerPass(new ResourcePass()); } } diff --git a/src/MethodHandler/InitializeMethodHandler.php b/src/MethodHandler/InitializeMethodHandler.php index 8909c02..c1b8a51 100644 --- a/src/MethodHandler/InitializeMethodHandler.php +++ b/src/MethodHandler/InitializeMethodHandler.php @@ -19,10 +19,11 @@ #[AsMethodHandler(methodName: 'initialize')] class InitializeMethodHandler implements MethodHandlerInterface { - public const string PROTOCOL_VERSION = '2025-03-26'; + public const string PROTOCOL_VERSION = '2025-06-18'; public function __construct( private readonly string $serverName, + private readonly string $serverTitle, private readonly string $serverVersion, private readonly ?EventDispatcherInterface $eventDispatcher = null, ) { @@ -41,9 +42,14 @@ public function handle(JsonRpcRequest $request): array 'tools' => [ 'listChanged' => false, ], + 'resources' => [ + 'subscribe' => false, + 'listChanged' => false, + ], ], 'serverInfo' => [ 'name' => $this->serverName, + 'title' => $this->serverTitle, 'version' => $this->serverVersion, ], ]; diff --git a/src/MethodHandler/ResourcesListMethodHandler.php b/src/MethodHandler/ResourcesListMethodHandler.php new file mode 100644 index 0000000..ad93116 --- /dev/null +++ b/src/MethodHandler/ResourcesListMethodHandler.php @@ -0,0 +1,43 @@ +resourceRegistry->getResourceDefinitions(); + /** @var DirectResourceDefinition[] $directResourcesDefinitions */ + $directResourcesDefinitions = array_filter($resourceDefinitions, function (AbstractResourceDefinition $definition) { + return $definition instanceof DirectResourceDefinition; + }); + + return [ + 'resources' => array_values($directResourcesDefinitions), + ]; + } +} diff --git a/src/MethodHandler/ResourcesReadMethodHandler.php b/src/MethodHandler/ResourcesReadMethodHandler.php new file mode 100644 index 0000000..6b4e4fc --- /dev/null +++ b/src/MethodHandler/ResourcesReadMethodHandler.php @@ -0,0 +1,51 @@ +params['uri'] ?? null; + + if ($uri === null) { + throw new \InvalidArgumentException('Resource URI is required.'); + } + + $this->eventDispatcher?->dispatch(new ResourceReadEvent($uri)); + + $result = $this->resourceExecutor->execute($uri); + + $this->eventDispatcher?->dispatch(new ResourceReadResultEvent($uri, $result)); + + return [ + 'contents' => $result->toArray(), + ]; + } +} diff --git a/src/MethodHandler/ResourcesTemplatesListMethodHandler.php b/src/MethodHandler/ResourcesTemplatesListMethodHandler.php new file mode 100644 index 0000000..713858a --- /dev/null +++ b/src/MethodHandler/ResourcesTemplatesListMethodHandler.php @@ -0,0 +1,43 @@ +resourceRegistry->getResourceDefinitions(); + /** @var TemplateResourceDefinition[] $templateDefinitions */ + $templateDefinitions = array_filter($resourceDefinitions, function (AbstractResourceDefinition $definition) { + return $definition instanceof TemplateResourceDefinition; + }); + + return [ + 'resourceTemplates' => array_values($templateDefinitions), + ]; + } +} diff --git a/src/Normalizer/TemplateResourceDefinitionNormalizer.php b/src/Normalizer/TemplateResourceDefinitionNormalizer.php new file mode 100644 index 0000000..308cdb6 --- /dev/null +++ b/src/Normalizer/TemplateResourceDefinitionNormalizer.php @@ -0,0 +1,48 @@ + $context + * + * @return array + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + if ($data instanceof TemplateResourceDefinition === false) { + throw new \InvalidArgumentException('Expected instance of TemplateResourceDefinition.'); + } + + $normalizedData = $this->normalizer->normalize($data); + unset($normalizedData['uri']); + $normalizedData['uriTemplate'] = $data->uri; + + return $normalizedData; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof TemplateResourceDefinition; + } + + public function getSupportedTypes(?string $format): array + { + return [ + TemplateResourceDefinition::class => true, + ]; + } +} diff --git a/src/Prompt/Argument.php b/src/Prompt/Argument.php index 5df4c2a..b2d38ec 100644 --- a/src/Prompt/Argument.php +++ b/src/Prompt/Argument.php @@ -4,6 +4,8 @@ namespace Ecourty\McpServerBundle\Prompt; +use Symfony\Component\Serializer\Attribute\Ignore; + /** * Represents an argument within a prompt. */ @@ -17,6 +19,7 @@ public function __construct( public readonly string $name, public readonly string $description, public readonly bool $required = true, + #[Ignore] public readonly bool $allowUnsafe = false, ) { } diff --git a/src/Resource/AbstractResourceDefinition.php b/src/Resource/AbstractResourceDefinition.php new file mode 100644 index 0000000..0ecb95a --- /dev/null +++ b/src/Resource/AbstractResourceDefinition.php @@ -0,0 +1,17 @@ +findDefinition($uri); + $resource = $this->getResourceInstance($definition->uri); + $invokeMethod = $this->getInvokeMethod($resource, $definition->uri); + + $rawArgs = $this->matcher->match($definition->uri, $uri); + + $orderedArgs = $this->resolveArguments($invokeMethod, $rawArgs, $definition->uri); + + $result = $invokeMethod->invoke($resource, ...$orderedArgs); + + if ($result instanceof ResourceResult === false) { + throw new \RuntimeException(\sprintf( + 'Resource "%s" did not return an instance of ResourceResult.', + $definition->uri, + )); + } + + return $result; + } + + private function findDefinition(string $uri): AbstractResourceDefinition + { + $resourceDefinitions = $this->registry->getResourceDefinitions(); + + foreach ($resourceDefinitions as $resourceDefinition) { + $matchResult = $this->matcher->match($resourceDefinition->uri, $uri); + + if (empty($matchResult) === true) { + continue; // No match, skip to next definition + } + + return $resourceDefinition; + } + + throw new \InvalidArgumentException(\sprintf( + 'No resource matched URI "%s".', + $uri, + )); + } + + private function getResourceInstance(string $resourceUri): object + { + $resource = $this->registry->getResource($resourceUri); + + if ($resource === null) { + throw new \RuntimeException(\sprintf( + 'Resource "%s" not found.', + $resourceUri, + )); + } + + return $resource; + } + + private function getInvokeMethod(object $resource, string $resourceUri): \ReflectionMethod + { + $reflection = new \ReflectionClass($resource); + + if ($reflection->hasMethod('__invoke') === false) { + throw new \RuntimeException(\sprintf( + 'Resource "%s" does not have an __invoke method.', + $resourceUri, + )); + } + + return $reflection->getMethod('__invoke'); + } + + /** + * @param array $args + * + * @return array + */ + private function resolveArguments( + \ReflectionMethod $method, + array $args, + string $resourceUri, + ): array { + $ordered = []; + + foreach ($method->getParameters() as $param) { + $name = $param->getName(); + + if (\array_key_exists($name, $args) === true) { + $ordered[] = $this->castParameter($param, $args[$name]); + } elseif ($param->isDefaultValueAvailable() === true) { + $ordered[] = $param->getDefaultValue(); + } else { + throw new \InvalidArgumentException(\sprintf( + 'Missing required parameter "%s" for resource "%s".', + $name, + $resourceUri, + )); + } + } + + return $ordered; + } + + /** + * Cast a raw URI value to the parameter's declared type + */ + private function castParameter(\ReflectionParameter $param, mixed $value): mixed + { + $type = $param->getType(); + + if ($type instanceof \ReflectionNamedType && $type->isBuiltin()) { + // Handle simple built-in types + switch ($type->getName()) { + case 'int': + return (int) $value; + + case 'float': + return (float) $value; + + case 'bool': + return filter_var( + (string) $value, + \FILTER_VALIDATE_BOOL, + \FILTER_NULL_ON_FAILURE, + ); + + case 'string': + return (string) $value; + + case 'array': + if (\is_string($value)) { + return json_decode($value, true, flags: \JSON_THROW_ON_ERROR); + } + + return (array) $value; + + default: + // Fallback for other built-ins + settype($value, $type->getName()); + + return $value; + } + } + + return $value; + } +} diff --git a/src/Service/ResourceRegistry.php b/src/Service/ResourceRegistry.php new file mode 100644 index 0000000..198376b --- /dev/null +++ b/src/Service/ResourceRegistry.php @@ -0,0 +1,97 @@ + */ + private array $resourceDefinitions = []; + + public function __construct(// @phpstan-ignore missingType.generics + #[AutowireLocator(services: 'mcp_server.resource', indexAttribute: 'uri')] + private readonly ServiceLocator $resourceLocator, + ) { + } + + public function getResource(string $uri): ?object + { + if ($this->resourceLocator->has($uri) === false) { + return null; + } + + return $this->resourceLocator->get($uri); + } + + public function getResourceDefinition(string $name): ?AbstractResourceDefinition + { + return $this->resourceDefinitions[$name] ?? null; + } + + /** + * @return AbstractResourceDefinition[] + */ + public function getResourceDefinitions(): array + { + return array_values($this->resourceDefinitions); + } + + /** + * @internal + */ + public function addDirectResourceDefinition( + string $name, + string $uri, + ?string $title = null, + ?string $description = null, + ?string $mimeType = null, + ?string $size = null, + ): void { + $resourceDefinition = new DirectResourceDefinition( + uri: $uri, + name: $name, + title: $title, + description: $description, + mimeType: $mimeType, + size: $size, + ); + + $this->resourceDefinitions[$uri] = $resourceDefinition; + } + + /** + * @internal + */ + public function addTemplateResourceDefinition( + string $name, + string $uri, + ?string $title = null, + ?string $description = null, + ?string $mimeType = null, + ): void { + $resourceDefinition = new TemplateResourceDefinition( + uri: $uri, + name: $name, + title: $title, + description: $description, + mimeType: $mimeType, + ); + + $this->resourceDefinitions[$uri] = $resourceDefinition; + } +} diff --git a/src/Service/ResourceUriMatcher.php b/src/Service/ResourceUriMatcher.php new file mode 100644 index 0000000..138aa8e --- /dev/null +++ b/src/Service/ResourceUriMatcher.php @@ -0,0 +1,36 @@ + #^database://user/(?[^/]+)$# + $regex = preg_replace_callback('#\{(\w+)\}#', function ($matches) { + return '(?<' . $matches[1] . '>[^/]+)'; + }, $uriPattern); + + $regex = '#^' . $regex . '$#'; + + if (preg_match($regex, $uri, $matches) !== false) { + if (empty($matches)) { + return []; // No matches found + } + + if (\count($matches) === 1 && $matches[0] === $uri) { // Exact match without captures + return [$uri => $uri]; + } + + // Only return named captures as an associative array + return array_filter($matches, 'is_string', \ARRAY_FILTER_USE_KEY); + } + + return []; + } +} diff --git a/tests/Controller/EntrypointControllerTest.php b/tests/Controller/EntrypointControllerTest.php index fd0022c..0f5df89 100644 --- a/tests/Controller/EntrypointControllerTest.php +++ b/tests/Controller/EntrypointControllerTest.php @@ -67,6 +67,9 @@ public function testInitialize(): void $this->assertArrayHasKey('name', $serverInfo); $this->assertSame('My Test MCP Server', $serverInfo['name']); + $this->assertArrayHasKey('title', $serverInfo); + $this->assertSame('My Test MCP Server Title', $serverInfo['title']); + $this->assertArrayHasKey('version', $serverInfo); $this->assertSame('1.0.1', $serverInfo['version']); } @@ -487,4 +490,215 @@ public function testPromptGetWithUnsafeParameterAllowed(): void $resultContent = $responseContent['result']; $this->assertStringContainsString($unsafeContent, $resultContent['messages'][0]['content']['text']); } + + public function testDirectResourceList(): void + { + $response = $this->request( + method: Request::METHOD_POST, + url: '/mcp', + body: [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/list', + ], + ); + + $responseContent = json_decode((string) $response->getContent(), true); + $this->assertNotFalse($responseContent); + + $this->assertSame([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'resources' => [ + [ + 'uri' => 'file://random', + 'name' => 'random_file', + 'title' => 'Get a random file', + 'description' => 'This resource returns the content of a random file.', + 'mimeType' => 'text/plain', + ], + [ + 'uri' => 'file://robots.txt', + 'name' => 'robots_txt', + 'title' => 'Get the Robots.txt file', + 'description' => 'This resource returns the content of the robots.txt file.', + 'mimeType' => 'text/plain', + ], + ], + ], + ], $responseContent); + } + + public function testTemplateResourceList(): void + { + $response = $this->request( + method: Request::METHOD_POST, + url: '/mcp', + body: [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/templates/list', + ], + ); + + $responseContent = json_decode((string) $response->getContent(), true); + $this->assertNotFalse($responseContent); + + $this->assertSame([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'resourceTemplates' => [ + [ + 'name' => 'order_data', + 'title' => 'Get Order Data', + 'description' => 'Gathers the data of an order by their ID.', + 'mimeType' => 'application/json', + 'uriTemplate' => 'database://order/{id}', + ], + [ + 'name' => 'user_data', + 'title' => 'Get User Data', + 'description' => 'Gathers the data of a user by their ID.', + 'mimeType' => 'application/json', + 'uriTemplate' => 'database://user/{id}', + ], + ], + ], + ], $responseContent); + } + + public function testCallNotFoundResource(): void + { + $response = $this->request( + method: Request::METHOD_POST, + url: '/mcp', + body: [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/read', + 'params' => [ + 'uri' => 'database://non_existing_resource/123', + ], + ], + ); + + $responseContent = json_decode((string) $response->getContent(), true); + $this->assertNotFalse($responseContent); + + $this->assertSame([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'error' => [ + 'code' => McpErrorCode::INTERNAL_ERROR->value, + 'message' => McpErrorCode::INTERNAL_ERROR->getMessage(), + ], + ], $responseContent); + } + + public function testCallDirectResource(): void + { + $response = $this->request( + method: Request::METHOD_POST, + url: '/mcp', + body: [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/read', + 'params' => [ + 'uri' => 'file://robots.txt', + ], + ], + ); + + $responseContent = json_decode((string) $response->getContent(), true); + $this->assertNotFalse($responseContent); + + $this->assertSame([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'contents' => [ + [ + 'uri' => 'file://robots.txt', + 'name' => 'robots.txt', + 'title' => 'The robots.txt file', + 'mimeType' => 'text/plain', + 'text' => 'Disallow: /', + ], + ], + ], + ], $responseContent); + } + + #[DataProvider('provideTestDataForTemplateResourceCall')] + public function testCallTemplateResource(string $uri, array $expectedResult): void + { + $response = $this->request( + method: Request::METHOD_POST, + url: '/mcp', + body: [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/read', + 'params' => [ + 'uri' => $uri, + ], + ], + ); + + $responseContent = json_decode((string) $response->getContent(), true); + $this->assertNotFalse($responseContent); + + $this->assertSame($expectedResult, $responseContent['result']); + } + + public static function provideTestDataForTemplateResourceCall(): \Generator + { + yield [ + 'uri' => 'database://order/1', + 'expectedResult' => [ + 'contents' => [ + [ + 'uri' => 'database://order/1', + 'name' => 'order_1', + 'title' => 'Order data', + 'mimeType' => 'application/json', + 'text' => '{"id":1,"reference":"C4CA4238A0B923820DCC509A6F75849B","status":"pending"}', + ], + ], + ], + ]; + + yield [ + 'uri' => 'database://user/2', + 'expectedResult' => [ + 'contents' => [ + [ + 'uri' => 'database://user/2', + 'name' => 'user_2', + 'title' => 'User data', + 'mimeType' => 'application/json', + 'text' => '{"id":2,"name":"User 2","email":"user2@example.com"}', + ], + ], + ], + ]; + + yield [ + 'uri' => 'database://user/999', + 'expectedResult' => [ + 'contents' => [ + [ + 'uri' => 'database://user/999', + 'name' => 'user_999', + 'title' => 'User data', + 'mimeType' => 'application/json', + 'text' => '{"id":999,"name":"User 999","email":"user999@example.com"}', + ], + ], + ], + ]; + } } diff --git a/tests/MethodHandler/InitializeMethodHandlerTest.php b/tests/MethodHandler/InitializeMethodHandlerTest.php index b51be10..51d00de 100644 --- a/tests/MethodHandler/InitializeMethodHandlerTest.php +++ b/tests/MethodHandler/InitializeMethodHandlerTest.php @@ -17,6 +17,7 @@ class InitializeMethodHandlerTest extends TestCase { private const string SERVER_NAME = 'Test Server'; + private const string SERVER_TITLE = 'Test Server Title'; private const string SERVER_VERSION = '1.1.0'; private MockObject&EventDispatcherInterface $eventDispatcher; @@ -29,6 +30,7 @@ protected function setUp(): void $this->initializeMethodHandler = new InitializeMethodHandler( serverName: self::SERVER_NAME, + serverTitle: self::SERVER_TITLE, serverVersion: self::SERVER_VERSION, eventDispatcher: $this->eventDispatcher, ); @@ -50,6 +52,7 @@ public function testHandleFiresEvent(): void $this->assertSame($result['protocolVersion'], InitializeMethodHandler::PROTOCOL_VERSION); $this->assertSame($result['serverInfo']['name'], self::SERVER_NAME); + $this->assertSame($result['serverInfo']['title'], self::SERVER_TITLE); $this->assertSame($result['serverInfo']['version'], self::SERVER_VERSION); } } diff --git a/tests/MethodHandler/ResourcesReadMethodHandlerTest.php b/tests/MethodHandler/ResourcesReadMethodHandlerTest.php new file mode 100644 index 0000000..cc61ac7 --- /dev/null +++ b/tests/MethodHandler/ResourcesReadMethodHandlerTest.php @@ -0,0 +1,92 @@ +resourceExecutor = $this->createMock(ResourceExecutor::class); + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); + + $this->handler = new ResourcesReadMethodHandler( + resourceExecutor: $this->resourceExecutor, + eventDispatcher: $this->eventDispatcher, + ); + } + + public function testFireEvents(): void + { + $events = [ + ResourceReadEvent::class, + ResourceReadResultEvent::class, + ]; + + $matcher = $this->exactly(\count($events)); + $this->eventDispatcher + ->expects($matcher) + ->method('dispatch') + ->willReturnCallback(function (AbstractResourceEvent $event) use ($matcher, $events) { + $this->assertInstanceOf($events[$matcher->numberOfInvocations() - 1], $event); + }); + + $resourceResult = new ResourceResult([ + new BinaryResource( + uri: 'file://random', + name: 'file.txt', + title: 'A random file from the filesystem', + mimeType: 'text/plain', + blob: 'blob_data', + ), + ]); + $this->resourceExecutor + ->expects($this->once()) + ->method('execute') + ->willReturn($resourceResult); + + $uri = 'file://random'; + + $request = new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: [ + 'uri' => $uri, + ], + ); + + $result = $this->handler->handle($request); + $this->assertSame([ + 'contents' => [ + [ + 'uri' => 'file://random', + 'name' => 'file.txt', + 'title' => 'A random file from the filesystem', + 'mimeType' => 'text/plain', + 'blob' => 'blob_data', + ], + ], + ], $result); + } +} diff --git a/tests/Normalizer/TemplateResourceDefinitionNormalizerTest.php b/tests/Normalizer/TemplateResourceDefinitionNormalizerTest.php new file mode 100644 index 0000000..58f3b6b --- /dev/null +++ b/tests/Normalizer/TemplateResourceDefinitionNormalizerTest.php @@ -0,0 +1,39 @@ +get(TemplateResourceDefinitionNormalizer::class); + + $this->normalizer = $normalizer; + } + + public function testNormalize(): void + { + $resourceDefinition = new TemplateResourceDefinition('uri', 'name', 'title', 'description', 'text/plain'); + $normalized = $this->normalizer->normalize($resourceDefinition); + + $this->assertSame([ + 'name' => 'name', + 'title' => 'title', + 'description' => 'description', + 'mimeType' => 'text/plain', + 'uriTemplate' => 'uri', + ], $normalized); + } +} diff --git a/tests/Service/ResourceUriMatcherTest.php b/tests/Service/ResourceUriMatcherTest.php new file mode 100644 index 0000000..e577a1f --- /dev/null +++ b/tests/Service/ResourceUriMatcherTest.php @@ -0,0 +1,66 @@ +matcher = new ResourceUriMatcher(); + } + + #[DataProvider('provideTestDataForUriMatchTest')] + public function testMatch(string $uriPattern, string $uri, ?array $expectedMatches): void + { + $matches = $this->matcher->match($uriPattern, $uri); + + $this->assertSame($expectedMatches, $matches); + } + + public static function provideTestDataForUriMatchTest(): \Generator + { + yield [ + 'uriPattern' => 'database://user/{id}', + 'uri' => 'database://user/123', + 'expectedMatches' => ['id' => '123'], + ]; + + yield [ + 'uriPattern' => 'database://user/{id}/profile', + 'uri' => 'database://user/456/profile', + 'expectedMatches' => ['id' => '456'], + ]; + + yield [ + 'uriPattern' => 'database://user/{id}/posts/{postId}', + 'uri' => 'database://user/789/posts/1011', + 'expectedMatches' => ['id' => '789', 'postId' => '1011'], + ]; + + yield [ + 'uriPattern' => 'file://robots.txt', + 'uri' => 'file://robots.txt', + 'expectedMatches' => ['file://robots.txt' => 'file://robots.txt'], + ]; + + yield [ + 'uriPattern' => 'file://{filename}', + 'uri' => 'file://example.txt/issou', + 'expectedMatches' => [], + ]; + + yield [ + 'uriPattern' => 'file://file', + 'uri' => 'file://', + 'expectedMatches' => [], + ]; + } +} diff --git a/tests/TestApp/config/packages/mcp_server.yaml b/tests/TestApp/config/packages/mcp_server.yaml index 0fe4884..dec6830 100644 --- a/tests/TestApp/config/packages/mcp_server.yaml +++ b/tests/TestApp/config/packages/mcp_server.yaml @@ -1,4 +1,5 @@ mcp_server: server: name: My Test MCP Server + title: My Test MCP Server Title version: 1.0.1 diff --git a/tests/TestApp/src/Resource/OrderResource.php b/tests/TestApp/src/Resource/OrderResource.php new file mode 100644 index 0000000..43458c9 --- /dev/null +++ b/tests/TestApp/src/Resource/OrderResource.php @@ -0,0 +1,41 @@ + $id, + 'reference' => mb_strtoupper(md5((string) $id)), + 'status' => 'pending', + ]; + + $stringUserData = (string) json_encode($userData); + + return new ResourceResult([ + new TextResource( + uri: 'database://order/' . $id, + name: 'order_' . $id, + title: 'Order data', + mimeType: 'application/json', + text: $stringUserData, + ), + ]); + } +} diff --git a/tests/TestApp/src/Resource/RandomFileResource.php b/tests/TestApp/src/Resource/RandomFileResource.php new file mode 100644 index 0000000..86edaa3 --- /dev/null +++ b/tests/TestApp/src/Resource/RandomFileResource.php @@ -0,0 +1,32 @@ + $id, + 'name' => 'User ' . $id, + 'email' => 'user' . $id . '@example.com', + ]; + + $stringUserData = (string) json_encode($userData); + + return new ResourceResult([ + new TextResource( + uri: 'database://user/' . $id, + name: 'user_' . $id, + title: 'User data', + mimeType: 'application/json', + text: $stringUserData, + ), + ]); + } +} diff --git a/tests/TestApp/src/Resources/robots.txt b/tests/TestApp/src/Resources/robots.txt new file mode 100644 index 0000000..c195fa2 --- /dev/null +++ b/tests/TestApp/src/Resources/robots.txt @@ -0,0 +1 @@ +Disallow: / \ No newline at end of file