diff --git a/CHANGELOG.md b/CHANGELOG.md index febbef45..9e6ef16f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.6.0 ----- +* Add `Builder::add(Tool|ResourceDefinition|ResourceTemplate|Prompt $definition, ElementHandlerInterface $handler)` for explicit registration of elements whose schema is only known at runtime. +* Add handler interfaces `ToolHandlerInterface`, `ResourceHandlerInterface`, `ResourceTemplateHandlerInterface`, `PromptHandlerInterface`, and the `ElementHandlerInterface` marker. +* Add `ExplicitElementLoader` that wraps explicit-handler instances in closures before registration. +* [BC Break] Renamed `Mcp\Schema\Resource` to `Mcp\Schema\ResourceDefinition`. No alias. +* [BC Break] Renamed `Mcp\Capability\Registry\Loader\ArrayLoader` to `Mcp\Capability\Registry\Loader\ReflectedElementLoader`. * [BC Break] Bump default protocol version to `2025-11-25` * Allow overriding the default name pattern for Discovery * Add configurable session garbage collection (`gcProbability`/`gcDivisor`) diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index b1a045f5..c39743c7 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -32,7 +32,8 @@ Each capability can be registered using two methods: 1. **Attribute-Based Discovery**: Use PHP attributes (`#[McpTool]`, `#[McpResource]`, etc.) on methods or classes. The server automatically discovers and registers them. -2. **Manual Registration**: Explicitly register capabilities using `ServerBuilder` methods (`addTool()`, `addResource()`, etc.). +2. **Manual Registration**: Explicitly register capabilities using `ServerBuilder` methods (`addTool()`, + `addResource()`, etc.). **Priority**: Manual registrations **always override** discovered elements with the same identifier: - **Tools**: Same `name` @@ -40,7 +41,12 @@ Each capability can be registered using two methods: - **Resource Templates**: Same `uriTemplate` - **Prompts**: Same `name` -For manual registration details, see [Server Builder Manual Registration](server-builder.md#manual-capability-registration). +For manual registration details, see +[Server Builder Manual Registration](server-builder.md#manual-capability-registration). + +For runtime, config-driven elements whose shape is not known at compile time (e.g. bridging configuration entities into +MCP elements), see [Explicit element registration](server-builder.md#explicit-element-registration) in the Server +Builder docs. ## Tools @@ -71,14 +77,16 @@ class Calculator ### Parameters - **`name`** (optional): Tool identifier. Defaults to method name if not provided. -- **`description`** (optional): Tool description. Defaults to docblock summary if not provided, otherwise uses method name. +- **`description`** (optional): Tool description. Defaults to docblock summary if not provided, otherwise uses method + name. - **`annotations`** (optional): `ToolAnnotations` object for additional metadata. - **`icons`** (optional): Array of `Icon` objects for visual representation. - **`meta`** (optional): Arbitrary key-value pairs for custom metadata. **Priority for name/description**: Attribute parameters → DocBlock content → Method name -For tool parameter validation and JSON schema generation, see [Schema Generation and Validation](#schema-generation-and-validation). +For tool parameter validation and JSON schema generation, see +[Schema Generation and Validation](#schema-generation-and-validation). ### Tool Return Values @@ -159,7 +167,8 @@ public function getMultipleContent(): array Tool handlers can throw any exception, but the type determines how it's handled: -- **`ToolCallException`**: Converted to JSON-RPC response with `CallToolResult` where `isError: true`, allowing the LLM to see the error message and self-correct +- **`ToolCallException`**: Converted to JSON-RPC response with `CallToolResult` where `isError: true`, allowing the LLM + to see the error message and self-correct - **Any other exception**: Converted to JSON-RPC error response, but with a generic error message ```php @@ -186,7 +195,8 @@ public function processFile(string $filename): string } ``` -**Recommendation**: Use `ToolCallException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. +**Recommendation**: Use `ToolCallException` when you want to communicate specific errors to clients. Any other +exception will still be converted to JSON-RPC compliant errors but with generic error messages. ## Resources @@ -215,7 +225,8 @@ class ConfigProvider ### Parameters -- **`uri`** (required): Unique resource identifier. Must comply with [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). +- **`uri`** (required): Unique resource identifier. Must comply with + [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). - **`name`** (optional): Human-readable name. Defaults to method name if not provided. - **`description`** (optional): Resource description. Defaults to docblock summary if not provided. - **`mimeType`** (optional): MIME type of the resource content. @@ -229,7 +240,8 @@ class ConfigProvider ### Resource Return Values -Resource handlers can return various data types that are automatically formatted into appropriate MCP resource content types. +Resource handlers can return various data types that are automatically formatted into appropriate MCP resource content +types. #### Supported Return Types @@ -333,12 +345,14 @@ public function getFile(string $path): string } ``` -**Recommendation**: Use `ResourceReadException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. +**Recommendation**: Use `ResourceReadException` when you want to communicate specific errors to clients. Any other +exception will still be converted to JSON-RPC compliant errors but with generic error messages. ## Resource Templates Resource templates are **dynamic resources** that use parameterized URIs with variables. They follow all the same rules -as static resources (URI schemas, return values, MIME types, etc.) but accept variables using [RFC 6570 URI template syntax](https://datatracker.ietf.org/doc/html/rfc6570). +as static resources (URI schemas, return values, MIME types, etc.) but accept variables using +[RFC 6570 URI template syntax](https://datatracker.ietf.org/doc/html/rfc6570). ```php use Mcp\Capability\Attribute\McpResourceTemplate; @@ -395,7 +409,10 @@ class PromptGenerator { return [ ['role' => 'system', 'content' => 'You are an expert code reviewer.'], - ['role' => 'user', 'content' => "Review this {$language} code focusing on {$focus}:\n\n```{$language}\n{$code}\n```"] + [ + 'role' => 'user', + 'content' => "Review this {$language} code focusing on {$focus}:\n\n```{$language}\n{$code}\n```", + ], ]; } } @@ -470,7 +487,8 @@ public function explicitMessages(): array } ``` -The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. +The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP +prompt message format. #### Valid Message Roles @@ -503,7 +521,8 @@ public function generatePrompt(string $topic, string $style): array } ``` -**Recommendation**: Use `PromptGetException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. +**Recommendation**: Use `PromptGetException` when you want to communicate specific errors to clients. Any other +exception will still be converted to JSON-RPC compliant errors but with generic error messages. ## Logging @@ -534,7 +553,9 @@ public function processData(string $input, RequestContext $context): array { ## Completion Providers -Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have dynamic parameters that benefit from completion hints. +Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike +Tools and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts +have dynamic parameters that benefit from completion hints. ### Completion Provider Types diff --git a/docs/server-builder.md b/docs/server-builder.md index a48e96da..e8215bba 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -398,6 +398,62 @@ the handler's method name and docblock. For more details on MCP elements, handlers, and attribute-based discovery, see [MCP Elements](mcp-elements.md). +### Explicit element registration + +When an element's name, schema, or description is only known at runtime (for example, the Drupal `mcp` module exposing +configuration or database entities as MCP elements), pair an `Mcp\Schema\*` value object with one of the four handler +interfaces below and register it through `Builder::add()`. + +| Element kind | Handler interface | +|-------------------|-------------------------------------------------------| +| Tool | `Mcp\Server\Handler\ToolHandlerInterface` | +| Resource | `Mcp\Server\Handler\ResourceHandlerInterface` | +| Resource template | `Mcp\Server\Handler\ResourceTemplateHandlerInterface` | +| Prompt | `Mcp\Server\Handler\PromptHandlerInterface` | + +Each handler interface declares a single execution method. Tool and prompt handlers receive an arguments map and a +`ClientGateway`. Resource handlers receive the requested URI; resource template handlers additionally receive the parsed +template variables. + +```php +use Mcp\Schema\Tool; +use Mcp\Server; +use Mcp\Server\ClientGateway; +use Mcp\Server\Handler\ToolHandlerInterface; + +final class WeatherHandler implements ToolHandlerInterface +{ + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return ['temperature' => 21, 'unit' => 'C']; + } +} + +$tool = new Tool( + name: 'get_weather', + title: null, + inputSchema: [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ], + description: 'Returns the current weather for a city.', + annotations: null, +); + +$server = Server::builder() + ->add($tool, new WeatherHandler()) + ->build(); +``` + +`Builder::add()` validates the pairing at registration time. Pairing a `Tool` definition with, for example, a +`PromptHandlerInterface` raises `Mcp\Exception\InvalidArgumentException`. The schema value object validates its own +inputs (name pattern, schema shape, etc.), so passing an incomplete definition fails before `add()` returns. + +Use `add()` when the metadata cannot be inferred from a handler class via reflection. For statically-known elements, +prefer `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from the handler's signature and +docblock. + ## Service Dependencies ### Container @@ -619,4 +675,5 @@ $server = Server::builder() | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | | `addPrompt()` | handler, name?, description? | Register prompt | +| `add()` | definition, handler | Register an element from a schema VO + handler pair | | `build()` | - | Create the server instance | diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 9304c487..dab6590c 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -27,7 +27,7 @@ use Mcp\Exception\RuntimeException; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use Psr\Log\LoggerInterface; @@ -249,7 +249,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); $description = $instance->description ?? $this->docBlockParser->getDescription($docBlock) ?? null; - $resource = new Resource( + $resource = new ResourceDefinition( $instance->uri, $name, $instance->title, diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index c8fdca3e..23167b68 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -26,7 +26,7 @@ use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Page; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use Psr\EventDispatcher\EventDispatcherInterface; @@ -83,7 +83,7 @@ public function registerTool(Tool $tool, callable|array|string $handler): ToolRe return $reference; } - public function registerResource(Resource $resource, callable|array|string $handler): ResourceReference + public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference { $reference = new ResourceReference($resource, $handler); $this->resources[$resource->uri] = $reference; diff --git a/src/Capability/Registry/Loader/ExplicitElementLoader.php b/src/Capability/Registry/Loader/ExplicitElementLoader.php new file mode 100644 index 00000000..0acd83cd --- /dev/null +++ b/src/Capability/Registry/Loader/ExplicitElementLoader.php @@ -0,0 +1,80 @@ + + */ +final class ExplicitElementLoader implements LoaderInterface +{ + /** + * @param list $tools + * @param list $resources + * @param list $resourceTemplates + * @param list $prompts + */ + public function __construct( + private readonly array $tools = [], + private readonly array $resources = [], + private readonly array $resourceTemplates = [], + private readonly array $prompts = [], + ) { + } + + public function load(RegistryInterface $registry): void + { + foreach ($this->tools as $entry) { + $handler = $entry['handler']; + $registry->registerTool( + $entry['definition'], + static fn (array $arguments, ClientGateway $client) => $handler->execute($arguments, $client), + ); + } + + foreach ($this->resources as $entry) { + $handler = $entry['handler']; + $registry->registerResource( + $entry['definition'], + static fn (string $uri, ClientGateway $client) => $handler->read($uri, $client), + ); + } + + foreach ($this->resourceTemplates as $entry) { + $handler = $entry['handler']; + $registry->registerResourceTemplate( + $entry['definition'], + static fn (string $uri, array $variables, ClientGateway $client) => $handler->read($uri, $variables, $client), + ); + } + + foreach ($this->prompts as $entry) { + $handler = $entry['handler']; + $registry->registerPrompt( + $entry['definition'], + static fn (array $arguments, ClientGateway $client) => $handler->get($arguments, $client), + ); + } + } +} diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ReflectedElementLoader.php similarity index 98% rename from src/Capability/Registry/Loader/ArrayLoader.php rename to src/Capability/Registry/Loader/ReflectedElementLoader.php index 8865b0ff..a87dd58b 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ReflectedElementLoader.php @@ -26,7 +26,7 @@ use Mcp\Schema\Icon; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; @@ -39,7 +39,7 @@ * * @phpstan-import-type Handler from ElementReference */ -final class ArrayLoader implements LoaderInterface +final class ReflectedElementLoader implements LoaderInterface { /** * @param array{ @@ -156,7 +156,7 @@ public function load(RegistryInterface $registry): void $description = $data['description'] ?? $docBlockParser->getDescription($docBlock) ?? null; } - $resource = new Resource( + $resource = new ResourceDefinition( uri: $data['uri'], name: $name, title: $data['title'] ?? null, diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 7b4a0cdc..e0f5d907 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,6 +13,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; +use Mcp\Server\ClientGateway; use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -22,6 +23,9 @@ */ final class ReferenceHandler implements ReferenceHandlerInterface { + private const RESERVED_ARGUMENT_KEYS = ['_session', '_request']; + private const ARGUMENT_BAG_PARAMETERS = ['arguments', 'variables']; + public function __construct( private readonly ?ContainerInterface $container = null, ) { @@ -87,8 +91,10 @@ private function getClassInstance(string $className): object private function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments): array { $finalArgs = []; + $parameters = $reflection->getParameters(); + $allParamNames = array_map(static fn (\ReflectionParameter $p) => $p->getName(), $parameters); - foreach ($reflection->getParameters() as $parameter) { + foreach ($parameters as $parameter) { // TODO: Handle variadic parameters. $paramName = $parameter->getName(); $paramPosition = $parameter->getPosition(); @@ -102,6 +108,11 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array $finalArgs[$paramPosition] = new RequestContext($arguments['_session'], $arguments['_request']); continue; } + + if (ClientGateway::class === $typeName && isset($arguments['_session'])) { + $finalArgs[$paramPosition] = new ClientGateway($arguments['_session']); + continue; + } } if (isset($arguments[$paramName])) { @@ -113,6 +124,16 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array } catch (\Throwable $e) { throw RegistryException::internalError("Error processing parameter `{$paramName}`: {$e->getMessage()}", $e); } + } elseif ( + $type instanceof \ReflectionNamedType + && 'array' === $type->getName() + && \in_array($paramName, self::ARGUMENT_BAG_PARAMETERS, true) + ) { + $skipKeys = array_merge( + self::RESERVED_ARGUMENT_KEYS, + array_values(array_diff($allParamNames, [$paramName])), + ); + $finalArgs[$paramPosition] = array_diff_key($arguments, array_flip($skipKeys)); } elseif ($parameter->isDefaultValueAvailable()) { $finalArgs[$paramPosition] = $parameter->getDefaultValue(); } elseif ($parameter->allowsNull()) { diff --git a/src/Capability/Registry/ResourceReference.php b/src/Capability/Registry/ResourceReference.php index bec8b9b5..10d78377 100644 --- a/src/Capability/Registry/ResourceReference.php +++ b/src/Capability/Registry/ResourceReference.php @@ -13,7 +13,7 @@ use Mcp\Capability\Formatter\ResourceResultFormatter; use Mcp\Schema\Content\ResourceContents; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; /** * @phpstan-import-type Handler from ElementReference @@ -26,7 +26,7 @@ class ResourceReference extends ElementReference * @param Handler $handler */ public function __construct( - public readonly Resource $resource, + public readonly ResourceDefinition $resource, callable|array|string $handler, ) { parent::__construct($handler); diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php index 124704b8..bbbef766 100644 --- a/src/Capability/RegistryInterface.php +++ b/src/Capability/RegistryInterface.php @@ -21,7 +21,7 @@ use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Page; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; @@ -47,7 +47,7 @@ public function registerTool(Tool $tool, callable|array|string $handler): ToolRe * * @param Handler $handler */ - public function registerResource(Resource $resource, callable|array|string $handler): ResourceReference; + public function registerResource(ResourceDefinition $resource, callable|array|string $handler): ResourceReference; /** * Registers a resource template with its handler and completion providers. diff --git a/src/Schema/Page.php b/src/Schema/Page.php index bad46e9c..3d546464 100644 --- a/src/Schema/Page.php +++ b/src/Schema/Page.php @@ -12,14 +12,14 @@ namespace Mcp\Schema; /** - * @phpstan-type PageItem Tool|Prompt|ResourceTemplate|Resource + * @phpstan-type PageItem Tool|Prompt|ResourceTemplate|ResourceDefinition * * @extends \ArrayObject */ final class Page extends \ArrayObject { /** - * @param array $references Items can be Tool, Prompt, ResourceTemplate, or Resource + * @param array $references Items can be Tool, Prompt, ResourceTemplate, or ResourceDefinition */ public function __construct( public readonly array $references, diff --git a/src/Schema/Resource.php b/src/Schema/ResourceDefinition.php similarity index 95% rename from src/Schema/Resource.php rename to src/Schema/ResourceDefinition.php index 00fc267a..a3999667 100644 --- a/src/Schema/Resource.php +++ b/src/Schema/ResourceDefinition.php @@ -19,7 +19,7 @@ * @phpstan-import-type AnnotationsData from Annotations * @phpstan-import-type IconData from Icon * - * @phpstan-type ResourceData array{ + * @phpstan-type ResourceDefinitionData array{ * uri: string, * name: string, * title?: string, @@ -33,7 +33,7 @@ * * @author Kyrian Obikwelu */ -class Resource implements \JsonSerializable +class ResourceDefinition implements \JsonSerializable { /** * Resource name pattern regex - must contain only alphanumeric characters, underscores, and hyphens. @@ -77,19 +77,19 @@ public function __construct( } /** - * @param ResourceData $data + * @param ResourceDefinitionData $data */ public static function fromArray(array $data): self { if (empty($data['uri']) || !\is_string($data['uri'])) { - throw new InvalidArgumentException('Invalid or missing "uri" in Resource data.'); + throw new InvalidArgumentException('Invalid or missing "uri" in ResourceDefinition data.'); } if (empty($data['name']) || !\is_string($data['name'])) { - throw new InvalidArgumentException('Invalid or missing "name" in Resource data.'); + throw new InvalidArgumentException('Invalid or missing "name" in ResourceDefinition data.'); } if (!empty($data['_meta']) && !\is_array($data['_meta'])) { - throw new InvalidArgumentException('Invalid "_meta" in Resource data.'); + throw new InvalidArgumentException('Invalid "_meta" in ResourceDefinition data.'); } return new self( diff --git a/src/Schema/Result/ListResourcesResult.php b/src/Schema/Result/ListResourcesResult.php index 50fa9bfe..0f338caa 100644 --- a/src/Schema/Result/ListResourcesResult.php +++ b/src/Schema/Result/ListResourcesResult.php @@ -14,20 +14,20 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\JsonRpc\ResultInterface; -use Mcp\Schema\Resource as ResourceSchema; +use Mcp\Schema\ResourceDefinition; /** * The server's response to a resources/list request from the client. * - * @phpstan-import-type ResourceData from ResourceSchema + * @phpstan-import-type ResourceDefinitionData from ResourceDefinition * * @author Kyrian Obikwelu */ class ListResourcesResult implements ResultInterface { /** - * @param array $resources the list of resource definitions - * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. + * @param array $resources the list of resource definitions + * @param string|null $nextCursor An opaque token representing the pagination position after the last returned result. * * If present, there may be more results available. */ @@ -39,7 +39,7 @@ public function __construct( /** * @param array{ - * resources: array, + * resources: array, * nextCursor?: string, * } $data */ @@ -50,14 +50,14 @@ public static function fromArray(array $data): self } return new self( - array_map(static fn (array $resource) => ResourceSchema::fromArray($resource), $data['resources']), + array_map(static fn (array $resource) => ResourceDefinition::fromArray($resource), $data['resources']), $data['nextCursor'] ?? null ); } /** * @return array{ - * resources: array, + * resources: array, * nextCursor?: string, * } */ diff --git a/src/Server/Builder.php b/src/Server/Builder.php index ba26d23f..eedd271c 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -18,10 +18,11 @@ use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ElementReference; -use Mcp\Capability\Registry\Loader\ArrayLoader; use Mcp\Capability\Registry\Loader\ChainLoader; use Mcp\Capability\Registry\Loader\DiscoveryLoader; +use Mcp\Capability\Registry\Loader\ExplicitElementLoader; use Mcp\Capability\Registry\Loader\LoaderInterface; +use Mcp\Capability\Registry\Loader\ReflectedElementLoader; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; @@ -31,11 +32,20 @@ use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; +use Mcp\Schema\Prompt; +use Mcp\Schema\ResourceDefinition; +use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; +use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; +use Mcp\Server\Handler\ElementHandlerInterface; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Handler\PromptHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Handler\ResourceHandlerInterface; +use Mcp\Server\Handler\ResourceTemplateHandlerInterface; +use Mcp\Server\Handler\ToolHandlerInterface; use Mcp\Server\Resource\SessionSubscriptionManager; use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\InMemorySessionStore; @@ -107,6 +117,7 @@ final class Builder * title: ?string, * description: ?string, * annotations: ?ToolAnnotations, + * inputSchema: ?array, * icons: ?Icon[], * meta: ?array, * outputSchema: ?array, @@ -148,6 +159,7 @@ final class Builder * @var array{ * handler: Handler, * name: ?string, + * title: ?string, * description: ?string, * icons: ?Icon[], * meta: ?array @@ -155,6 +167,26 @@ final class Builder */ private array $prompts = []; + /** + * @var list + */ + private array $explicitTools = []; + + /** + * @var list + */ + private array $explicitResources = []; + + /** + * @var list + */ + private array $explicitResourceTemplates = []; + + /** + * @var list + */ + private array $explicitPrompts = []; + private ?string $discoveryBasePath = null; /** @@ -518,6 +550,32 @@ public function addPrompt( return $this; } + /** + * Registers an element using an explicit schema value object paired with a handler interface. + * + * Use this entry point when an element's name, schema, or description is only known at + * runtime (e.g. config-driven integrations). For statically-known elements, prefer + * `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from + * reflection of the handler. + * + * Mismatched pairings (e.g. a `Tool` with a `PromptHandlerInterface`) raise + * `Mcp\Exception\InvalidArgumentException`. + */ + public function add( + Tool|ResourceDefinition|ResourceTemplate|Prompt $definition, + ElementHandlerInterface $handler, + ): self { + match (true) { + $definition instanceof Tool && $handler instanceof ToolHandlerInterface => $this->explicitTools[] = ['definition' => $definition, 'handler' => $handler], + $definition instanceof ResourceDefinition && $handler instanceof ResourceHandlerInterface => $this->explicitResources[] = ['definition' => $definition, 'handler' => $handler], + $definition instanceof ResourceTemplate && $handler instanceof ResourceTemplateHandlerInterface => $this->explicitResourceTemplates[] = ['definition' => $definition, 'handler' => $handler], + $definition instanceof Prompt && $handler instanceof PromptHandlerInterface => $this->explicitPrompts[] = ['definition' => $definition, 'handler' => $handler], + default => throw new InvalidArgumentException(\sprintf('%s definition cannot be paired with %s; expected the matching handler interface.', $definition::class, $handler::class)), + }; + + return $this; + } + /** * Register a single custom loader. */ @@ -556,11 +614,17 @@ public function build(): Server $this->gcDivisor, ); - // ArrayLoader runs before DiscoveryLoader so manual entries are seen first; DiscoveryLoader's - // identity check then preserves them against same-name discovered entries. + // ReflectedElementLoader runs before DiscoveryLoader so manual entries are seen first; + // DiscoveryLoader's identity check then preserves them against same-name discovered entries. $loaders = [ ...$this->loaders, - new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), + new ExplicitElementLoader( + $this->explicitTools, + $this->explicitResources, + $this->explicitResourceTemplates, + $this->explicitPrompts, + ), + new ReflectedElementLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), ]; if (null !== $this->discoveryBasePath) { diff --git a/src/Server/Handler/ElementHandlerInterface.php b/src/Server/Handler/ElementHandlerInterface.php new file mode 100644 index 00000000..6b08955d --- /dev/null +++ b/src/Server/Handler/ElementHandlerInterface.php @@ -0,0 +1,23 @@ + + */ +interface ElementHandlerInterface +{ +} diff --git a/src/Server/Handler/PromptHandlerInterface.php b/src/Server/Handler/PromptHandlerInterface.php new file mode 100644 index 00000000..4143c976 --- /dev/null +++ b/src/Server/Handler/PromptHandlerInterface.php @@ -0,0 +1,28 @@ + + */ +interface PromptHandlerInterface extends ElementHandlerInterface +{ + /** + * @param array $arguments + */ + public function get(array $arguments, ClientGateway $gateway): mixed; +} diff --git a/src/Server/Handler/ResourceHandlerInterface.php b/src/Server/Handler/ResourceHandlerInterface.php new file mode 100644 index 00000000..7ca533a9 --- /dev/null +++ b/src/Server/Handler/ResourceHandlerInterface.php @@ -0,0 +1,25 @@ + + */ +interface ResourceHandlerInterface extends ElementHandlerInterface +{ + public function read(string $uri, ClientGateway $gateway): mixed; +} diff --git a/src/Server/Handler/ResourceTemplateHandlerInterface.php b/src/Server/Handler/ResourceTemplateHandlerInterface.php new file mode 100644 index 00000000..892e4393 --- /dev/null +++ b/src/Server/Handler/ResourceTemplateHandlerInterface.php @@ -0,0 +1,28 @@ + + */ +interface ResourceTemplateHandlerInterface extends ElementHandlerInterface +{ + /** + * @param array $variables + */ + public function read(string $uri, array $variables, ClientGateway $gateway): mixed; +} diff --git a/src/Server/Handler/ToolHandlerInterface.php b/src/Server/Handler/ToolHandlerInterface.php new file mode 100644 index 00000000..4f2ee3e9 --- /dev/null +++ b/src/Server/Handler/ToolHandlerInterface.php @@ -0,0 +1,28 @@ + + */ +interface ToolHandlerInterface extends ElementHandlerInterface +{ + /** + * @param array $arguments + */ + public function execute(array $arguments, ClientGateway $gateway): mixed; +} diff --git a/tests/Unit/Capability/Discovery/DiscoveryStateTest.php b/tests/Unit/Capability/Discovery/DiscoveryStateTest.php index b5b60b12..e40c722d 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryStateTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryStateTest.php @@ -17,7 +17,7 @@ use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use PHPUnit\Framework\TestCase; @@ -141,7 +141,7 @@ private function tool(string $name): ToolReference private function resource(string $uri): ResourceReference { return new ResourceReference( - new Resource(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'), + new ResourceDefinition(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'), static fn () => null, ); } diff --git a/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php b/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php index eaab2dfa..dcb1d8d4 100644 --- a/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php +++ b/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php @@ -24,7 +24,7 @@ use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use Mcp\Tests\Unit\Capability\Registry\Loader\Stub\MutableDiscoverer; @@ -267,9 +267,9 @@ private function makeTool(string $name): Tool ); } - private function makeResource(string $uri): Resource + private function makeResource(string $uri): ResourceDefinition { - return new Resource(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'); + return new ResourceDefinition(uri: $uri, name: 'r', description: null, mimeType: 'text/plain'); } private function makePrompt(string $name): Prompt diff --git a/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php new file mode 100644 index 00000000..d3e205aa --- /dev/null +++ b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php @@ -0,0 +1,258 @@ + 'object', 'properties' => ['foo' => ['type' => 'string']], 'required' => []], + description: 'A demo tool', + annotations: null, + ); + $handler = new class implements ToolHandlerInterface { + /** @var array|null */ + public ?array $receivedArguments = null; + public ?ClientGateway $receivedGateway = null; + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + $this->receivedArguments = $arguments; + $this->receivedGateway = $gateway; + + return 'tool-ok'; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($tool, $handler)); + + $reference = $registry->getTool('demo'); + $this->assertSame('demo', $reference->tool->name); + $this->assertSame('A demo tool', $reference->tool->description); + $this->assertSame(['type' => 'object', 'properties' => ['foo' => ['type' => 'string']], 'required' => []], $reference->tool->inputSchema); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'foo' => 'bar', + ]); + + $this->assertSame('tool-ok', $result); + $this->assertSame(['foo' => 'bar'], $handler->receivedArguments); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + + public function testAddResourceRegistersDefinitionAndDispatchesToHandler(): void + { + $resource = new ResourceDefinition( + uri: 'config://demo', + name: 'demo', + description: 'A demo resource', + mimeType: 'text/plain', + ); + $handler = new class implements ResourceHandlerInterface { + public ?string $receivedUri = null; + public ?ClientGateway $receivedGateway = null; + + public function read(string $uri, ClientGateway $gateway): mixed + { + $this->receivedUri = $uri; + $this->receivedGateway = $gateway; + + return ['contents' => 'resource-ok']; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($resource, $handler)); + + $reference = $registry->getResource('config://demo', false); + $this->assertSame('config://demo', $reference->resource->uri); + $this->assertSame('demo', $reference->resource->name); + $this->assertSame('text/plain', $reference->resource->mimeType); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'uri' => 'config://demo', + ]); + + $this->assertSame(['contents' => 'resource-ok'], $result); + $this->assertSame('config://demo', $handler->receivedUri); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + + public function testAddResourceTemplateRegistersDefinitionAndDispatchesToHandler(): void + { + $template = new ResourceTemplate( + uriTemplate: 'config://{key}', + name: 'config_template', + description: 'A demo template', + ); + $handler = new class implements ResourceTemplateHandlerInterface { + public ?string $receivedUri = null; + /** @var array|null */ + public ?array $receivedVariables = null; + public ?ClientGateway $receivedGateway = null; + + public function read(string $uri, array $variables, ClientGateway $gateway): mixed + { + $this->receivedUri = $uri; + $this->receivedVariables = $variables; + $this->receivedGateway = $gateway; + + return ['contents' => 'template-ok']; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($template, $handler)); + + $reference = $registry->getResourceTemplate('config://{key}'); + $this->assertSame('config://{key}', $reference->resourceTemplate->uriTemplate); + $this->assertSame('config_template', $reference->resourceTemplate->name); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'uri' => 'config://abc', + 'key' => 'abc', + ]); + + $this->assertSame(['contents' => 'template-ok'], $result); + $this->assertSame('config://abc', $handler->receivedUri); + $this->assertSame(['key' => 'abc'], $handler->receivedVariables); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + + public function testAddPromptRegistersDefinitionAndDispatchesToHandler(): void + { + $prompt = new Prompt( + name: 'demo_prompt', + title: null, + description: 'A demo prompt', + ); + $handler = new class implements PromptHandlerInterface { + /** @var array|null */ + public ?array $receivedArguments = null; + public ?ClientGateway $receivedGateway = null; + + public function get(array $arguments, ClientGateway $gateway): mixed + { + $this->receivedArguments = $arguments; + $this->receivedGateway = $gateway; + + return 'prompt-ok'; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($prompt, $handler)); + + $reference = $registry->getPrompt('demo_prompt'); + $this->assertSame('demo_prompt', $reference->prompt->name); + $this->assertSame('A demo prompt', $reference->prompt->description); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $result = (new ReferenceHandler())->handle($reference, [ + '_session' => $session, + '_request' => new \stdClass(), + 'topic' => 'php', + ]); + + $this->assertSame('prompt-ok', $result); + $this->assertSame(['topic' => 'php'], $handler->receivedArguments); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + + public function testMismatchedDefinitionAndHandlerThrowsInvalidArgumentException(): void + { + $prompt = new Prompt(name: 'mismatched', title: null, description: null); + $toolHandler = new class implements ToolHandlerInterface { + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return null; + } + }; + + $this->expectException(InvalidArgumentException::class); + + Server::builder() + ->setServerInfo('test', '1.0.0') + ->add($prompt, $toolHandler); + } + + public function testLoaderRegistersClosuresRatherThanHandlerInstances(): void + { + $tool = new Tool( + name: 'closure_check', + title: null, + inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], + description: null, + annotations: null, + ); + $handler = new class implements ToolHandlerInterface { + public function execute(array $arguments, ClientGateway $gateway): mixed + { + return null; + } + }; + + $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($tool, $handler)); + + $reference = $registry->getTool('closure_check'); + $this->assertInstanceOf(\Closure::class, $reference->handler); + } + + /** + * @param callable(Server\Builder): Server\Builder $configure + */ + private function buildAndGetRegistry(callable $configure): RegistryInterface + { + $registry = new Registry(); + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setRegistry($registry); + $configure($builder)->build(); + + return $registry; + } +} diff --git a/tests/Unit/Capability/Registry/Loader/ArrayLoaderResourceTitleTest.php b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php similarity index 88% rename from tests/Unit/Capability/Registry/Loader/ArrayLoaderResourceTitleTest.php rename to tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php index bd01207a..6dd09991 100644 --- a/tests/Unit/Capability/Registry/Loader/ArrayLoaderResourceTitleTest.php +++ b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php @@ -12,10 +12,10 @@ namespace Mcp\Tests\Unit\Capability\Registry\Loader; use Mcp\Capability\Registry; -use Mcp\Capability\Registry\Loader\ArrayLoader; +use Mcp\Capability\Registry\Loader\ReflectedElementLoader; use PHPUnit\Framework\TestCase; -class ArrayLoaderResourceTitleTest extends TestCase +class ReflectedElementLoaderResourceTitleTest extends TestCase { public function testLoadPropagatesResourceTitleToRegisteredResource(): void { @@ -34,7 +34,7 @@ public function testLoadPropagatesResourceTitleToRegisteredResource(): void ], ]; - $loader = new ArrayLoader([], $resources); + $loader = new ReflectedElementLoader([], $resources); $registry = new Registry(); $loader->load($registry); @@ -58,7 +58,7 @@ public function testLoadPropagatesResourceTemplateTitleToRegisteredTemplate(): v ], ]; - $loader = new ArrayLoader([], [], $resourceTemplates); + $loader = new ReflectedElementLoader([], [], $resourceTemplates); $registry = new Registry(); $loader->load($registry); diff --git a/tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php similarity index 87% rename from tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php rename to tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php index 68a1bfb7..8537fcc8 100644 --- a/tests/Unit/Capability/Registry/Loader/ArrayLoaderToolTitleTest.php +++ b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderToolTitleTest.php @@ -12,10 +12,10 @@ namespace Mcp\Tests\Unit\Capability\Registry\Loader; use Mcp\Capability\Registry; -use Mcp\Capability\Registry\Loader\ArrayLoader; +use Mcp\Capability\Registry\Loader\ReflectedElementLoader; use PHPUnit\Framework\TestCase; -class ArrayLoaderToolTitleTest extends TestCase +class ReflectedElementLoaderToolTitleTest extends TestCase { public function testLoadPropagatesToolTitleToRegisteredTool(): void { @@ -37,7 +37,7 @@ public function testLoadPropagatesToolTitleToRegisteredTool(): void ], ]; - $loader = new ArrayLoader($tools); + $loader = new ReflectedElementLoader($tools); $registry = new Registry(); $loader->load($registry); diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php new file mode 100644 index 00000000..d464872f --- /dev/null +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -0,0 +1,109 @@ +createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $toolHandler = new class implements ToolHandlerInterface { + /** @var array|null */ + public ?array $executedWith = null; + public ?ClientGateway $receivedGateway = null; + + public function execute(array $arguments, ClientGateway $gateway): mixed + { + $this->executedWith = $arguments; + $this->receivedGateway = $gateway; + + return 'tool-result'; + } + }; + + $closure = static fn (array $arguments, ClientGateway $client) => $toolHandler->execute($arguments, $client); + $reference = new ElementReference($closure); + $referenceHandler = new ReferenceHandler(); + + $request = new \stdClass(); + $result = $referenceHandler->handle($reference, [ + '_session' => $session, + '_request' => $request, + 'kept' => 'value', + 'other' => 'value2', + ]); + + $this->assertSame('tool-result', $result); + $this->assertSame( + ['kept' => 'value', 'other' => 'value2'], + $toolHandler->executedWith, + ); + $this->assertInstanceOf(ClientGateway::class, $toolHandler->receivedGateway); + } + + public function testHandleDispatchesToResourceClosureAndForwardsClientGateway(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $resourceHandler = new class implements ResourceHandlerInterface { + public ?string $receivedUri = null; + public ?ClientGateway $receivedGateway = null; + + public function read(string $uri, ClientGateway $gateway): mixed + { + $this->receivedUri = $uri; + $this->receivedGateway = $gateway; + + return ['contents' => 'r-ok']; + } + }; + + $closure = static fn (string $uri, ClientGateway $client) => $resourceHandler->read($uri, $client); + $reference = new ElementReference($closure); + $referenceHandler = new ReferenceHandler(); + + $request = new \stdClass(); + $result = $referenceHandler->handle($reference, [ + '_session' => $session, + '_request' => $request, + 'uri' => 'config://x', + ]); + + $this->assertSame(['contents' => 'r-ok'], $result); + $this->assertSame('config://x', $resourceHandler->receivedUri); + $this->assertInstanceOf(ClientGateway::class, $resourceHandler->receivedGateway); + } + + public function testHandleThrowsForStringHandlerThatIsNeitherFunctionNorClass(): void + { + $session = $this->createMock(SessionInterface::class); + + $reference = new ElementReference('definitely_not_a_function_or_class_xyz'); + + $this->expectException(InvalidArgumentException::class); + + (new ReferenceHandler())->handle($reference, ['_session' => $session]); + } +} diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index 5173aaff..a4d27801 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -21,7 +21,7 @@ use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Prompt; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\Tool; use PHPUnit\Framework\MockObject\MockObject; @@ -124,8 +124,8 @@ public function testGetResourcesReturnsAllRegisteredResources(): void $this->assertCount(2, $resources); $this->assertArrayHasKey('test://resource1', $resources->references); $this->assertArrayHasKey('test://resource2', $resources->references); - $this->assertInstanceOf(Resource::class, $resources->references['test://resource1']); - $this->assertInstanceOf(Resource::class, $resources->references['test://resource2']); + $this->assertInstanceOf(ResourceDefinition::class, $resources->references['test://resource1']); + $this->assertInstanceOf(ResourceDefinition::class, $resources->references['test://resource2']); } public function testGetResourceReturnsRegisteredResource(): void @@ -554,9 +554,9 @@ private function createValidTool(string $name, ?array $outputSchema = null): Too ); } - private function createValidResource(string $uri): Resource + private function createValidResource(string $uri): ResourceDefinition { - return new Resource( + return new ResourceDefinition( uri: $uri, name: 'test_resource', description: 'Test resource', diff --git a/tests/Unit/Schema/ResourceTest.php b/tests/Unit/Schema/ResourceDefinitionTest.php similarity index 81% rename from tests/Unit/Schema/ResourceTest.php rename to tests/Unit/Schema/ResourceDefinitionTest.php index 7a2d9a1d..ab39f41a 100644 --- a/tests/Unit/Schema/ResourceTest.php +++ b/tests/Unit/Schema/ResourceDefinitionTest.php @@ -12,11 +12,11 @@ namespace Mcp\Tests\Unit\Schema; use Mcp\Exception\InvalidArgumentException; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -class ResourceTest extends TestCase +class ResourceDefinitionTest extends TestCase { private const VALID_URI = 'https://example.com/list-books'; @@ -24,12 +24,12 @@ public function testConstructorValid(): void { $uri = self::VALID_URI; - $resource = new Resource( + $resource = new ResourceDefinition( uri: $uri, name: 'list-books', ); - $this->assertInstanceOf(Resource::class, $resource); + $this->assertInstanceOf(ResourceDefinition::class, $resource); $this->assertSame($uri, $resource->uri); } @@ -40,7 +40,7 @@ public function testConstructorInvalid(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid resource URI: "/list-books" must be a valid URI with a scheme and optional path.'); - $resource = new Resource( + $resource = new ResourceDefinition( uri: $uri, name: 'list-books', ); @@ -49,12 +49,12 @@ public function testConstructorInvalid(): void #[DataProvider('provideValidUris')] public function testConstructorAcceptsUris(string $uri): void { - $resource = new Resource( + $resource = new ResourceDefinition( uri: $uri, name: 'test-resource', ); - $this->assertInstanceOf(Resource::class, $resource); + $this->assertInstanceOf(ResourceDefinition::class, $resource); $this->assertSame($uri, $resource->uri); } @@ -69,12 +69,12 @@ public static function provideValidUris(): iterable public function testFromArrayValid(): void { - $resource = Resource::fromArray([ + $resource = ResourceDefinition::fromArray([ 'uri' => self::VALID_URI, 'name' => 'list-books', ]); - $this->assertInstanceOf(Resource::class, $resource); + $this->assertInstanceOf(ResourceDefinition::class, $resource); $this->assertSame(self::VALID_URI, $resource->uri); $this->assertSame('list-books', $resource->name); $this->assertNull($resource->title); @@ -84,7 +84,7 @@ public function testFromArrayValid(): void public function testTitleFromArray(): void { - $resource = Resource::fromArray([ + $resource = ResourceDefinition::fromArray([ 'uri' => self::VALID_URI, 'name' => 'list-books', 'title' => 'Book Listing', @@ -95,7 +95,7 @@ public function testTitleFromArray(): void public function testTitleSerialization(): void { - $resource = new Resource( + $resource = new ResourceDefinition( uri: self::VALID_URI, name: 'list-books', title: 'Book Listing', @@ -107,7 +107,7 @@ public function testTitleSerialization(): void public function testTitleOmittedWhenNull(): void { - $resource = new Resource( + $resource = new ResourceDefinition( uri: self::VALID_URI, name: 'list-books', ); @@ -122,15 +122,15 @@ public function testFromArrayInvalid(array $input, string $expectedExceptionMess $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage($expectedExceptionMessage); - Resource::fromArray($input); + ResourceDefinition::fromArray($input); } public static function provideInvalidResources(): iterable { - yield 'missing uri' => [[], 'Invalid or missing "uri" in Resource data.']; + yield 'missing uri' => [[], 'Invalid or missing "uri" in ResourceDefinition data.']; yield 'missing name' => [ ['uri' => self::VALID_URI], - 'Invalid or missing "name" in Resource data.', + 'Invalid or missing "name" in ResourceDefinition data.', ]; yield 'meta' => [ [ @@ -138,7 +138,7 @@ public static function provideInvalidResources(): iterable 'name' => 'list-books', '_meta' => 'foo', ], - 'Invalid "_meta" in Resource data.', + 'Invalid "_meta" in ResourceDefinition data.', ]; } } diff --git a/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php b/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php index dca1c31c..3e385fef 100644 --- a/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php @@ -14,7 +14,7 @@ use Mcp\Capability\Registry; use Mcp\Exception\InvalidCursorException; use Mcp\Schema\Request\ListResourcesRequest; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\Result\ListResourcesResult; use Mcp\Server\Handler\Request\ListResourcesHandler; use Mcp\Server\Session\InMemorySessionStore; @@ -221,7 +221,7 @@ public function testMaintainsStableCursorsAcrossCalls(): void private function addResourcesToRegistry(int $count): void { for ($i = 0; $i < $count; ++$i) { - $resource = new Resource( + $resource = new ResourceDefinition( uri: "resource://test/resource_$i", name: "resource_$i", description: "Test resource $i" diff --git a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php index 66a22961..efdc9142 100644 --- a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -21,7 +21,7 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\Handler\Request\ReadResourceHandler; use Mcp\Server\Session\SessionInterface; @@ -66,7 +66,7 @@ public function testHandleSuccessfulResourceRead(): void $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -106,7 +106,7 @@ public function testHandleResourceReadWithBlobContent(): void $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'image/png'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'image/png'), []]) ->getMock(); $this->registry @@ -150,7 +150,7 @@ public function testHandleResourceReadWithMultipleContents(): void $expectedResult = new ReadResourceResult([$textContent, $blobContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'application/octet-stream'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'application/octet-stream'), []]) ->getMock(); $this->registry @@ -273,7 +273,7 @@ public function testHandleResourceReadWithDifferentUriSchemes(): void $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -318,7 +318,7 @@ public function testHandleResourceReadWithEmptyContent(): void $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -380,7 +380,7 @@ public function testHandleResourceReadWithDifferentMimeTypes(): void $expectedResult = new ReadResourceResult([$expectedContent]); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: $mimeType), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: $mimeType), []]) ->getMock(); $this->registry diff --git a/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php index 02e2e5c4..b7d491b5 100644 --- a/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php +++ b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php @@ -18,7 +18,7 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ResourceSubscribeRequest; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\Result\EmptyResult; use Mcp\Server\Handler\Request\ResourceSubscribeHandler; use Mcp\Server\Resource\SubscriptionManagerInterface; @@ -51,7 +51,7 @@ public function testClientCanSuccessfulSubscribeToAResource(): void $uri = 'file://documents/readme.txt'; $request = $this->createResourceSubscribeRequest($uri); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -77,7 +77,7 @@ public function testDuplicateSubscriptionIsGracefullyHandled(): void $uri = 'file://documents/readme.txt'; $request = $this->createResourceSubscribeRequest($uri); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry diff --git a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php index d71dfb3b..5586d055 100644 --- a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php +++ b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php @@ -18,7 +18,7 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ResourceUnsubscribeRequest; -use Mcp\Schema\Resource; +use Mcp\Schema\ResourceDefinition; use Mcp\Schema\Result\EmptyResult; use Mcp\Server\Handler\Request\ResourceUnsubscribeHandler; use Mcp\Server\Resource\SubscriptionManagerInterface; @@ -53,7 +53,7 @@ public function testClientCanUnsubscribeFromAResource(): void $uri = 'file://documents/readme.txt'; $request = $this->createResourceUnsubscribeRequest($uri); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry @@ -82,7 +82,7 @@ public function testDuplicateUnSubscriptionIsGracefullyHandled(): void $uri = 'file://documents/readme.txt'; $request = $this->createResourceUnsubscribeRequest($uri); $resourceReference = $this->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->setConstructorArgs([new ResourceDefinition($uri, 'test', mimeType: 'text/plain'), []]) ->getMock(); $this->registry