Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 2 additions & 1 deletion docs/servers/registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions src/Schema/ResourceTemplate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
26 changes: 26 additions & 0 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed>|null $meta
*
* @throws InvalidArgumentException if the URI template contains no placeholder
*/
public function addResourceTemplate(
\Closure|array|string $handler,
Expand All @@ -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)));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bringing the validation logic into Builder is not a real option to me. even tho you tried to mitigate with that new isValidUriTemplate method, this still is a bit leaky and wouldn't scale nice - think of bringing all those validation+exception paths to the Builder ... 😬


$this->resourceTemplates[] = compact(
'handler',
'uriTemplate',
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions tests/Unit/Schema/ResourceTemplateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
45 changes: 45 additions & 0 deletions tests/Unit/Server/BuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}

/**
Expand Down