Skip to content
Draft
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to `mcp/sdk` will be documented in this file.

0.11.0
------

* [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.
* [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.
* Add `DeferredRegistryInterface`, so the builder loads a custom registry without announcing the load as list changes, and no longer depends on the concrete `Registry`.
* 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.

0.10.0
------

Expand Down
4 changes: 2 additions & 2 deletions docs/run/server-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -506,8 +506,8 @@ $server = Server::builder()
| `setSchemaGenerator()` | schemaGenerator | Replace the generator that builds input schemas from PHP types, see [Schema generation](../servers/schemas.md) |
| `setSchemaValidator()` | schemaValidator | Replace the validator for tool arguments and structured output, see [Schema generation](../servers/schemas.md) |
| `setReferenceHandler()` | referenceHandler | Replace the class that calls the handlers of tools, resources and prompts |
| `setRegistry()` | registry | Use your own registry. It is loaded when the server is built |
| `setLazyLoading()` | lazyLoading? | Load elements on the first registry read (default) or, with `false`, when the server is built |
| `setRegistry()` | registry | Use your own registry. It is loaded when the server is built, unless you call `setLazyLoading()` |
| `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 |
| `addLoader()` | loader | Add a custom loader that registers elements |
| `addLoaders()` | loaders | Add several custom loaders |
| `addRequestHandler()` | handler | Prepend a single custom request handler |
Expand Down
75 changes: 15 additions & 60 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use Mcp\Schema\Tool;
use Mcp\Schema\ToolAnnotations;
use Mcp\Server\Capability\Completion\ProviderInterface;
use Mcp\Server\Capability\DeferredRegistryInterface;
use Mcp\Server\Capability\Discovery\CachedDiscoverer;
use Mcp\Server\Capability\Discovery\Discoverer;
use Mcp\Server\Capability\Discovery\DiscovererInterface;
Expand All @@ -34,6 +35,7 @@
use Mcp\Server\Capability\Registry;
use Mcp\Server\Capability\Registry\Container;
use Mcp\Server\Capability\Registry\ElementReference;
use Mcp\Server\Capability\Registry\LazyRegistry;
use Mcp\Server\Capability\Registry\Loader\ChainLoader;
use Mcp\Server\Capability\Registry\Loader\DiscoveryLoader;
use Mcp\Server\Capability\Registry\Loader\ExplicitElementLoader;
Expand Down Expand Up @@ -276,7 +278,7 @@ final class Builder

private bool $hasCustomRegistry = false;

private bool $lazyLoading = true;
private ?bool $lazyLoading = null;

private bool $headerValidation = true;

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

return new StatelessProtocol(
Expand Down Expand Up @@ -1160,35 +1163,31 @@ private function resolve(): array

$chainLoader = new ChainLoader($loaders);

if ($this->hasCustomRegistry) {
// Builder can't inject the loader into an already-constructed instance, so load it eagerly.
// Via loadFrom(), which suppresses the change events the load would otherwise dispatch.
if ($this->hasCustomRegistry && true === $this->lazyLoading) {
$registry = new LazyRegistry($this->registry, $chainLoader);
} elseif ($this->hasCustomRegistry) {
$registry = $this->registry;
if ($registry instanceof Registry) {
if ($registry instanceof DeferredRegistryInterface) {
$registry->loadFrom($chainLoader);
} else {
$chainLoader->load($registry);
}
$eagerlyLoaded = true;
} else {
$registry = new Registry($eventDispatcher, $logger, loader: $chainLoader);
if (!$this->lazyLoading) {
if (false === $this->lazyLoading) {
$registry->load();
}
$eagerlyLoaded = !$this->lazyLoading;
}

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

$capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded);

// Extensions enabled via enableExtension() are folded into caller-supplied
// capabilities too, so setCapabilities() does not silently drop them.
if (null !== $this->serverCapabilities && [] !== $this->extensions) {
$capabilities = $capabilities->withExtensions($this->extensions);
}
$capabilities = null === $this->serverCapabilities
? (new RegistryCapabilities($registry, null !== $this->notificationBus, $this->extensions))->detect(...)
: ([] !== $this->extensions ? $this->serverCapabilities->withExtensions($this->extensions) : $this->serverCapabilities);

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

/**
* When loaded, capabilities are read from the registry. When deferred, reading it would force
* the load, so they are advertised from the configured sources instead — opaque sources (custom
* loaders, discovery) advertise all kinds, and over-advertising is harmless per MCP semantics.
*/
private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLoaded): ServerCapabilities
{
// Only the bus delivers list changes, and only if the registry publishes to it.
$listChanged = null !== $this->notificationBus && $registry->publishesListChanges();

if ($eagerlyLoaded) {
$hasResources = $registry->hasResources() || $registry->hasResourceTemplates();

return new ServerCapabilities(
tools: $registry->hasTools(),
toolsListChanged: $listChanged,
resources: $hasResources,
resourcesSubscribe: $hasResources,
resourcesListChanged: $listChanged,
prompts: $registry->hasPrompts(),
promptsListChanged: $listChanged,
logging: true,
completions: true,
extensions: $this->extensions ?: null,
);
}

$hasOpaqueSources = [] !== $this->loaders || null !== $this->discoveryBasePath;
$hasResources = [] !== $this->resources || [] !== $this->explicitResources || [] !== $this->resourceTemplates || [] !== $this->explicitResourceTemplates || $hasOpaqueSources;

return new ServerCapabilities(
tools: [] !== $this->tools || [] !== $this->explicitTools || $hasOpaqueSources,
toolsListChanged: $listChanged,
resources: $hasResources,
resourcesSubscribe: $hasResources,
resourcesListChanged: $listChanged,
prompts: [] !== $this->prompts || [] !== $this->explicitPrompts || $hasOpaqueSources,
promptsListChanged: $listChanged,
logging: true,
completions: true,
extensions: $this->extensions ?: null,
);
}

private function createDiscoverer(LoggerInterface $logger): DiscovererInterface
{
$discoverer = new Discoverer($logger, null, $this->schemaGenerator);
Expand Down
35 changes: 35 additions & 0 deletions src/Server/Capability/DeferredRegistryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?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\Server\Capability;

use Mcp\Server\Capability\Registry\Loader\LoaderInterface;

/**
* A registry that takes part in its own load lifecycle.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface DeferredRegistryInterface
{
/**
* Runs a pending load, if there is one. A failed load stays pending.
*/
public function load(): void;

public function isLoaded(): bool;

/**
* Runs $loader against this registry without announcing its writes as list changes, since they
* describe the registry filling up rather than changing.
*/
public function loadFrom(LoaderInterface $loader): void;
}
54 changes: 54 additions & 0 deletions src/Server/Capability/ElementCatalogInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?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\Server\Capability;

use Mcp\Schema\Page;
use Mcp\Schema\Prompt;
use Mcp\Schema\ResourceDefinition;
use Mcp\Schema\ResourceTemplate;
use Mcp\Schema\Tool;

/**
* Lists the elements a server offers.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface ElementCatalogInterface
{
/**
* Gets all registered tools.
*
* @return Page<Tool>
*/
public function getTools(?int $limit = null, ?string $cursor = null): Page;

/**
* Gets all registered resources.
*
* @return Page<ResourceDefinition>
*/
public function getResources(?int $limit = null, ?string $cursor = null): Page;

/**
* Gets all registered resource templates.
*
* @return Page<ResourceTemplate>
*/
public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page;

/**
* Gets all registered prompts.
*
* @return Page<Prompt>
*/
public function getPrompts(?int $limit = null, ?string $cursor = null): Page;
}
51 changes: 51 additions & 0 deletions src/Server/Capability/ElementResolverInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?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\Server\Capability;

use Mcp\Server\Capability\Registry\PromptReference;
use Mcp\Server\Capability\Registry\ResourceReference;
use Mcp\Server\Capability\Registry\ResourceTemplateReference;
use Mcp\Server\Capability\Registry\ToolReference;
use Mcp\Server\Exception\ConfigurationException;
use Mcp\Server\Exception\PromptNotFoundException;
use Mcp\Server\Exception\ResourceNotFoundException;
use Mcp\Server\Exception\ToolNotFoundException;

/**
* Finds a single element by its name or URI, without needing the whole catalog.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface ElementResolverInterface
{
/**
* Gets a tool reference by name.
*
* @throws ToolNotFoundException
* @throws ConfigurationException when the elements fail to load
*/
public function getTool(string $name): ToolReference;

/**
* Gets a resource reference by URI (includes template matching if enabled).
*
* @throws ResourceNotFoundException
*/
public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference;

/**
* Gets a prompt reference by name.
*
* @throws PromptNotFoundException
*/
public function getPrompt(string $name): PromptReference;
}
Loading
Loading