php-sdk/src/Server/Builder.php
Mateu Aguiló Bosch fb2c8c2ee4
[Schema][Server] Add top-level title to Tool (#288)
* feat(schema): add top-level title to Tool

Mirror PR #278's Prompt change for Tool:

- Add optional ?string $title to Mcp\Schema\Tool between $name
  and $inputSchema. fromArray() now reads $data['title'];
  jsonSerialize() emits 'title' right after 'name'. The
  @phpstan-type ToolData gains title?: string.
- Add ?string $title to the #[McpTool] attribute between $name
  and $description.
- Note ToolAnnotations::$title as deprecated-for-display in
  favor of Tool::$title per MCP spec 2025-06-18 (PHPDoc only,
  field retained for BC).

Phase 1 of plan 01--tool-title-spec-compliance. Later phases
update Discoverer, Builder::addTool(), ArrayLoader, and the
conformance fixture to match the new signature.

* feat(server): thread title through discovery

- Discoverer::processMethod() now propagates McpTool::$title
  into every discovered Tool via named arguments.
- Builder::addTool() accepts ?string $title between $name and
  $description. [BC Break] positional callers passing the old
  $description as 3rd positional argument must switch to named
  arguments, matching the precedent set for addPrompt() in
  0.5.0.
- tests/Conformance/server.php migrated to named-argument form
  for every addTool() call.

Phase 2 of plan 01--tool-title-spec-compliance.

* feat(loader): forward title in ArrayLoader

ArrayLoader now reads 'title' from each tool config entry and
passes it into the Tool constructor, completing the wiring
from Builder::addTool() through array-driven registration.

The $tools @param array-shape typedef gains title: ?string
between name and description.

Phase 3 of plan 01--tool-title-spec-compliance.

* test(schema): cover Tool.title end-to-end

- New tests/Unit/Schema/ToolTest.php: constructor round-trip,
  jsonSerialize key-order when title set vs null, fromArray
  tolerance.
- New tests/Unit/Capability/Discovery/DiscovererToolTitleTest
  plus a TitleFixture class verifying McpTool(title: ...)
  propagates through discovery into the registered Tool.
- New tests/Unit/Capability/Registry/Loader/ArrayLoader...
  test verifying array-driven title propagation.
- Extend McpToolTest for title named-arg and default-null.
- Extend BuilderTest with addTool(title: 't') propagation.
- Update tests/Unit/.../RegistryTest, CallToolHandlerTest,
  ListToolsHandlerTest and examples/.../server.php
  new Tool(...) call sites to pass title (named args) so
  they match the new constructor signature.
- CHANGELOG 0.6.0 section: feature bullet + [BC Break]
  note for addTool() signature, mirroring 0.5.0 Prompt
  entries.
- docs/server-builder.md: addTool() row and prose now
  list title? between name and description.

Phase 4 of plan 01--tool-title-spec-compliance. All 730
phpunit tests pass; remaining phpstan warnings are
pre-existing and unrelated to Tool.title.

* refactor(schema): make Tool.title non-BC-breaking

Move `title` to the end of the Tool, Builder::addTool, and McpTool
attribute constructors with a default of null. This preserves
positional-argument compatibility and removes the need for
`title: null` boilerplate at call sites. JSON serialization order
(name, title, inputSchema, ...) is unchanged.

Also tighten the new tests: parameterize ToolTest with data
providers, fold the Discoverer title fixture into the existing
DiscoverableToolHandler, and drop the reflection-heavy Builder
test that overlapped with ArrayLoaderToolTitleTest.

* fix: move title after name

---------

Co-authored-by: DDEV User <nobody@example.com>
2026-04-26 15:37:40 +02:00

604 lines
18 KiB
PHP

<?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;
use Mcp\Capability\Discovery\CachedDiscoverer;
use Mcp\Capability\Discovery\Discoverer;
use Mcp\Capability\Discovery\DiscovererInterface;
use Mcp\Capability\Discovery\SchemaGeneratorInterface;
use Mcp\Capability\Registry;
use Mcp\Capability\Registry\Container;
use Mcp\Capability\Registry\ElementReference;
use Mcp\Capability\Registry\Loader\ArrayLoader;
use Mcp\Capability\Registry\Loader\DiscoveryLoader;
use Mcp\Capability\Registry\Loader\LoaderInterface;
use Mcp\Capability\Registry\ReferenceHandler;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\RegistryInterface;
use Mcp\Exception\InvalidArgumentException;
use Mcp\JsonRpc\MessageFactory;
use Mcp\Schema\Annotations;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Icon;
use Mcp\Schema\Implementation;
use Mcp\Schema\ServerCapabilities;
use Mcp\Schema\ToolAnnotations;
use Mcp\Server;
use Mcp\Server\Handler\Notification\NotificationHandlerInterface;
use Mcp\Server\Handler\Request\RequestHandlerInterface;
use Mcp\Server\Resource\SessionSubscriptionManager;
use Mcp\Server\Resource\SubscriptionManagerInterface;
use Mcp\Server\Session\InMemorySessionStore;
use Mcp\Server\Session\SessionManager;
use Mcp\Server\Session\SessionManagerInterface;
use Mcp\Server\Session\SessionStoreInterface;
use Psr\Container\ContainerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Psr\SimpleCache\CacheInterface;
use Symfony\Component\Finder\Finder;
/**
* @phpstan-import-type Handler from ElementReference
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
final class Builder
{
private ?Implementation $serverInfo = null;
private RegistryInterface $registry;
private ?SubscriptionManagerInterface $subscriptionManager = null;
private ?LoggerInterface $logger = null;
private ?CacheInterface $discoveryCache = null;
private ?EventDispatcherInterface $eventDispatcher = null;
private ?ContainerInterface $container = null;
private ?SchemaGeneratorInterface $schemaGenerator = null;
private ?ReferenceHandlerInterface $referenceHandler = null;
private ?DiscovererInterface $discoverer = null;
private ?SessionManagerInterface $sessionManager = null;
private ?SessionStoreInterface $sessionStore = null;
private int $paginationLimit = 50;
private ?string $instructions = null;
private ?ProtocolVersion $protocolVersion = null;
/**
* @var array<int, RequestHandlerInterface<mixed>>
*/
private array $requestHandlers = [];
/**
* @var array<int, NotificationHandlerInterface>
*/
private array $notificationHandlers = [];
/**
* @var array{
* handler: Handler,
* name: ?string,
* title: ?string,
* description: ?string,
* annotations: ?ToolAnnotations,
* icons: ?Icon[],
* meta: ?array<string, mixed>,
* outputSchema: ?array<string, mixed>,
* }[]
*/
private array $tools = [];
/**
* @var array{
* handler: Handler,
* uri: string,
* name: ?string,
* description: ?string,
* mimeType: ?string,
* size: int|null,
* annotations: ?Annotations,
* icons: ?Icon[],
* meta: ?array<string, mixed>
* }[]
*/
private array $resources = [];
/**
* @var array{
* handler: Handler,
* uriTemplate: string,
* name: ?string,
* description: ?string,
* mimeType: ?string,
* annotations: ?Annotations,
* meta: ?array<string, mixed>
* }[]
*/
private array $resourceTemplates = [];
/**
* @var array{
* handler: Handler,
* name: ?string,
* description: ?string,
* icons: ?Icon[],
* meta: ?array<string, mixed>
* }[]
*/
private array $prompts = [];
private ?string $discoveryBasePath = null;
/**
* @var string[]
*/
private array $discoveryScanDirs = [];
/**
* @var array|string[]
*/
private array $discoveryExcludeDirs = [];
private ?ServerCapabilities $serverCapabilities = null;
/**
* @var LoaderInterface[]
*/
private array $loaders = [];
/**
* Sets the server's identity. Required.
*
* @param ?Icon[] $icons
*/
public function setServerInfo(
string $name,
string $version,
?string $description = null,
?array $icons = null,
?string $websiteUrl = null,
): self {
$this->serverInfo = new Implementation(trim($name), trim($version), $description, $icons, $websiteUrl);
return $this;
}
/**
* Configures the server's pagination limit.
*/
public function setPaginationLimit(int $paginationLimit): self
{
$this->paginationLimit = $paginationLimit;
return $this;
}
/**
* Configures the instructions describing how to use the server and its features.
*
* This can be used by clients to improve the LLM's understanding of available tools, resources,
* etc. It can be thought of like a "hint" to the model. For example, this information MAY
* be added to the system prompt.
*/
public function setInstructions(?string $instructions): self
{
$this->instructions = $instructions;
return $this;
}
/**
* Explicitly set server capabilities. If set, this overrides automatic detection.
*/
public function setCapabilities(ServerCapabilities $serverCapabilities): self
{
$this->serverCapabilities = $serverCapabilities;
return $this;
}
/**
* Register a single custom method handler.
*
* @param RequestHandlerInterface<mixed> $handler
*/
public function addRequestHandler(RequestHandlerInterface $handler): self
{
$this->requestHandlers[] = $handler;
return $this;
}
/**
* Register multiple custom method handlers.
*
* @param iterable<RequestHandlerInterface<mixed>> $handlers
*/
public function addRequestHandlers(iterable $handlers): self
{
foreach ($handlers as $handler) {
$this->requestHandlers[] = $handler;
}
return $this;
}
/**
* Register a single custom notification handler.
*/
public function addNotificationHandler(NotificationHandlerInterface $handler): self
{
$this->notificationHandlers[] = $handler;
return $this;
}
/**
* Register multiple custom notification handlers.
*
* @param iterable<int, NotificationHandlerInterface> $handlers
*/
public function addNotificationHandlers(iterable $handlers): self
{
foreach ($handlers as $handler) {
$this->notificationHandlers[] = $handler;
}
return $this;
}
public function setRegistry(RegistryInterface $registry): self
{
$this->registry = $registry;
return $this;
}
/**
* Provides a PSR-3 logger instance. Defaults to NullLogger.
*/
public function setLogger(LoggerInterface $logger): self
{
$this->logger = $logger;
return $this;
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): self
{
$this->eventDispatcher = $eventDispatcher;
return $this;
}
/**
* Provides a PSR-11 DI container, primarily for resolving user-defined handler classes.
* Defaults to a basic internal container.
*/
public function setContainer(ContainerInterface $container): self
{
$this->container = $container;
return $this;
}
public function setSchemaGenerator(SchemaGeneratorInterface $schemaGenerator): self
{
$this->schemaGenerator = $schemaGenerator;
return $this;
}
public function setReferenceHandler(ReferenceHandlerInterface $referenceHandler): self
{
$this->referenceHandler = $referenceHandler;
return $this;
}
public function setDiscoverer(DiscovererInterface $discoverer): self
{
$this->discoverer = $discoverer;
return $this;
}
public function setResourceSubscriptionManager(SubscriptionManagerInterface $subscriptionManager): self
{
$this->subscriptionManager = $subscriptionManager;
return $this;
}
public function setSession(
?SessionStoreInterface $sessionStore = null,
?SessionManagerInterface $sessionManager = null,
): self {
$this->sessionStore = $sessionStore;
$this->sessionManager = $sessionManager;
if (null !== $sessionManager && null !== $sessionStore) {
throw new InvalidArgumentException('Cannot set both SessionStore and SessionManager. Set only one or the other.');
}
return $this;
}
/**
* @param string[] $scanDirs
* @param string[] $excludeDirs
*/
public function setDiscovery(
string $basePath,
array $scanDirs = ['.', 'src'],
array $excludeDirs = [],
?CacheInterface $cache = null,
): self {
$this->discoveryBasePath = $basePath;
$this->discoveryScanDirs = $scanDirs;
$this->discoveryExcludeDirs = $excludeDirs;
$this->discoveryCache = $cache;
return $this;
}
public function setProtocolVersion(ProtocolVersion $protocolVersion): self
{
$this->protocolVersion = $protocolVersion;
return $this;
}
/**
* Manually registers a tool handler.
*
* @param Handler $handler
* @param ?string $title Optional human-readable title for display in UI
* @param array<string, mixed>|null $inputSchema
* @param ?Icon[] $icons
* @param array<string, mixed>|null $meta
* @param array<string, mixed>|null $outputSchema
*/
public function addTool(
callable|array|string $handler,
?string $name = null,
?string $title = null,
?string $description = null,
?ToolAnnotations $annotations = null,
?array $inputSchema = null,
?array $icons = null,
?array $meta = null,
?array $outputSchema = null,
): self {
$this->tools[] = compact(
'handler',
'name',
'title',
'description',
'annotations',
'inputSchema',
'icons',
'meta',
'outputSchema',
);
return $this;
}
/**
* Manually registers a resource handler.
*
* @param Handler $handler
* @param ?Icon[] $icons
* @param array<string, mixed>|null $meta
*/
public function addResource(
\Closure|array|string $handler,
string $uri,
?string $name = null,
?string $description = null,
?string $mimeType = null,
?int $size = null,
?Annotations $annotations = null,
?array $icons = null,
?array $meta = null,
): self {
$this->resources[] = compact(
'handler',
'uri',
'name',
'description',
'mimeType',
'size',
'annotations',
'icons',
'meta',
);
return $this;
}
/**
* Manually registers a resource template handler.
*
* @param Handler $handler
* @param array<string, mixed>|null $meta
*/
public function addResourceTemplate(
\Closure|array|string $handler,
string $uriTemplate,
?string $name = null,
?string $description = null,
?string $mimeType = null,
?Annotations $annotations = null,
?array $meta = null,
): self {
$this->resourceTemplates[] = compact(
'handler',
'uriTemplate',
'name',
'description',
'mimeType',
'annotations',
'meta',
);
return $this;
}
/**
* Manually registers a prompt handler.
*
* @param Handler $handler
* @param ?Icon[] $icons
* @param array<string, mixed>|null $meta
*/
public function addPrompt(
\Closure|array|string $handler,
?string $name = null,
?string $title = null,
?string $description = null,
?array $icons = null,
?array $meta = null,
): self {
$this->prompts[] = compact('handler', 'name', 'title', 'description', 'icons', 'meta');
return $this;
}
/**
* Register a single custom loader.
*/
public function addLoader(LoaderInterface $loader): self
{
$this->loaders[] = $loader;
return $this;
}
/**
* @param iterable<LoaderInterface> $loaders
*/
public function addLoaders(iterable $loaders): self
{
foreach ($loaders as $loader) {
$this->loaders[] = $loader;
}
return $this;
}
/**
* Builds the fully configured Server instance.
*/
public function build(): Server
{
$logger = $this->logger ?? new NullLogger();
$container = $this->container ?? new Container();
$registry = $this->registry ?? new Registry($this->eventDispatcher, $logger);
$subscriptionManager = $this->subscriptionManager ?? new SessionSubscriptionManager($logger);
$loaders = [
...$this->loaders,
new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator),
];
$sessionManager = $this->sessionManager ?? new SessionManager(
$this->sessionStore ?? new InMemorySessionStore(),
$logger,
);
if (null !== $this->discoveryBasePath) {
if (null !== $this->discoverer || class_exists(Finder::class)) {
$discoverer = $this->discoverer ?? $this->createDiscoverer($logger);
$loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer);
} else {
$logger->warning('File-based discovery requires symfony/finder. Skipping automatic discovery. Run: composer require symfony/finder');
}
}
foreach ($loaders as $loader) {
$loader->load($registry);
}
$messageFactory = MessageFactory::make();
$capabilities = $this->serverCapabilities ?? new ServerCapabilities(
tools: $registry->hasTools(),
toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
resources: $registry->hasResources() || $registry->hasResourceTemplates(),
resourcesSubscribe: $registry->hasResources() || $registry->hasResourceTemplates(),
resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
prompts: $registry->hasPrompts(),
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
logging: true,
completions: true,
);
$serverInfo = $this->serverInfo ?? new Implementation();
$configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion);
$referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container);
$requestHandlers = array_merge($this->requestHandlers, [
new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger),
new Handler\Request\CompletionCompleteHandler($registry, $container),
new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger),
new Handler\Request\InitializeHandler($configuration),
new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit),
new Handler\Request\ListResourcesHandler($registry, $this->paginationLimit),
new Handler\Request\ListResourceTemplatesHandler($registry, $this->paginationLimit),
new Handler\Request\ListToolsHandler($registry, $this->paginationLimit),
new Handler\Request\PingHandler(),
new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger),
new Handler\Request\ResourceSubscribeHandler($registry, $subscriptionManager, $logger),
new Handler\Request\ResourceUnsubscribeHandler($registry, $subscriptionManager, $logger),
new Handler\Request\SetLogLevelHandler(),
]);
$notificationHandlers = array_merge($this->notificationHandlers, [
new Handler\Notification\InitializedHandler(),
]);
$protocol = new Protocol(
requestHandlers: $requestHandlers,
notificationHandlers: $notificationHandlers,
messageFactory: $messageFactory,
sessionManager: $sessionManager,
logger: $logger,
eventDispatcher: $this->eventDispatcher,
);
return new Server($protocol, $logger);
}
private function createDiscoverer(LoggerInterface $logger): DiscovererInterface
{
$discoverer = new Discoverer($logger, null, $this->schemaGenerator);
if (null !== $this->discoveryCache) {
return new CachedDiscoverer($discoverer, $this->discoveryCache, $logger);
}
return $discoverer;
}
}