Skip to content

Commit 176b86a

Browse files
committed
[Server][Capability] Let setLazyLoading() defer loading into any supplied registry
Refs #495
1 parent 46648bd commit 176b86a

16 files changed

Lines changed: 795 additions & 93 deletions

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ All notable changes to `mcp/sdk` will be documented in this file.
55
0.11.0
66
------
77

8+
* [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.
9+
* [BC Break] Detect the server capabilities when a request asks for them, so `initialize`, `server/discover` and `subscriptions/listen` run a pending load. The server advertises what the registry holds, and no longer every kind for a custom loader or discovery that was not loaded yet. While a load is still running, it advertises all kinds. `Configuration::$capabilities` is replaced by `Configuration::capabilities()`, and the constructor also takes a closure.
10+
* Add `DeferredRegistryInterface`, so the builder loads a custom registry without announcing the load as list changes, and no longer depends on the concrete `Registry`.
811
* 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.
912

1013
0.10.0

‎docs/run/server-builder.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -506,8 +506,8 @@ $server = Server::builder()
506506
| `setSchemaGenerator()` | schemaGenerator | Replace the generator that builds input schemas from PHP types, see [Schema generation](../servers/schemas.md) |
507507
| `setSchemaValidator()` | schemaValidator | Replace the validator for tool arguments and structured output, see [Schema generation](../servers/schemas.md) |
508508
| `setReferenceHandler()` | referenceHandler | Replace the class that calls the handlers of tools, resources and prompts |
509-
| `setRegistry()` | registry | Use your own registry. It is loaded when the server is built |
510-
| `setLazyLoading()` | lazyLoading? | Load elements on the first registry read (default) or, with `false`, when the server is built |
509+
| `setRegistry()` | registry | Use your own registry. It is loaded when the server is built, unless you call `setLazyLoading()` |
510+
| `setLazyLoading()` | lazyLoading? | Load elements on the first registry read, which is at the latest when a client asks for the capabilities (default without `setRegistry()`), or, with `false`, when the server is built. Your own registry stays empty until the server first reads it |
511511
| `addLoader()` | loader | Add a custom loader that registers elements |
512512
| `addLoaders()` | loaders | Add several custom loaders |
513513
| `addRequestHandler()` | handler | Prepend a single custom request handler |

‎src/Server/Builder.php‎

Lines changed: 15 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use Mcp\Schema\Tool;
2727
use Mcp\Schema\ToolAnnotations;
2828
use Mcp\Server\Capability\Completion\ProviderInterface;
29+
use Mcp\Server\Capability\DeferredRegistryInterface;
2930
use Mcp\Server\Capability\Discovery\CachedDiscoverer;
3031
use Mcp\Server\Capability\Discovery\Discoverer;
3132
use Mcp\Server\Capability\Discovery\DiscovererInterface;
@@ -34,6 +35,7 @@
3435
use Mcp\Server\Capability\Registry;
3536
use Mcp\Server\Capability\Registry\Container;
3637
use Mcp\Server\Capability\Registry\ElementReference;
38+
use Mcp\Server\Capability\Registry\LazyRegistry;
3739
use Mcp\Server\Capability\Registry\Loader\ChainLoader;
3840
use Mcp\Server\Capability\Registry\Loader\DiscoveryLoader;
3941
use Mcp\Server\Capability\Registry\Loader\ExplicitElementLoader;
@@ -276,7 +278,7 @@ final class Builder
276278

277279
private bool $hasCustomRegistry = false;
278280

279-
private bool $lazyLoading = true;
281+
private ?bool $lazyLoading = null;
280282

281283
private bool $headerValidation = true;
282284

@@ -596,8 +598,8 @@ public function setRegistry(RegistryInterface $registry): self
596598
*
597599
* Lazy (the default) defers loading to the first registry read so a persistent runtime does not
598600
* freeze the registry to a source not yet ready at build time. Disable to load eagerly at build.
599-
* A registry supplied via setRegistry() is always loaded eagerly; its own constructor loader,
600-
* if it has one, still runs on the first read.
601+
* A registry supplied via setRegistry() is loaded at build unless lazy loading is asked for explicitly.
602+
* Then it stays unloaded until the server first reads it, also for code holding the same instance.
601603
*/
602604
public function setLazyLoading(bool $lazyLoading = true): self
603605
{
@@ -1079,7 +1081,8 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202
10791081
$discovery = $parts['configuration'];
10801082
// Without a bus, `subscriptions/listen` delivers nothing; `resources/subscribe` only exists on the handshake.
10811083
if (null === $this->notificationBus && null === $this->serverCapabilities) {
1082-
$discovery = new Configuration($discovery->serverInfo, $discovery->capabilities->withoutResourcesSubscribe(), $discovery->paginationLimit, $discovery->instructions, $discovery->protocolVersion);
1084+
$configuration = $discovery;
1085+
$discovery = new Configuration($discovery->serverInfo, static fn (): ServerCapabilities => $configuration->capabilities()->withoutResourcesSubscribe(), $discovery->paginationLimit, $discovery->instructions, $discovery->protocolVersion);
10831086
}
10841087

10851088
return new StatelessProtocol(
@@ -1160,35 +1163,31 @@ private function resolve(): array
11601163

11611164
$chainLoader = new ChainLoader($loaders);
11621165

1163-
if ($this->hasCustomRegistry) {
1164-
// Builder can't inject the loader into an already-constructed instance, so load it eagerly.
1165-
// Via loadFrom(), which suppresses the change events the load would otherwise dispatch.
1166+
if ($this->hasCustomRegistry && true === $this->lazyLoading) {
1167+
$registry = new LazyRegistry($this->registry, $chainLoader);
1168+
} elseif ($this->hasCustomRegistry) {
11661169
$registry = $this->registry;
1167-
if ($registry instanceof Registry) {
1170+
if ($registry instanceof DeferredRegistryInterface) {
11681171
$registry->loadFrom($chainLoader);
11691172
} else {
11701173
$chainLoader->load($registry);
11711174
}
1172-
$eagerlyLoaded = true;
11731175
} else {
11741176
$registry = new Registry($eventDispatcher, $logger, loader: $chainLoader);
1175-
if (!$this->lazyLoading) {
1177+
if (false === $this->lazyLoading) {
11761178
$registry->load();
11771179
}
1178-
$eagerlyLoaded = !$this->lazyLoading;
11791180
}
11801181

11811182
$messageFactory = MessageFactory::make(additional: $this->extensionMessages);
11821183
// `server/discover` exists in the modern era only.
11831184
$modernMessageFactory = MessageFactory::make(additional: [...$this->extensionMessages, DiscoverRequest::class]);
11841185

1185-
$capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded);
1186-
11871186
// Extensions enabled via enableExtension() are folded into caller-supplied
11881187
// capabilities too, so setCapabilities() does not silently drop them.
1189-
if (null !== $this->serverCapabilities && [] !== $this->extensions) {
1190-
$capabilities = $capabilities->withExtensions($this->extensions);
1191-
}
1188+
$capabilities = null === $this->serverCapabilities
1189+
? (new RegistryCapabilities($registry, null !== $this->notificationBus, $this->extensions))->detect(...)
1190+
: ([] !== $this->extensions ? $this->serverCapabilities->withExtensions($this->extensions) : $this->serverCapabilities);
11921191

11931192
if (null !== $this->protocolVersion && $this->protocolVersion->isModern()) {
11941193
$logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, negotiating the handshake revisions instead.', [
@@ -1234,50 +1233,6 @@ private function resolve(): array
12341233
];
12351234
}
12361235

1237-
/**
1238-
* When loaded, capabilities are read from the registry. When deferred, reading it would force
1239-
* the load, so they are advertised from the configured sources instead — opaque sources (custom
1240-
* loaders, discovery) advertise all kinds, and over-advertising is harmless per MCP semantics.
1241-
*/
1242-
private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded): ServerCapabilities
1243-
{
1244-
// Only the bus delivers list changes, and only if the registry publishes to it.
1245-
$listChanged = null !== $this->notificationBus && $registry->publishesListChanges();
1246-
1247-
if ($eagerlyLoaded) {
1248-
$hasResources = $registry->hasResources() || $registry->hasResourceTemplates();
1249-
1250-
return new ServerCapabilities(
1251-
tools: $registry->hasTools(),
1252-
toolsListChanged: $listChanged,
1253-
resources: $hasResources,
1254-
resourcesSubscribe: $hasResources,
1255-
resourcesListChanged: $listChanged,
1256-
prompts: $registry->hasPrompts(),
1257-
promptsListChanged: $listChanged,
1258-
logging: true,
1259-
completions: true,
1260-
extensions: $this->extensions ?: null,
1261-
);
1262-
}
1263-
1264-
$hasOpaqueSources = [] !== $this->loaders || null !== $this->discoveryBasePath;
1265-
$hasResources = [] !== $this->resources || [] !== $this->explicitResources || [] !== $this->resourceTemplates || [] !== $this->explicitResourceTemplates || $hasOpaqueSources;
1266-
1267-
return new ServerCapabilities(
1268-
tools: [] !== $this->tools || [] !== $this->explicitTools || $hasOpaqueSources,
1269-
toolsListChanged: $listChanged,
1270-
resources: $hasResources,
1271-
resourcesSubscribe: $hasResources,
1272-
resourcesListChanged: $listChanged,
1273-
prompts: [] !== $this->prompts || [] !== $this->explicitPrompts || $hasOpaqueSources,
1274-
promptsListChanged: $listChanged,
1275-
logging: true,
1276-
completions: true,
1277-
extensions: $this->extensions ?: null,
1278-
);
1279-
}
1280-
12811236
private function createDiscoverer(LoggerInterface $logger): DiscovererInterface
12821237
{
12831238
$discoverer = new Discoverer($logger, null, $this->schemaGenerator);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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\Server\Capability;
13+
14+
use Mcp\Server\Capability\Registry\Loader\LoaderInterface;
15+
16+
/**
17+
* A registry that takes part in its own load lifecycle.
18+
*
19+
* @author Christopher Hertel <mail@christopher-hertel.de>
20+
*/
21+
interface DeferredRegistryInterface
22+
{
23+
/**
24+
* Runs a pending load, if there is one. A failed load stays pending.
25+
*/
26+
public function load(): void;
27+
28+
public function isLoaded(): bool;
29+
30+
/**
31+
* Runs $loader against this registry without announcing its writes as list changes, since they
32+
* describe the registry filling up rather than changing.
33+
*/
34+
public function loadFrom(LoaderInterface $loader): void;
35+
}

‎src/Server/Capability/Registry.php‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
*
4141
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
4242
*/
43-
final class Registry implements RegistryInterface
43+
final class Registry implements RegistryInterface, DeferredRegistryInterface
4444
{
4545
/**
4646
* @var array<string, ToolReference>
@@ -64,7 +64,8 @@ final class Registry implements RegistryInterface
6464

6565
private bool $loaded = false;
6666

67-
private bool $loading = false;
67+
/** Loads running right now, counted because loads from other fibers may interleave */
68+
private int $loading = 0;
6869

6970
public function __construct(
7071
private readonly ?EventDispatcherInterface $eventDispatcher = null,
@@ -85,7 +86,7 @@ public function __construct(
8586
*/
8687
public function load(): void
8788
{
88-
if ($this->loaded || $this->loading || null === $this->loader) {
89+
if ($this->loaded || 0 !== $this->loading || null === $this->loader) {
8990
return;
9091
}
9192

@@ -94,24 +95,26 @@ public function load(): void
9495
$this->loaded = true;
9596
}
9697

98+
public function isLoaded(): bool
99+
{
100+
return $this->loaded || null === $this->loader;
101+
}
102+
97103
/**
98104
* Runs $loader with the change events its registrations would dispatch suppressed, since they
99105
* describe the registry filling up rather than changing.
100106
*
101-
* Re-entrant-safe, and failure propagates. Does not mark the registry loaded: $loader is the
102-
* caller's, and the one the constructor took is still owed its run.
107+
* Always runs $loader, even while another load is running, and failure propagates. Does not
108+
* mark the registry loaded: $loader is the caller's, and the one the constructor took is still
109+
* owed its run.
103110
*/
104111
public function loadFrom(LoaderInterface $loader): void
105112
{
106-
if ($this->loading) {
107-
return;
108-
}
109-
110-
$this->loading = true;
113+
++$this->loading;
111114
try {
112115
$loader->load($this);
113116
} finally {
114-
$this->loading = false;
117+
--$this->loading;
115118
}
116119
}
117120

@@ -418,7 +421,7 @@ public function getPrompt(string $name): PromptReference
418421
*/
419422
private function dispatch(object $event): void
420423
{
421-
if ($this->loading) {
424+
if (0 !== $this->loading) {
422425
return;
423426
}
424427

0 commit comments

Comments
 (0)