Skip to content

Commit 2fcac14

Browse files
committed
[Capability][Server] Add element sources and isolate loading failures
Refs #495
1 parent fa5a2d2 commit 2fcac14

19 files changed

Lines changed: 753 additions & 33 deletions

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ All notable changes to `mcp/sdk` will be documented in this file.
2929
* [BC Break] `ReferenceHandler` resolves a string handler that is not an invokable class from the container, so a loader can register a service id as handler. The container goes first, so a function handler no longer runs if the container has an entry of the same name.
3030
* [BC Break] `Builder::setLazyLoading(true)` now also defers loading into a registry passed to `setRegistry()`, which any `RegistryInterface` supports. A registry you read directly stays empty until the server first reads it; without the call it is still loaded at build.
3131
* Add `DeferredRegistryInterface`, so the builder loads a custom registry without announcing the load as list changes, and no longer depends on the concrete `Registry`.
32+
* Add `ElementSourceInterface`, `ElementCollection` and `Builder::addSource()`. A source returns its elements as a value and declares its element kinds, so the server announces only those capabilities.
33+
* [BC Break] An exception from an element loader or discovery no longer aborts loading: `ChainLoader` logs it as an error, runs the remaining loaders and throws a `PartialLoadException`. The registry serves what loaded and retries on the next read, and `Builder::build()` no longer throws for such a failure.
34+
* Deprecate `DiscoveryLoader`, use `SourceLoader` over a `DiscoverySource` with `onlyMissing: true` instead.
3235
* Split `RegistryInterface` by role into `ElementCatalogInterface`, `ElementResolverInterface` and `ElementWriterInterface`, which it extends. Request handlers depend on the role they use, so a handler can run against a catalog or resolver alone.
3336
* Add `Psr6SessionStore` and `Psr6NotificationBus`, which work like `Psr16SessionStore` and `Psr16NotificationBus` over a PSR-6 cache pool. `Builder::setDiscovery()` also accepts a PSR-6 pool as `$cache`.
3437
* Fix a nullable tool parameter with a default (e.g. `?string $category = 'books'`) receiving the default when the client explicitly sends `null`: `ReferenceHandler` now passes the `null` through.

‎docs/run/server-builder.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,7 @@ $server = Server::builder()
489489
| `setReferenceHandler()` | referenceHandler | Replace the class that calls the handlers of tools, resources and prompts |
490490
| `setRegistry()` | registry | Use your own registry. It is loaded when the server is built, unless you call `setLazyLoading()` |
491491
| `setLazyLoading()` | lazyLoading? | Load elements on the first registry read (default without `setRegistry()`) or, with `false`, when the server is built. Your own registry stays empty until the server first reads it |
492+
| `addSource()` | source | Add an element source, see [Element sources](../servers/registration.md#element-sources) |
492493
| `addLoader()` | loader | Add a custom loader that registers elements |
493494
| `addLoaders()` | loaders | Add several custom loaders |
494495
| `addRequestHandler()` | handler | Prepend a single custom request handler |

‎docs/servers/registration.md‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,63 @@ Use `add()` when the metadata cannot be inferred from a handler class via reflec
241241
prefer `addTool/addResource/addResourceTemplate/addPrompt`, which can derive metadata from the handler's signature and
242242
docblock.
243243

244+
## Element sources
245+
246+
Your tools, resources or prompts may come from a database or another service. An element source reads them and
247+
returns them as a collection:
248+
249+
```php
250+
use Mcp\Capability\Registry\Source\ElementCollection;
251+
use Mcp\Capability\Registry\Source\ElementKind;
252+
use Mcp\Capability\Registry\Source\ElementSourceInterface;
253+
use Mcp\Capability\Registry\ToolReference;
254+
use Mcp\Schema\Tool;
255+
256+
final class ProductToolSource implements ElementSourceInterface
257+
{
258+
public function __construct(private readonly ProductRepository $products)
259+
{
260+
}
261+
262+
public function provide(): ElementCollection
263+
{
264+
$tools = [];
265+
foreach ($this->products->findPublished() as $product) {
266+
$tool = new Tool('product_'.$product->id, $product->title, $product->inputSchema, $product->description, null);
267+
$tools[] = new ToolReference($tool, [ProductTools::class, 'run']);
268+
}
269+
270+
return new ElementCollection(tools: $tools);
271+
}
272+
273+
public function kinds(): array
274+
{
275+
return [ElementKind::Tool];
276+
}
277+
}
278+
```
279+
280+
Register the source on the builder:
281+
282+
```php
283+
$server = Server::builder()
284+
->setServerInfo('Product Server', '1.0.0')
285+
->addSource(new ProductToolSource($products))
286+
->build();
287+
```
288+
289+
The server calls `kinds()` to announce its capabilities and calls `provide()` when it loads the elements.
290+
291+
An element from a source replaces one with the same identifier from a custom loader. Elements registered on the
292+
builder replace those of a source, and discovery never replaces an existing element.
293+
294+
If a source throws an exception, the server logs it as an error and serves the other elements. The server calls the
295+
source again on each read until it succeeds. Until then, it doesn't call the sources that already succeeded. The registry
296+
doesn't announce elements that arrive on a later attempt, so clients see them the next time they list.
297+
298+
A custom loader can write elements before it fails. While a custom loader fails, the server also runs everything that
299+
comes after it on each read, including the sources.
300+
244301
## Hybrid Approach
245302

246303
Combine both methods for maximum flexibility:

‎src/Capability/DeferredRegistryInterface.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
interface DeferredRegistryInterface
2222
{
2323
/**
24-
* Runs a pending load, if there is one. A failed load stays pending.
24+
* Runs a pending load, if there is one. A load that fails or is incomplete stays pending.
2525
*/
2626
public function load(): void;
2727

‎src/Capability/Registry.php‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Mcp\Event\ResourceTemplateListChangedEvent;
2323
use Mcp\Event\ToolListChangedEvent;
2424
use Mcp\Exception\InvalidCursorException;
25+
use Mcp\Exception\PartialLoadException;
2526
use Mcp\Exception\PromptNotFoundException;
2627
use Mcp\Exception\ResourceNotFoundException;
2728
use Mcp\Exception\ToolNotFoundException;
@@ -89,7 +90,11 @@ public function load(): void
8990
return;
9091
}
9192

92-
$this->loadFrom($this->loader);
93+
try {
94+
$this->loadFrom($this->loader);
95+
} catch (PartialLoadException) {
96+
return;
97+
}
9398

9499
$this->loaded = true;
95100
}

‎src/Capability/Registry/LazyRegistry.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Mcp\Capability\DeferredRegistryInterface;
1515
use Mcp\Capability\Registry\Loader\LoaderInterface;
1616
use Mcp\Capability\RegistryInterface;
17+
use Mcp\Exception\PartialLoadException;
1718
use Mcp\Schema\Page;
1819
use Mcp\Schema\Prompt;
1920
use Mcp\Schema\ResourceDefinition;
@@ -48,6 +49,8 @@ public function load(): void
4849
try {
4950
$this->loadFrom($this->loader);
5051
$this->loaded = true;
52+
} catch (PartialLoadException) {
53+
// what loaded is served, and the load stays pending
5154
} finally {
5255
$this->loading = false;
5356
}

‎src/Capability/Registry/Loader/ChainLoader.php‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,27 +12,67 @@
1212
namespace Mcp\Capability\Registry\Loader;
1313

1414
use Mcp\Capability\RegistryInterface;
15+
use Mcp\Exception\PartialLoadException;
16+
use Psr\Log\LoggerInterface;
17+
use Psr\Log\NullLogger;
1518

1619
/**
1720
* Composes multiple loaders into a single one. Child loaders run in the order they were given;
18-
* for conflicting keys, the loader that runs later wins (last-write-wins).
21+
* for conflicting keys, the loader that runs later wins (last-write-wins). A failing loader is logged
22+
* and skipped, and the chain reports the incomplete load once the others ran.
23+
*
24+
* The next load of that registry only retries what failed. A loader that recovers, or that may have
25+
* written before it failed, is followed by all loaders after it, so that last-write-wins gives the
26+
* result of an uninterrupted run. A {@see SourceLoader} fails before it writes.
1927
*
2028
* @author Christopher Hertel <mail@christopher-hertel.de>
2129
*/
2230
final class ChainLoader implements LoaderInterface
2331
{
32+
/**
33+
* @var \WeakMap<RegistryInterface, array<int, true>>
34+
*/
35+
private \WeakMap $failed;
36+
2437
/**
2538
* @param LoaderInterface[] $loaders
2639
*/
2740
public function __construct(
2841
private readonly array $loaders,
42+
private readonly LoggerInterface $logger = new NullLogger(),
2943
) {
44+
$this->failed = new \WeakMap();
3045
}
3146

3247
public function load(RegistryInterface $registry): void
3348
{
34-
foreach ($this->loaders as $loader) {
35-
$loader->load($registry);
49+
$retry = $this->failed[$registry] ?? null;
50+
$runRemaining = null === $retry;
51+
$failed = [];
52+
53+
foreach (array_values($this->loaders) as $position => $loader) {
54+
if (!$runRemaining && !isset($retry[$position])) {
55+
continue;
56+
}
57+
58+
try {
59+
$loader->load($registry);
60+
$runRemaining = true;
61+
} catch (\Throwable $e) { // loading runs inside request handling, so nothing a source throws may end it
62+
$failed[$position] = true;
63+
$runRemaining = $runRemaining || !$loader instanceof SourceLoader;
64+
$this->logger->error(\sprintf('Loading elements from "%s" failed: %s', $loader::class, $e->getMessage()), ['exception' => $e]);
65+
}
3666
}
67+
68+
if ([] === $failed) {
69+
unset($this->failed[$registry]);
70+
71+
return;
72+
}
73+
74+
$this->failed[$registry] = $failed;
75+
76+
throw new PartialLoadException('Some element loaders failed.');
3777
}
3878
}

‎src/Capability/Registry/Loader/DiscoveryLoader.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,16 @@
1313

1414
use Mcp\Capability\Discovery\DiscovererInterface;
1515
use Mcp\Capability\Discovery\DiscoveryState;
16+
use Mcp\Capability\Registry\Source\DiscoverySource;
1617
use Mcp\Capability\RegistryInterface;
1718
use Psr\Log\LoggerInterface;
1819
use Psr\Log\NullLogger;
1920

2021
/**
2122
* @internal
2223
*
24+
* @deprecated use {@see SourceLoader} over a {@see DiscoverySource} with `onlyMissing: true`
25+
*
2326
* @author Antoine Bluchet <soyuka@gmail.com>
2427
*/
2528
final class DiscoveryLoader implements LoaderInterface
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Capability\Registry\Loader;
13+
14+
use Mcp\Capability\Registry\Source\ElementSourceInterface;
15+
use Mcp\Capability\RegistryInterface;
16+
use Psr\Log\LoggerInterface;
17+
use Psr\Log\NullLogger;
18+
19+
/**
20+
* Writes the elements of a source into a registry.
21+
*
22+
* @author Christopher Hertel <mail@christopher-hertel.de>
23+
*/
24+
final class SourceLoader implements LoaderInterface
25+
{
26+
/**
27+
* @param bool $onlyMissing keep what the registry already holds under the same name or URI
28+
*/
29+
public function __construct(
30+
private readonly ElementSourceInterface $source,
31+
private readonly bool $onlyMissing = false,
32+
private readonly LoggerInterface $logger = new NullLogger(),
33+
) {
34+
}
35+
36+
public function load(RegistryInterface $registry): void
37+
{
38+
$collection = $this->source->provide();
39+
40+
foreach ($collection->getTools() as $reference) {
41+
if ($this->mayWrite('tool', $reference->tool->name, $registry->hasTool(...))) {
42+
$registry->registerTool($reference->tool, $reference->handler);
43+
}
44+
}
45+
46+
foreach ($collection->getResources() as $reference) {
47+
if ($this->mayWrite('resource', $reference->resource->uri, $registry->hasResource(...))) {
48+
$registry->registerResource($reference->resource, $reference->handler);
49+
}
50+
}
51+
52+
foreach ($collection->getResourceTemplates() as $reference) {
53+
if ($this->mayWrite('resource template', $reference->resourceTemplate->uriTemplate, $registry->hasResourceTemplate(...))) {
54+
$registry->registerResourceTemplate($reference->resourceTemplate, $reference->handler, $reference->completionProviders);
55+
}
56+
}
57+
58+
foreach ($collection->getPrompts() as $reference) {
59+
if ($this->mayWrite('prompt', $reference->prompt->name, $registry->hasPrompt(...))) {
60+
$registry->registerPrompt($reference->prompt, $reference->handler, $reference->completionProviders);
61+
}
62+
}
63+
}
64+
65+
/**
66+
* @param callable(string): bool $exists
67+
*/
68+
private function mayWrite(string $kind, string $key, callable $exists): bool
69+
{
70+
if (!$this->onlyMissing || !$exists($key)) {
71+
return true;
72+
}
73+
74+
$this->logger->debug(\sprintf('Ignoring %s "%s" from an element source: the registry already holds it.', $kind, $key));
75+
76+
return false;
77+
}
78+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Capability\Registry\Source;
13+
14+
use Mcp\Capability\Discovery\DiscovererInterface;
15+
16+
/**
17+
* Provides the elements a {@see DiscovererInterface} finds in a directory tree.
18+
*
19+
* @author Christopher Hertel <mail@christopher-hertel.de>
20+
*/
21+
final class DiscoverySource implements ElementSourceInterface
22+
{
23+
/**
24+
* @param string[] $scanDirs
25+
* @param string[] $excludeDirs
26+
* @param string[] $namePatterns
27+
*/
28+
public function __construct(
29+
private readonly string $basePath,
30+
private readonly array $scanDirs,
31+
private readonly array $excludeDirs,
32+
private readonly DiscovererInterface $discoverer,
33+
private readonly array $namePatterns = DiscovererInterface::DEFAULT_NAME_PATERNS,
34+
) {
35+
}
36+
37+
public function provide(): ElementCollection
38+
{
39+
$state = $this->discoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs, $this->namePatterns);
40+
41+
return new ElementCollection($state->getTools(), $state->getResources(), $state->getResourceTemplates(), $state->getPrompts());
42+
}
43+
44+
public function kinds(): array
45+
{
46+
return ElementKind::cases();
47+
}
48+
}

0 commit comments

Comments
 (0)