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 `annotations` to `ImageContent`.
* Fix empty tool/resource schemas serializing as `[]` instead of `{}`.
* Fix `PromptResultFormatter` dropping `annotations`, `_meta` and `mimeType` for plain-array content.
* Fix one unregistrable element aborting the whole registry load: `ReflectedElementLoader` now logs and skips it instead of rethrowing a `ConfigurationException`, which under lazy loading answered every request with the failed element's message.

0.7.0
-----
Expand Down
4 changes: 3 additions & 1 deletion docs/servers/registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ $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.
A template that does not qualify is logged and skipped when the registry loads; the rest of the server keeps working.
- `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
10 changes: 5 additions & 5 deletions src/Capability/Registry/Loader/ReflectedElementLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
use Mcp\Capability\Discovery\SchemaGeneratorInterface;
use Mcp\Capability\Registry\ElementReference;
use Mcp\Capability\RegistryInterface;
use Mcp\Exception\ConfigurationException;
use Mcp\Schema\Annotations;
use Mcp\Schema\Icon;
use Mcp\Schema\Prompt;
Expand All @@ -35,6 +34,11 @@
use Psr\Log\NullLogger;

/**
* An element that cannot be registered is logged and skipped, never rethrown: loading is lazy by
* default and therefore runs while a request is being served, where aborting takes every other
* element down with it and answers unrelated calls with the failed element's message. This matches
* how `Discoverer` already treats a bad attribute.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*
* @phpstan-import-type Handler from ElementReference
Expand Down Expand Up @@ -135,7 +139,6 @@ public function load(RegistryInterface $registry): void
'Failed to register manual tool',
['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e],
);
throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e);
}
}

Expand Down Expand Up @@ -176,7 +179,6 @@ public function load(RegistryInterface $registry): void
'Failed to register manual resource',
['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e],
);
throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e);
}
}

Expand Down Expand Up @@ -216,7 +218,6 @@ public function load(RegistryInterface $registry): void
'Failed to register manual template',
['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e],
);
throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e);
}
}

Expand Down Expand Up @@ -274,7 +275,6 @@ public function load(RegistryInterface $registry): void
'Failed to register manual prompt',
['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e],
);
throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Schema/ResourceTemplate.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public function __construct(
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));
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));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Tests\Unit\Capability\Registry\Loader;

use Mcp\Capability\Registry;
use Mcp\Capability\Registry\Loader\ReflectedElementLoader;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Psr\Log\AbstractLogger;
use Psr\Log\LogLevel;

class ReflectedElementLoaderFailureTest extends TestCase
{
#[TestDox('A placeholder-less URI template costs its own registration, not the whole registry')]
public function testMalformedResourceTemplateLeavesEveryOtherElementRegistered(): void
{
$registry = new Registry(logger: new RecordingLogger(), loader: $this->loaderWithBadTemplate());

$this->assertCount(1, $registry->getTools());
$this->assertCount(1, $registry->getResources());
$this->assertCount(1, $registry->getPrompts());
$this->assertCount(0, $registry->getResourceTemplates());
}

#[TestDox('The skipped template is reported at error level with its handler and URI')]
public function testSkippedElementIsLogged(): void
{
$logger = new RecordingLogger();

(new ReflectedElementLoader(
resourceTemplates: [$this->templateData('data://tags')],
logger: $logger,
))->load(new Registry());

$this->assertCount(1, $logger->errors);
$this->assertSame('Failed to register manual template', $logger->errors[0]['message']);
$this->assertSame('data://tags', $logger->errors[0]['context']['uriTemplate']);
$this->assertArrayHasKey('exception', $logger->errors[0]['context']);
}

#[TestDox('A load that skipped an element still counts as loaded, so reads stop retrying it')]
public function testLoadIsNotRetriedAfterSkippingAnElement(): void
{
$logger = new RecordingLogger();
$registry = new Registry(logger: new RecordingLogger(), loader: new ReflectedElementLoader(
resourceTemplates: [$this->templateData('data://tags')],
logger: $logger,
));

$registry->getTools();
$registry->getResourceTemplates();

$this->assertCount(1, $logger->errors);
}

#[TestDox('An unresolvable handler is skipped for every element type')]
public function testUnresolvableHandlerIsSkippedForEveryElementType(): void
{
$missing = 'Mcp\Tests\Unit\Capability\Registry\Loader\NoSuchHandler';

$registry = new Registry(logger: new RecordingLogger(), loader: new ReflectedElementLoader(
tools: [['handler' => $missing, 'name' => 'broken_tool', 'title' => null, 'description' => null, 'annotations' => null, 'icons' => null, 'meta' => null, 'outputSchema' => null]],
resources: [['handler' => $missing, 'uri' => 'config://broken', 'name' => 'broken_resource', 'title' => null, 'description' => null, 'mimeType' => null, 'size' => null, 'annotations' => null, 'icons' => null, 'meta' => null]],
resourceTemplates: [['handler' => $missing, 'uriTemplate' => 'config://{broken}', 'name' => 'broken_template', 'title' => null, 'description' => null, 'mimeType' => null, 'annotations' => null, 'meta' => null]],
prompts: [['handler' => $missing, 'name' => 'broken_prompt', 'title' => null, 'description' => null, 'icons' => null, 'meta' => null]],
));

$this->assertCount(0, $registry->getTools());
$this->assertCount(0, $registry->getResources());
$this->assertCount(0, $registry->getResourceTemplates());
$this->assertCount(0, $registry->getPrompts());
}

private function loaderWithBadTemplate(): ReflectedElementLoader
{
return new ReflectedElementLoader(
tools: [[
'handler' => static fn (): string => 'ok',
'name' => 'greet',
'title' => null,
'description' => null,
'annotations' => null,
'icons' => null,
'meta' => null,
'outputSchema' => null,
]],
resources: [[
'handler' => static fn (): string => 'ok',
'uri' => 'config://app/settings',
'name' => 'app_settings',
'title' => null,
'description' => null,
'mimeType' => null,
'size' => null,
'annotations' => null,
'icons' => null,
'meta' => null,
]],
resourceTemplates: [$this->templateData('data://tags')],
prompts: [[
'handler' => static fn (): string => 'ok',
'name' => 'welcome',
'title' => null,
'description' => null,
'icons' => null,
'meta' => null,
]],
logger: new RecordingLogger(),
);
}

/**
* @return array<string, mixed>
*/
private function templateData(string $uriTemplate): array
{
return [
'handler' => static fn (): string => 'ok',
'uriTemplate' => $uriTemplate,
'name' => 'all_tags',
'title' => null,
'description' => null,
'mimeType' => null,
'annotations' => null,
'meta' => null,
];
}
}

final class RecordingLogger extends AbstractLogger
{
/**
* @var list<array{message: string, context: array<string, mixed>}>
*/
public array $errors = [];

/**
* @param string|\Stringable $message
* @param array<string, mixed> $context
*/
public function log($level, $message, array $context = []): void
{
if (LogLevel::ERROR === $level) {
$this->errors[] = ['message' => (string) $message, 'context' => $context];
}
}
}
20 changes: 19 additions & 1 deletion tests/Unit/Schema/ResourceTemplateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function testConstructorInvalid(): void
$uri = '/list-books';

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid URI template : "/list-books" must be a valid URI template with at least one placeholder.');
$this->expectExceptionMessage('Invalid URI template : "/list-books" 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.');

$resource = new ResourceTemplate(
uriTemplate: $uri,
Expand All @@ -65,6 +65,24 @@ public static function provideValidTemplates(): iterable
yield 'urn-style template' => ['urn:resource:{id}'];
}

#[DataProvider('provideInvalidTemplates')]
public function testConstructorRejectsNonTemplates(string $uriTemplate): void
{
$this->expectException(InvalidArgumentException::class);

new ResourceTemplate(
uriTemplate: $uriTemplate,
name: 'test-template',
);
}

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