Registry Architecture Refactoring - Enhanced Separation of Concerns (#46)

* refactor: Separate Registry concerns following SOLID principles [WIP]

- Extract ReferenceProvider and ReferenceRegistryInterface interfaces
- Create DefaultToolExecutor with ReferenceHandlerInterface
- Remove execution responsibility from Registry class
- Enable custom handler and executor implementations

* refactor: implement SOLID principles with separated execution concerns

* Create DefaultResourceReader, DefaultPromptGetter
* Refactor JsonRpc Handler and RequestHandlers to use dedicated executors
* Update ServerBuilder to support custom executors via dependency injection

* chore: remove extra comments

* feat: introduce DispatchableRegistry for enhanced tool and resource management

* refactor: use proper interfaces

* refactor: implement HandlerInterface for improved abstraction and flexibility

* test: add unit tests for DispatchableRegistry and Registry classes

* refactor: cover with unit tests Resource reader, Prompt getter and Tool executor

* cs fix

* phpstan fix

* test: add unit tests for CallToolHandler, GetPromptHandler, PingHandler, and ReadResourceHandler

* refactor: revert Handler

* chore: remove style guide for tests

* refactor: remove "Default" prefix from classes

* refactor: remove DispatchableRegistry

* refactor: use package specific exception classes

* refactor: remove DispatchableRegistry

* refactor: add logger support to PromptGetter and ResourceReader classes

* Update src/Capability/Prompt/PromptGetter.php

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>

* refactor: Use FQN for Resource class to avoid cs-fixer misinterpreting it as `resource`

* refactor: rename ToolExecutor to ToolCaller and related classes for clarity and consistency

* refactor: add missed docblock

* refactor: rename ToolExecutor references to ToolCaller

* refactor: rename ToolCallerTest property and variable references

* cs fix

* ignore some phpstan errors

---------

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
This commit is contained in:
Pavel Buchnev 2025-09-14 22:44:18 +04:00 • committed by GitHub
commit 99bf56eef8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 4656 additions and 334 deletions

View file

@ -12,7 +12,7 @@
namespace App;
use Mcp\Capability\Tool\MetadataInterface;
use Mcp\Capability\Tool\ToolExecutorInterface;
use Mcp\Capability\Tool\ToolCallerInterface;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Result\CallToolResult;
@ -20,7 +20,7 @@ use Mcp\Schema\Result\CallToolResult;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class ExampleTool implements MetadataInterface, ToolExecutorInterface
class ExampleTool implements MetadataInterface, ToolCallerInterface
{
public function call(CallToolRequest $request): CallToolResult
{

View file

@ -367,131 +367,23 @@ parameters:
path: examples/08-schema-showcase-streamable/server.php
-
message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\CallToolHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ToolChain given\.$#'
message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListPromptsHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\PromptChain given\.$#'
identifier: argument.type
count: 1
path: examples/09-standalone-cli/src/Builder.php
-
message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\GetPromptHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\PromptChain given\.$#'
message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListResourcesHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\ResourceChain given\.$#'
identifier: argument.type
count: 1
path: examples/09-standalone-cli/src/Builder.php
-
message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListPromptsHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\PromptChain given\.$#'
message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListToolsHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\ToolChain given\.$#'
identifier: argument.type
count: 1
path: examples/09-standalone-cli/src/Builder.php
-
message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListResourcesHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ResourceChain given\.$#'
identifier: argument.type
count: 1
path: examples/09-standalone-cli/src/Builder.php
-
message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListToolsHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ToolChain given\.$#'
identifier: argument.type
count: 1
path: examples/09-standalone-cli/src/Builder.php
-
message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ReadResourceHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ResourceChain given\.$#'
identifier: argument.type
count: 1
path: examples/09-standalone-cli/src/Builder.php
-
message: '#^Call to protected method formatResult\(\) of class Mcp\\Capability\\Registry\\ResourceReference\.$#'
identifier: method.protected
count: 1
path: src/Capability/Registry.php
-
message: '#^Cannot import type alias CallableArray\: type alias does not exist in Mcp\\Capability\\Registry\\ElementReference\.$#'
identifier: typeAlias.notFound
count: 1
path: src/Capability/Registry.php
-
message: '#^Method Mcp\\Capability\\Registry\:\:handleCallTool\(\) has parameter \$arguments with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/Capability/Registry.php
-
message: '#^Method Mcp\\Capability\\Registry\:\:handleCallTool\(\) return type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/Capability/Registry.php
-
message: '#^Method Mcp\\Capability\\Registry\:\:handleGetPrompt\(\) has parameter \$arguments with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/Capability/Registry.php
-
message: '#^Method Mcp\\Capability\\Registry\:\:registerPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/Capability/Registry.php
-
message: '#^Method Mcp\\Capability\\Registry\:\:registerResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/Capability/Registry.php
-
message: '#^Method Mcp\\Capability\\Registry\:\:registerResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/Capability/Registry.php
-
message: '#^Method Mcp\\Capability\\Registry\:\:registerTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: src/Capability/Registry.php
-
message: '#^PHPDoc tag @param for parameter \$handler with type \(callable\)\|Mcp\\Capability\\CallableArray\|string is not subtype of native type array\|\(callable\)\|string\.$#'
identifier: parameter.phpDocType
count: 4
path: src/Capability/Registry.php
-
message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerPrompt\(\) has invalid type Mcp\\Capability\\CallableArray\.$#'
identifier: class.notFound
count: 1
path: src/Capability/Registry.php
-
message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerResource\(\) has invalid type Mcp\\Capability\\CallableArray\.$#'
identifier: class.notFound
count: 1
path: src/Capability/Registry.php
-
message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerResourceTemplate\(\) has invalid type Mcp\\Capability\\CallableArray\.$#'
identifier: class.notFound
count: 1
path: src/Capability/Registry.php
-
message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerTool\(\) has invalid type Mcp\\Capability\\CallableArray\.$#'
identifier: class.notFound
count: 1
path: src/Capability/Registry.php
-
message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Capability/Registry/ResourceTemplateReference.php
-
message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#'
identifier: return.phpDocType
@ -505,11 +397,23 @@ parameters:
path: src/Schema/Result/ReadResourceResult.php
-
message: '#^Method Mcp\\Capability\\Registry\:\:getPrompts\(\) invoked with 2 parameters, 0 required\.$#'
message: '#^Result of && is always false\.$#'
identifier: booleanAnd.alwaysFalse
count: 1
path: src/Server/RequestHandler/ListResourcesHandler.php
-
message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getPrompts\(\) invoked with 2 parameters, 0 required\.$#'
identifier: arguments.count
count: 1
path: src/Server/RequestHandler/ListPromptsHandler.php
-
message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Capability/Registry/ResourceTemplateReference.php
-
message: '#^Result of && is always false\.$#'
identifier: booleanAnd.alwaysFalse
@ -523,29 +427,23 @@ parameters:
path: src/Server/RequestHandler/ListPromptsHandler.php
-
message: '#^Method Mcp\\Capability\\Registry\:\:getResources\(\) invoked with 2 parameters, 0 required\.$#'
message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getResources\(\) invoked with 2 parameters, 0 required\.$#'
identifier: arguments.count
count: 1
path: src/Server/RequestHandler/ListResourcesHandler.php
-
message: '#^Result of && is always false\.$#'
identifier: booleanAnd.alwaysFalse
count: 1
path: src/Server/RequestHandler/ListResourcesHandler.php
-
message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#'
identifier: notIdentical.alwaysFalse
count: 1
path: src/Server/RequestHandler/ListResourcesHandler.php
-
message: '#^Method Mcp\\Capability\\Registry\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#'
message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#'
identifier: arguments.count
count: 1
path: src/Server/RequestHandler/ListToolsHandler.php
-
message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#'
identifier: notIdentical.alwaysFalse
count: 1
path: src/Server/RequestHandler/ListResourcesHandler.php
-
message: '#^Result of && is always false\.$#'
identifier: booleanAnd.alwaysFalse

View file

@ -19,7 +19,7 @@ use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Prompt\Completion\EnumCompletionProvider;
use Mcp\Capability\Prompt\Completion\ListCompletionProvider;
use Mcp\Capability\Prompt\Completion\ProviderInterface;
use Mcp\Capability\Registry;
use Mcp\Capability\Registry\ReferenceRegistryInterface;
use Mcp\Exception\ExceptionInterface;
use Mcp\Schema\Prompt;
use Mcp\Schema\PromptArgument;
@ -44,7 +44,7 @@ use Symfony\Component\Finder\SplFileInfo;
class Discoverer
{
public function __construct(
private readonly Registry $registry,
private readonly ReferenceRegistryInterface $registry,
private readonly LoggerInterface $logger = new NullLogger(),
private ?DocBlockParser $docBlockParser = null,
private ?SchemaGenerator $schemaGenerator = null,

View file

@ -0,0 +1,69 @@
<?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\Capability\Prompt;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Exception\PromptGetException;
use Mcp\Exception\PromptNotFoundException;
use Mcp\Schema\Request\GetPromptRequest;
use Mcp\Schema\Result\GetPromptResult;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* @author Pavel Buchnev <butschster@gmail.com>
*/
final class PromptGetter implements PromptGetterInterface
{
public function __construct(
private readonly ReferenceProviderInterface $referenceProvider,
private readonly ReferenceHandlerInterface $referenceHandler,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}
public function get(GetPromptRequest $request): GetPromptResult
{
$promptName = $request->name;
$arguments = $request->arguments ?? [];
$this->logger->debug('Getting prompt', ['name' => $promptName, 'arguments' => $arguments]);
$reference = $this->referenceProvider->getPrompt($promptName);
if (null === $reference) {
$this->logger->warning('Prompt not found', ['name' => $promptName]);
throw new PromptNotFoundException($request);
}
try {
$result = $this->referenceHandler->handle($reference, $arguments);
$formattedResult = $reference->formatResult($result);
$this->logger->debug('Prompt retrieved successfully', [
'name' => $promptName,
'result_type' => \gettype($result),
]);
return new GetPromptResult($formattedResult);
} catch (\Throwable $e) {
$this->logger->error('Prompt retrieval failed', [
'name' => $promptName,
'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw new PromptGetException($request, $e);
}
}
}

View file

@ -11,9 +11,9 @@
namespace Mcp\Capability;
use Mcp\Capability\Registry\ElementReference;
use Mcp\Capability\Registry\PromptReference;
use Mcp\Capability\Registry\ReferenceHandler;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Capability\Registry\ReferenceRegistryInterface;
use Mcp\Capability\Registry\ResourceReference;
use Mcp\Capability\Registry\ResourceTemplateReference;
use Mcp\Capability\Registry\ToolReference;
@ -21,9 +21,6 @@ use Mcp\Event\PromptListChangedEvent;
use Mcp\Event\ResourceListChangedEvent;
use Mcp\Event\ResourceTemplateListChangedEvent;
use Mcp\Event\ToolListChangedEvent;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Schema\Content\PromptMessage;
use Mcp\Schema\Content\ResourceContents;
use Mcp\Schema\Prompt;
use Mcp\Schema\Resource;
use Mcp\Schema\ResourceTemplate;
@ -34,11 +31,14 @@ use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* @phpstan-import-type CallableArray from ElementReference
* Registry implementation that manages MCP element registration and access.
* Implements both ReferenceProvider (for access) and ReferenceRegistry (for registration)
* following the Interface Segregation Principle.
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
* @author Pavel Buchnev <butschster@gmail.com>
*/
class Registry
final class Registry implements ReferenceProviderInterface, ReferenceRegistryInterface
{
/**
* @var array<string, ToolReference>
@ -61,7 +61,6 @@ class Registry
private array $resourceTemplates = [];
public function __construct(
private readonly ReferenceHandler $referenceHandler = new ReferenceHandler(),
private readonly ?EventDispatcherInterface $eventDispatcher = null,
private readonly LoggerInterface $logger = new NullLogger(),
) {
@ -74,28 +73,27 @@ class Registry
}
return new ServerCapabilities(
tools: true, // [] !== $this->tools,
tools: [] !== $this->tools,
toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
resources: [] !== $this->resources || [] !== $this->resourceTemplates,
resourcesSubscribe: false,
resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
prompts: [] !== $this->prompts,
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
logging: false, // true,
logging: false,
completions: true,
);
}
/**
* @param callable|CallableArray|string $handler
*/
public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void
{
$toolName = $tool->name;
$existing = $this->tools[$toolName] ?? null;
if ($existing && !$isManual && $existing->isManual) {
$this->logger->debug("Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one.");
$this->logger->debug(
"Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one.",
);
return;
}
@ -105,16 +103,15 @@ class Registry
$this->eventDispatcher?->dispatch(new ToolListChangedEvent());
}
/**
* @param callable|CallableArray|string $handler
*/
public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void
{
$uri = $resource->uri;
$existing = $this->resources[$uri] ?? null;
if ($existing && !$isManual && $existing->isManual) {
$this->logger->debug("Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one.");
$this->logger->debug(
"Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one.",
);
return;
}
@ -124,10 +121,6 @@ class Registry
$this->eventDispatcher?->dispatch(new ResourceListChangedEvent());
}
/**
* @param callable|CallableArray|string $handler
* @param array<string, class-string|object> $completionProviders
*/
public function registerResourceTemplate(
ResourceTemplate $template,
callable|array|string $handler,
@ -138,20 +131,23 @@ class Registry
$existing = $this->resourceTemplates[$uriTemplate] ?? null;
if ($existing && !$isManual && $existing->isManual) {
$this->logger->debug("Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one.");
$this->logger->debug(
"Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one.",
);
return;
}
$this->resourceTemplates[$uriTemplate] = new ResourceTemplateReference($template, $handler, $isManual, $completionProviders);
$this->resourceTemplates[$uriTemplate] = new ResourceTemplateReference(
$template,
$handler,
$isManual,
$completionProviders,
);
$this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent());
}
/**
* @param callable|CallableArray|string $handler
* @param array<string, class-string|object> $completionProviders
*/
public function registerPrompt(
Prompt $prompt,
callable|array|string $handler,
@ -162,7 +158,9 @@ class Registry
$existing = $this->prompts[$promptName] ?? null;
if ($existing && !$isManual && $existing->isManual) {
$this->logger->debug("Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one.");
$this->logger->debug(
"Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one.",
);
return;
}
@ -172,20 +170,6 @@ class Registry
$this->eventDispatcher?->dispatch(new PromptListChangedEvent());
}
/**
* Checks if any elements (manual or discovered) are currently registered.
*/
public function hasElements(): bool
{
return !empty($this->tools)
|| !empty($this->resources)
|| !empty($this->prompts)
|| !empty($this->resourceTemplates);
}
/**
* Clear discovered elements from registry.
*/
public function clear(): void
{
$clearCount = 0;
@ -220,43 +204,15 @@ class Registry
}
}
public function handleCallTool(string $name, array $arguments): array
{
$reference = $this->getTool($name);
if (null === $reference) {
throw new InvalidArgumentException(\sprintf('Tool "%s" is not registered.', $name));
}
return $reference->formatResult(
$this->referenceHandler->handle($reference, $arguments)
);
}
public function getTool(string $name): ?ToolReference
{
return $this->tools[$name] ?? null;
}
/**
* @return ResourceContents[]
*/
public function handleReadResource(string $uri): array
{
$reference = $this->getResource($uri);
if (null === $reference) {
throw new InvalidArgumentException(\sprintf('Resource "%s" is not registered.', $uri));
}
return $reference->formatResult(
$this->referenceHandler->handle($reference, ['uri' => $uri]),
$uri,
);
}
public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference|null
{
public function getResource(
string $uri,
bool $includeTemplates = true,
): ResourceReference|ResourceTemplateReference|null {
$registration = $this->resources[$uri] ?? null;
if ($registration) {
return $registration;
@ -282,54 +238,37 @@ class Registry
return $this->resourceTemplates[$uriTemplate] ?? null;
}
/**
* @return PromptMessage[]
*/
public function handleGetPrompt(string $name, ?array $arguments): array
{
$reference = $this->getPrompt($name);
if (null === $reference) {
throw new InvalidArgumentException(\sprintf('Prompt "%s" is not registered.', $name));
}
return $reference->formatResult(
$this->referenceHandler->handle($reference, $arguments)
);
}
public function getPrompt(string $name): ?PromptReference
{
return $this->prompts[$name] ?? null;
}
/**
* @return array<string, Tool>
*/
public function getTools(): array
{
return array_map(fn (ToolReference $tool) => $tool->tool, $this->tools);
}
/**
* @return array<string, resource>
*/
public function getResources(): array
{
return array_map(fn (ResourceReference $resource) => $resource->schema, $this->resources);
}
/**
* @return array<string, Prompt>
*/
public function getPrompts(): array
{
return array_map(fn (PromptReference $prompt) => $prompt->prompt, $this->prompts);
}
/** @return array<string, ResourceTemplate> */
public function getResourceTemplates(): array
{
return array_map(fn ($template) => $template->resourceTemplate, $this->resourceTemplates);
return array_map(fn (ResourceTemplateReference $template) => $template->resourceTemplate,
$this->resourceTemplates);
}
public function hasElements(): bool
{
return !empty($this->tools)
|| !empty($this->resources)
|| !empty($this->prompts)
|| !empty($this->resourceTemplates);
}
}

View file

@ -18,7 +18,7 @@ use Psr\Container\ContainerInterface;
/**
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class ReferenceHandler
final class ReferenceHandler implements ReferenceHandlerInterface
{
public function __construct(
private readonly ?ContainerInterface $container = null,

View file

@ -0,0 +1,34 @@
<?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\Capability\Registry;
/**
* Interface for handling execution of MCP elements.
* Allows custom implementations of element execution logic.
*
* @author Pavel Buchnev <butschster@gmail.com>
*/
interface ReferenceHandlerInterface
{
/**
* Handles execution of an MCP element reference.
*
* @param ElementReference $reference the element reference to execute
* @param array<string, mixed> $arguments arguments to pass to the handler
*
* @return mixed the result of the element execution
*
* @throws \Mcp\Exception\InvalidArgumentException if the handler is invalid
* @throws \Mcp\Exception\RegistryException if execution fails
*/
public function handle(ElementReference $reference, array $arguments): mixed;
}

View file

@ -0,0 +1,78 @@
<?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\Capability\Registry;
use Mcp\Schema\Prompt;
use Mcp\Schema\ResourceTemplate;
use Mcp\Schema\Tool;
/**
* Interface for providing access to registered MCP elements.
* Separates the concern of accessing elements from registering them.
*
* @author Pavel Buchnev <butschster@gmail.com>
*/
interface ReferenceProviderInterface
{
/**
* Gets a tool reference by name.
*/
public function getTool(string $name): ?ToolReference;
/**
* Gets a resource reference by URI (includes template matching if enabled).
*/
public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference|null;
/**
* Gets a resource template reference by URI template.
*/
public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateReference;
/**
* Gets a prompt reference by name.
*/
public function getPrompt(string $name): ?PromptReference;
/**
* Gets all registered tools.
*
* @return array<string, Tool>
*/
public function getTools(): array;
/**
* Gets all registered resources.
*
* @return array<string, \Mcp\Schema\Resource>
*/
public function getResources(): array;
/**
* Gets all registered prompts.
*
* @return array<string, Prompt>
*/
public function getPrompts(): array;
/**
* Gets all registered resource templates.
*
* @return array<string, ResourceTemplate>
*/
public function getResourceTemplates(): array;
/**
* Checks if any elements (manual or discovered) are currently registered.
*/
public function hasElements(): bool;
}

View file

@ -0,0 +1,79 @@
<?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\Capability\Registry;
use Mcp\Schema\Prompt;
use Mcp\Schema\Resource;
use Mcp\Schema\ResourceTemplate;
use Mcp\Schema\ServerCapabilities;
use Mcp\Schema\Tool;
/**
* @phpstan-import-type Handler from ElementReference
*
* Interface for registering MCP elements.
* Separates the concern of registering elements from accessing them.
*
* @author Pavel Buchnev <butschster@gmail.com>
*/
interface ReferenceRegistryInterface
{
/**
* Gets server capabilities based on registered elements.
*/
public function getCapabilities(): ServerCapabilities;
/**
* Registers a tool with its handler.
*
* @param Handler $handler
*/
public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void;
/**
* Registers a resource with its handler.
*
* @param Handler $handler
*/
public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void;
/**
* Registers a resource template with its handler and completion providers.
*
* @param Handler $handler
* @param array<string, class-string|object> $completionProviders
*/
public function registerResourceTemplate(
ResourceTemplate $template,
callable|array|string $handler,
array $completionProviders = [],
bool $isManual = false,
): void;
/**
* Registers a prompt with its handler and completion providers.
*
* @param Handler $handler
* @param array<string, class-string|object> $completionProviders
*/
public function registerPrompt(
Prompt $prompt,
callable|array|string $handler,
array $completionProviders = [],
bool $isManual = false,
): void;
/**
* Clear discovered elements from registry.
*/
public function clear(): void;
}

View file

@ -55,7 +55,8 @@ class ResourceTemplateReference extends ElementReference
}
/**
* Gets the resource template.
* @deprecated
* Gets the resource template
*
* @return array<int, ResourceContents> array of ResourceContents objects
*/
@ -162,7 +163,7 @@ class ResourceTemplateReference extends ElementReference
* - array: Converted to JSON if MIME type is application/json or contains 'json'
* For other MIME types, will try to convert to JSON with a warning
*/
protected function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array
public function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array
{
if ($readResult instanceof ResourceContents) {
return [$readResult];

View file

@ -1,30 +0,0 @@
<?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\Capability\Resource;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class ResourceReadResult
{
public function __construct(
public readonly string $result,
public readonly string $uri,
/**
* @var "text"|"blob"
*/
public readonly string $type = 'text',
public readonly string $mimeType = 'text/plain',
) {
}
}

View file

@ -0,0 +1,68 @@
<?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\Capability\Resource;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Exception\ResourceNotFoundException;
use Mcp\Exception\ResourceReadException;
use Mcp\Schema\Request\ReadResourceRequest;
use Mcp\Schema\Result\ReadResourceResult;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* @author Pavel Buchnev <butschster@gmail.com>
*/
final class ResourceReader implements ResourceReaderInterface
{
public function __construct(
private readonly ReferenceProviderInterface $referenceProvider,
private readonly ReferenceHandlerInterface $referenceHandler,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}
public function read(ReadResourceRequest $request): ReadResourceResult
{
$uri = $request->uri;
$this->logger->debug('Reading resource', ['uri' => $uri]);
$reference = $this->referenceProvider->getResource($uri);
if (null === $reference) {
$this->logger->warning('Resource not found', ['uri' => $uri]);
throw new ResourceNotFoundException($request);
}
try {
$result = $this->referenceHandler->handle($reference, ['uri' => $uri]);
$formattedResult = $reference->formatResult($result, $uri);
$this->logger->debug('Resource read successfully', [
'uri' => $uri,
'result_type' => \gettype($result),
]);
return new ReadResourceResult($formattedResult);
} catch (\Throwable $e) {
$this->logger->error('Resource read failed', [
'uri' => $uri,
'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw new ResourceReadException($request, $e);
}
}
}

View file

@ -0,0 +1,81 @@
<?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\Capability\Tool;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Exception\ToolCallException;
use Mcp\Exception\ToolNotFoundException;
use Mcp\Schema\Content\AudioContent;
use Mcp\Schema\Content\EmbeddedResource;
use Mcp\Schema\Content\ImageContent;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Result\CallToolResult;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* Default implementation of ToolCallerInterface that uses ReferenceProvider
* and ReferenceHandlerInterface to execute tools.
*
* @author Pavel Buchnev <butschster@gmail.com>
*/
final class ToolCaller implements ToolCallerInterface
{
public function __construct(
private readonly ReferenceProviderInterface $referenceProvider,
private readonly ReferenceHandlerInterface $referenceHandler,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}
/**
* @throws ToolCallException if the tool execution fails
* @throws ToolNotFoundException if the tool is not found
*/
public function call(CallToolRequest $request): CallToolResult
{
$toolName = $request->name;
$arguments = $request->arguments ?? [];
$this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]);
$toolReference = $this->referenceProvider->getTool($toolName);
if (null === $toolReference) {
$this->logger->warning('Tool not found', ['name' => $toolName]);
throw new ToolNotFoundException($request);
}
try {
$result = $this->referenceHandler->handle($toolReference, $arguments);
/** @var TextContent[]|ImageContent[]|EmbeddedResource[]|AudioContent[] $formattedResult */
$formattedResult = $toolReference->formatResult($result);
$this->logger->debug('Tool executed successfully', [
'name' => $toolName,
'result_type' => \gettype($result),
]);
return new CallToolResult($formattedResult);
} catch (\Throwable $e) {
$this->logger->error('Tool execution failed', [
'name' => $toolName,
'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw new ToolCallException($request, $e);
}
}
}

View file

@ -0,0 +1,29 @@
<?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\Capability\Tool;
use Mcp\Exception\ToolCallException;
use Mcp\Exception\ToolNotFoundException;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Result\CallToolResult;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
interface ToolCallerInterface
{
/**
* @throws ToolCallException if the tool execution fails
* @throws ToolNotFoundException if the tool is not found
*/
public function call(CallToolRequest $request): CallToolResult;
}

View file

@ -14,9 +14,9 @@ namespace Mcp\Capability;
use Mcp\Capability\Tool\CollectionInterface;
use Mcp\Capability\Tool\IdentifierInterface;
use Mcp\Capability\Tool\MetadataInterface;
use Mcp\Capability\Tool\ToolExecutorInterface;
use Mcp\Capability\Tool\ToolCallerInterface;
use Mcp\Exception\InvalidCursorException;
use Mcp\Exception\ToolExecutionException;
use Mcp\Exception\ToolCallException;
use Mcp\Exception\ToolNotFoundException;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Result\CallToolResult;
@ -26,7 +26,7 @@ use Mcp\Schema\Result\CallToolResult;
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class ToolChain implements ToolExecutorInterface, CollectionInterface
class ToolChain implements ToolCallerInterface, CollectionInterface
{
public function __construct(
/**
@ -63,11 +63,11 @@ class ToolChain implements ToolExecutorInterface, CollectionInterface
public function call(CallToolRequest $request): CallToolResult
{
foreach ($this->items as $item) {
if ($item instanceof ToolExecutorInterface && $request->name === $item->getName()) {
if ($item instanceof ToolCallerInterface && $request->name === $item->getName()) {
try {
return $item->call($request);
} catch (\Throwable $e) {
throw new ToolExecutionException($request, $e);
throw new ToolCallException($request, $e);
}
}
}

View file

@ -0,0 +1,27 @@
<?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\Exception;
use Mcp\Schema\Request\CallToolRequest;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class ToolCallException extends \RuntimeException implements ExceptionInterface
{
public function __construct(
public readonly CallToolRequest $request,
?\Throwable $previous = null,
) {
parent::__construct(\sprintf('Tool call "%s" failed with error: "%s".', $request->name, $previous?->getMessage() ?? ''), previous: $previous);
}
}

View file

@ -11,7 +11,11 @@
namespace Mcp\JsonRpc;
use Mcp\Capability\Registry;
use Mcp\Capability\Prompt\PromptGetterInterface;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Capability\Registry\ReferenceRegistryInterface;
use Mcp\Capability\Resource\ResourceReaderInterface;
use Mcp\Capability\Tool\ToolCallerInterface;
use Mcp\Exception\ExceptionInterface;
use Mcp\Exception\HandlerNotFoundException;
use Mcp\Exception\InvalidInputMessageException;
@ -46,28 +50,34 @@ class Handler
iterable $methodHandlers,
private readonly LoggerInterface $logger = new NullLogger(),
) {
$this->methodHandlers = $methodHandlers instanceof \Traversable ? iterator_to_array($methodHandlers) : $methodHandlers;
$this->methodHandlers = $methodHandlers instanceof \Traversable ? iterator_to_array(
$methodHandlers,
) : $methodHandlers;
}
public static function make(
Registry $registry,
ReferenceRegistryInterface $registry,
ReferenceProviderInterface $referenceProvider,
Implementation $implementation,
ToolCallerInterface $toolCaller,
ResourceReaderInterface $resourceReader,
PromptGetterInterface $promptGetter,
LoggerInterface $logger = new NullLogger(),
): self {
return new self(
MessageFactory::make(),
[
messageFactory: MessageFactory::make(),
methodHandlers: [
new NotificationHandler\InitializedHandler(),
new RequestHandler\InitializeHandler($registry->getCapabilities(), $implementation),
new RequestHandler\PingHandler(),
new RequestHandler\ListPromptsHandler($registry),
new RequestHandler\GetPromptHandler($registry),
new RequestHandler\ListResourcesHandler($registry),
new RequestHandler\ReadResourceHandler($registry),
new RequestHandler\CallToolHandler($registry, $logger),
new RequestHandler\ListToolsHandler($registry),
new RequestHandler\ListPromptsHandler($referenceProvider),
new RequestHandler\GetPromptHandler($promptGetter),
new RequestHandler\ListResourcesHandler($referenceProvider),
new RequestHandler\ReadResourceHandler($resourceReader),
new RequestHandler\CallToolHandler($toolCaller, $logger),
new RequestHandler\ListToolsHandler($referenceProvider),
],
$logger,
logger: $logger,
);
}
@ -107,7 +117,8 @@ class Handler
} catch (\DomainException) {
yield null;
} catch (NotFoundExceptionInterface $e) {
$this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e]);
$this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e],
);
yield $this->encodeResponse(Error::forMethodNotFound($e->getMessage()));
} catch (\InvalidArgumentException $e) {

View file

@ -51,7 +51,7 @@ class ServerCapabilities implements \JsonSerializable
* completions?: mixed,
* prompts?: array{listChanged?: bool}|object,
* resources?: array{listChanged?: bool, subscribe?: bool}|object,
* tools?: object,
* tools?: object|array{listChanged?: bool},
* experimental?: array<string, mixed>,
* } $data
*/
@ -106,7 +106,7 @@ class ServerCapabilities implements \JsonSerializable
promptsListChanged: $promptsListChanged,
logging: $loggingEnabled,
completions: $completionsEnabled,
experimental: $data['experimental'] ?? null
experimental: $data['experimental'] ?? null,
);
}

View file

@ -11,13 +11,12 @@
namespace Mcp\Server\RequestHandler;
use Mcp\Capability\Registry;
use Mcp\Capability\Tool\ToolCallerInterface;
use Mcp\Exception\ExceptionInterface;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\HasMethodInterface;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Server\MethodHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
@ -29,7 +28,7 @@ use Psr\Log\NullLogger;
final class CallToolHandler implements MethodHandlerInterface
{
public function __construct(
private readonly Registry $registry,
private readonly ToolCallerInterface $toolCaller,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}
@ -44,16 +43,19 @@ final class CallToolHandler implements MethodHandlerInterface
\assert($message instanceof CallToolRequest);
try {
$content = $this->registry->handleCallTool($message->name, $message->arguments);
$content = $this->toolCaller->call($message);
} catch (ExceptionInterface $exception) {
$this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $message->name, $exception->getMessage()), [
'tool' => $message->name,
'arguments' => $message->arguments,
]);
$this->logger->error(
\sprintf('Error while executing tool "%s": "%s".', $message->name, $exception->getMessage()),
[
'tool' => $message->name,
'arguments' => $message->arguments,
],
);
return Error::forInternalError('Error while executing tool', $message->getId());
}
return new Response($message->getId(), new CallToolResult($content));
return new Response($message->getId(), $content);
}
}

View file

@ -11,13 +11,12 @@
namespace Mcp\Server\RequestHandler;
use Mcp\Capability\Registry;
use Mcp\Capability\Prompt\PromptGetterInterface;
use Mcp\Exception\ExceptionInterface;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\HasMethodInterface;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\GetPromptRequest;
use Mcp\Schema\Result\GetPromptResult;
use Mcp\Server\MethodHandlerInterface;
/**
@ -26,7 +25,7 @@ use Mcp\Server\MethodHandlerInterface;
final class GetPromptHandler implements MethodHandlerInterface
{
public function __construct(
private readonly Registry $registry,
private readonly PromptGetterInterface $promptGetter,
) {
}
@ -40,11 +39,11 @@ final class GetPromptHandler implements MethodHandlerInterface
\assert($message instanceof GetPromptRequest);
try {
$messages = $this->registry->handleGetPrompt($message->name, $message->arguments);
$messages = $this->promptGetter->get($message);
} catch (ExceptionInterface) {
return Error::forInternalError('Error while handling prompt', $message->getId());
}
return new Response($message->getId(), new GetPromptResult($messages));
return new Response($message->getId(), $messages);
}
}

Some files were not shown because too many files have changed in this diff Show more