From 69190d8bd881beebde4958b9deb0a0d98a4e8ed6 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Sun, 23 Aug 2026 22:29:57 +0200 Subject: [PATCH] [Server] Reject a placeholder-less resource template URI at registration A URI template without a placeholder was accepted by Builder::addResourceTemplate() and only rejected when the registry was loaded. Loading is lazy by default, so that happened while a request was being served, aborted the whole load and left every other element unreachable: one malformed template answered tools/list and tools/call with -32602 and the template's message. The builder now refuses it where it is written, naming the handler and pointing a URI that addresses a single resource at addResource(). Fixes #476 --- CHANGELOG.md | 1 + docs/servers/registration.md | 3 +- src/Schema/ResourceTemplate.php | 16 +++++++- src/Server/Builder.php | 26 +++++++++++++ tests/Unit/Schema/ResourceTemplateTest.php | 19 +++++++++ tests/Unit/Server/BuilderTest.php | 45 ++++++++++++++++++++++ 6 files changed, 107 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8e2be67..c0bd8b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `Mcp\Schema\Content\ResourceLink` — reference a resource by URI/name in tool results and prompt messages without embedding its contents. * Add client-side Roots support: `RootsCallbackInterface`, `Client::sendRootsListChanged()`, and server-side `ClientGateway::listRoots()`/`supportsRoots()`. * Add `ClientGateway::supportsSampling()` to check the client's advertised capabilities before sending a sampling request, matching `supportsRoots()`/`supportsElicitation()`. +* Reject a placeholder-less URI template in `Builder::addResourceTemplate()` rather than when the registry is loaded. Loading is lazy by default, so the `ConfigurationException` it raised there arrived while a request was being served, aborted the whole load, and left every other element unreachable too: a single malformed template answered `tools/list` and `tools/call` with `-32602` and the template's message. The builder now refuses it where it is written, naming the handler and pointing a static URI at `addResource()`. Adds `ResourceTemplate::isValidUriTemplate()`. * Fix empty tool/resource schemas serializing as `[]` instead of `{}` in `inputSchema`/`outputSchema`. * Fix `PromptResultFormatter` dropping `annotations`, `_meta`, and `mimeType` when a prompt generator returns content as a plain array. * Add `annotations` support to `ImageContent`, matching `TextContent`/`AudioContent`. diff --git a/docs/servers/registration.md b/docs/servers/registration.md index e2dc563c..46fc361d 100644 --- a/docs/servers/registration.md +++ b/docs/servers/registration.md @@ -136,7 +136,8 @@ $server = Server::builder() #### Parameters - `handler` (callable|string): The resource template handler -- `uriTemplate` (string): The resource URI template +- `uriTemplate` (string): The resource URI template. It must carry at least one `{placeholder}`, which is what lets it + address more than one resource. A URI without one is a single resource: register it with `addResource()` instead. - `name` (string|null): Optional resource template name - `title` (string|null): Optional human-readable title for display in UI - `description` (string|null): Optional resource template description diff --git a/src/Schema/ResourceTemplate.php b/src/Schema/ResourceTemplate.php index 46033975..7358da2c 100644 --- a/src/Schema/ResourceTemplate.php +++ b/src/Schema/ResourceTemplate.php @@ -56,11 +56,23 @@ public function __construct( public readonly ?Annotations $annotations = null, public readonly ?array $meta = null, ) { - if (!preg_match(self::URI_TEMPLATE_PATTERN, $uriTemplate)) { - throw new InvalidArgumentException(\sprintf('Invalid URI template : "%s" must be a valid URI template with at least one placeholder.', $uriTemplate)); + if (!self::isValidUriTemplate($uriTemplate)) { + throw new InvalidArgumentException(\sprintf('Invalid URI template : "%s" must be a valid URI template with at least one placeholder. A URI without a placeholder addresses a single resource, register it as a resource instead.', $uriTemplate)); } } + /** + * Whether the string can address more than one resource, which is what makes it a template + * rather than a resource URI: a scheme, and at least one placeholder to fill. + * + * Exposed so registration can reject a malformed template where the developer wrote it, + * instead of at the first read of the registry. + */ + public static function isValidUriTemplate(string $uriTemplate): bool + { + return 1 === preg_match(self::URI_TEMPLATE_PATTERN, $uriTemplate); + } + /** * @param ResourceTemplateData $data */ diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 90272ef3..a9f519e5 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -729,9 +729,15 @@ public function addResource( /** * Manually registers a resource template handler. * + * The URI template is validated here rather than when the registry is loaded: loading is lazy + * by default and therefore happens while a request is being served, where a malformed template + * aborts the whole load and takes every other element down with it. + * * @param Handler $handler * @param ?string $title Optional human-readable title for display in UI * @param array|null $meta + * + * @throws InvalidArgumentException if the URI template contains no placeholder */ public function addResourceTemplate( \Closure|array|string $handler, @@ -743,6 +749,10 @@ public function addResourceTemplate( ?Annotations $annotations = null, ?array $meta = null, ): self { + if (!ResourceTemplate::isValidUriTemplate($uriTemplate)) { + throw new InvalidArgumentException(\sprintf('Invalid URI template "%s" for resource template handler %s: a template needs a scheme and at least one placeholder, e.g. "user://{userId}/profile". Use addResource() for a URI that addresses a single resource.', $uriTemplate, $this->describeHandler($handler))); + } + $this->resourceTemplates[] = compact( 'handler', 'uriTemplate', @@ -878,6 +888,22 @@ public function setInputRequiredLimits(int $maxRounds, int $roundTimeout): self return $this; } + /** + * @param Handler $handler + */ + private function describeHandler(\Closure|array|string $handler): string + { + if ($handler instanceof \Closure) { + return 'Closure'; + } + + if (\is_array($handler)) { + return \sprintf('%s::%s()', \is_object($handler[0]) ? $handler[0]::class : $handler[0], $handler[1]); + } + + return $handler; + } + private function requestStateCodec(): ?RequestStateCodec { return null !== $this->requestStateKey diff --git a/tests/Unit/Schema/ResourceTemplateTest.php b/tests/Unit/Schema/ResourceTemplateTest.php index b99c9d08..b26bc072 100644 --- a/tests/Unit/Schema/ResourceTemplateTest.php +++ b/tests/Unit/Schema/ResourceTemplateTest.php @@ -65,6 +65,25 @@ public static function provideValidTemplates(): iterable yield 'urn-style template' => ['urn:resource:{id}']; } + #[DataProvider('provideValidTemplates')] + public function testIsValidUriTemplateAcceptsTemplates(string $uriTemplate): void + { + $this->assertTrue(ResourceTemplate::isValidUriTemplate($uriTemplate)); + } + + #[DataProvider('provideInvalidTemplates')] + public function testIsValidUriTemplateRejectsNonTemplates(string $uriTemplate): void + { + $this->assertFalse(ResourceTemplate::isValidUriTemplate($uriTemplate)); + } + + public static function provideInvalidTemplates(): iterable + { + yield 'no scheme' => ['/list-books']; + yield 'no placeholder' => ['data://tags']; + yield 'empty placeholder' => ['data://tags/{}']; + } + public function testFromArrayValid(): void { $resource = ResourceTemplate::fromArray([ diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 9cdf3292..bb82100b 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -404,6 +404,51 @@ private function callTool(Server $server, string $toolName): mixed $this->fail('CallToolHandler not found in request handlers'); } + + #[TestDox('addResourceTemplate() rejects a URI template without a placeholder, naming the handler')] + public function testAddResourceTemplateRejectsUriTemplateWithoutPlaceholder(): void + { + try { + Server::builder()->addResourceTemplate([GreetingService::class, 'greet'], 'data://tags'); + $this->fail('Expected the malformed URI template to be rejected.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('"data://tags"', $e->getMessage()); + $this->assertStringContainsString(GreetingService::class.'::greet()', $e->getMessage()); + $this->assertStringContainsString('addResource()', $e->getMessage()); + } + } + + #[TestDox('addResourceTemplate() accepts a template carrying a placeholder')] + public function testAddResourceTemplateAcceptsUriTemplateWithPlaceholder(): void + { + $builder = Server::builder(); + + $result = $builder->addResourceTemplate([GreetingService::class, 'greet'], 'data://tags/{id}'); + + $this->assertSame($builder, $result); + } + + #[TestDox('A rejected resource template leaves the remaining elements servable')] + public function testRejectedResourceTemplateLeavesOtherElementsServable(): void + { + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->addTool([GreetingService::class, 'greet'], 'greet'); + + try { + $builder->addResourceTemplate([GreetingService::class, 'greet'], 'data://tags'); + $this->fail('Expected the malformed URI template to be rejected.'); + } catch (InvalidArgumentException) { + // The registration is refused where it is written, so the registry never carries it. + } + + $registry = new Registry(); + $builder->setRegistry($registry); + $builder->build(); + + $this->assertCount(1, $registry->getTools()); + $this->assertCount(0, $registry->getResourceTemplates()); + } } /**