[Server] Simplify server extensibility with pluggable method handlers (#100)

* refactor: use a custom method handler registration system

* refactor: remove redundant Completer and CompleterInterface

* docs: update server-builder documentation to reflect custom method handler changes
This commit is contained in:
Kyrian Obikwelu 2025-10-07 23:25:06 +01:00 • committed by GitHub
commit fcf605d5d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 896 additions and 2865 deletions

View file

@ -12,7 +12,7 @@ various aspects of the server behavior.
- [Session Management](#session-management)
- [Manual Capability Registration](#manual-capability-registration)
- [Service Dependencies](#service-dependencies)
- [Custom Capability Handlers](#custom-capability-handlers)
- [Custom Method Handlers](#custom-method-handlers)
- [Complete Example](#complete-example)
- [Method Reference](#method-reference)
@ -344,102 +344,50 @@ $server = Server::builder()
->setEventDispatcher($eventDispatcher);
```
## Custom Capability Handlers
## Custom Method Handlers
**Advanced customization for specific use cases.** Override the default capability handlers when you need completely custom
behavior for how tools are executed, resources are read, or prompts are generated. Most users should stick with the default implementations.
**Low-level escape hatch.** Custom method handlers run before the SDK’s built-in handlers and give you total control over
individual JSON-RPC methods. They do not receive the builder’s registry, container, or discovery output unless you pass
those dependencies in yourself.
The default handlers work by:
1. Looking up registered tools/resources/prompts by name/URI
2. Resolving the handler from the container
3. Executing the handler with the provided arguments
4. Formatting the result and handling errors
### Custom Tool Caller
Replace how tool execution requests are processed. Your custom `ToolCallerInterface` receives a `CallToolRequest` (with
tool name and arguments) and must return a `CallToolResult`.
Attach handlers with `addMethodHandler()` (single) or `addMethodHandlers()` (multiple). You can call these methods as
many times as needed; each call prepends the handlers so they execute before the defaults:
```php
use Mcp\Capability\Tool\ToolCallerInterface;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Result\CallToolResult;
class CustomToolCaller implements ToolCallerInterface
{
public function call(CallToolRequest $request): CallToolResult
{
// Custom tool routing, execution, authentication, caching, etc.
// You handle finding the tool, executing it, and formatting results
$toolName = $request->name;
$arguments = $request->arguments ?? [];
// Your custom logic here
return new CallToolResult([/* content */]);
}
}
$server = Server::builder()
->setToolCaller(new CustomToolCaller());
->addMethodHandler(new AuditHandler())
->addMethodHandlers([
new CustomListToolsHandler(),
new CustomCallToolHandler(),
])
->build();
```
### Custom Resource Reader
Replace how resource reading requests are processed. Your custom `ResourceReaderInterface` receives a `ReadResourceRequest`
(with URI) and must return a `ReadResourceResult`.
Custom handlers implement `MethodHandlerInterface`:
```php
use Mcp\Capability\Resource\ResourceReaderInterface;
use Mcp\Schema\Request\ReadResourceRequest;
use Mcp\Schema\Result\ReadResourceResult;
use Mcp\Schema\JsonRpc\HasMethodInterface;
use Mcp\Server\Handler\MethodHandlerInterface;
use Mcp\Server\Session\SessionInterface;
class CustomResourceReader implements ResourceReaderInterface
interface MethodHandlerInterface
{
public function read(ReadResourceRequest $request): ReadResourceResult
{
// Custom resource resolution, caching, access control, etc.
$uri = $request->uri;
// Your custom logic here
return new ReadResourceResult([/* content */]);
}
}
public function supports(HasMethodInterface $message): bool;
$server = Server::builder()
->setResourceReader(new CustomResourceReader());
public function handle(HasMethodInterface $message, SessionInterface $session);
}
```
### Custom Prompt Getter
- `supports()` decides if the handler should look at the incoming message.
- `handle()` must return a JSON-RPC `Response`, an `Error`, or `null`.
Replace how prompt generation requests are processed. Your custom `PromptGetterInterface` receives a `GetPromptRequest`
(with prompt name and arguments) and must return a `GetPromptResult`.
Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement
custom `tool/list` and `tool/call` methods independently of the registry.
```php
use Mcp\Capability\Prompt\PromptGetterInterface;
use Mcp\Schema\Request\GetPromptRequest;
use Mcp\Schema\Result\GetPromptResult;
class CustomPromptGetter implements PromptGetterInterface
{
public function get(GetPromptRequest $request): GetPromptResult
{
// Custom prompt generation, template engines, dynamic content, etc.
$promptName = $request->name;
$arguments = $request->arguments ?? [];
// Your custom logic here
return new GetPromptResult([/* messages */]);
}
}
$server = Server::builder()
->setPromptGetter(new CustomPromptGetter());
```
> **Warning**: Custom capability handlers bypass the entire default registration system (discovered attributes, manual
> registration, container resolution, etc.). You become responsible for all aspect of execution, including error handling,
> logging, and result formatting. Only use this for very specific advanced use cases like custom authentication, complex
> routing, or integration with external systems.
> **Warning**: Custom method handlers bypass discovery, manual capability registration, and container lookups (unlesss
> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler
> loads and executes them manually.
> Reach for this API only when you need that level of control and are comfortable taking on the additional plumbing.
## Complete Example
@ -505,9 +453,8 @@ $server = Server::builder()
| `setLogger()` | logger | Set PSR-3 logger |
| `setContainer()` | container | Set PSR-11 container |
| `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher |
| `setToolCaller()` | caller | Set custom tool caller |
| `setResourceReader()` | reader | Set custom resource reader |
| `setPromptGetter()` | getter | Set custom prompt getter |
| `addMethodHandler()` | handler | Prepend a single custom method handler |
| `addMethodHandlers()` | handlers | Prepend multiple custom method handlers |
| `addTool()` | handler, name?, description?, annotations?, inputSchema? | Register tool |
| `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource |
| `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template |

View file

@ -0,0 +1,144 @@
#!/usr/bin/env 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.
*/
require_once dirname(__DIR__).'/bootstrap.php';
chdir(__DIR__);
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\HasMethodInterface;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Request\ListToolsRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Result\ListToolsResult;
use Mcp\Schema\ServerCapabilities;
use Mcp\Schema\Tool;
use Mcp\Server;
use Mcp\Server\Handler\MethodHandlerInterface;
use Mcp\Server\Session\SessionInterface;
use Mcp\Server\Transport\StdioTransport;
logger()->info('Starting MCP Custom Method Handlers (Stdio) Server...');
$toolDefinitions = [
'say_hello' => new Tool(
name: 'say_hello',
inputSchema: [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string', 'description' => 'Name to greet'],
],
'required' => ['name'],
],
description: 'Greets a user by name.',
annotations: null,
),
'sum' => new Tool(
name: 'sum',
inputSchema: [
'type' => 'object',
'properties' => [
'a' => ['type' => 'number'],
'b' => ['type' => 'number'],
],
'required' => ['a', 'b'],
],
description: 'Returns a+b.',
annotations: null,
),
];
$listToolsHandler = new class($toolDefinitions) implements MethodHandlerInterface {
/**
* @param array<string, Tool> $toolDefinitions
*/
public function __construct(private array $toolDefinitions)
{
}
public function supports(HasMethodInterface $message): bool
{
return $message instanceof ListToolsRequest;
}
public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response
{
assert($message instanceof ListToolsRequest);
return new Response($message->getId(), new ListToolsResult(array_values($this->toolDefinitions), null));
}
};
$callToolHandler = new class($toolDefinitions) implements MethodHandlerInterface {
/**
* @param array<string, Tool> $toolDefinitions
*/
public function __construct(private array $toolDefinitions)
{
}
public function supports(HasMethodInterface $message): bool
{
return $message instanceof CallToolRequest;
}
public function handle(CallToolRequest|HasMethodInterface $message, SessionInterface $session): Response|Error
{
assert($message instanceof CallToolRequest);
$name = $message->name;
$args = $message->arguments ?? [];
if (!isset($this->toolDefinitions[$name])) {
return new Error($message->getId(), Error::METHOD_NOT_FOUND, sprintf('Tool not found: %s', $name));
}
try {
switch ($name) {
case 'say_hello':
$greetName = (string) ($args['name'] ?? 'world');
$result = [new TextContent(sprintf('Hello, %s!', $greetName))];
break;
case 'sum':
$a = (float) ($args['a'] ?? 0);
$b = (float) ($args['b'] ?? 0);
$result = [new TextContent((string) ($a + $b))];
break;
default:
$result = [new TextContent('Unknown tool')];
}
return new Response($message->getId(), new CallToolResult($result));
} catch (Throwable $e) {
return new Response($message->getId(), new CallToolResult([new TextContent('Tool execution failed')], true));
}
}
};
$capabilities = new ServerCapabilities(tools: true, resources: false, prompts: false);
$server = Server::builder()
->setServerInfo('Custom Handlers Server', '1.0.0')
->setLogger(logger())
->setContainer(container())
->setCapabilities($capabilities)
->addMethodHandlers([$listToolsHandler, $callToolHandler])
->build();
$transport = new StdioTransport(logger: logger());
$server->connect($transport);
$transport->listen();
logger()->info('Server listener stopped gracefully.');

View file

@ -1,75 +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\Completion;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Exception\RuntimeException;
use Mcp\Schema\Request\CompletionCompleteRequest;
use Mcp\Schema\Result\CompletionCompleteResult;
use Psr\Container\ContainerInterface;
/**
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
final class Completer implements CompleterInterface
{
public function __construct(
private readonly ReferenceProviderInterface $referenceProvider,
private readonly ?ContainerInterface $container = null,
) {
}
public function complete(CompletionCompleteRequest $request): CompletionCompleteResult
{
$argumentName = $request->argument['name'] ?? '';
$currentValue = $request->argument['value'] ?? '';
$reference = match (true) {
'ref/prompt' === $request->ref->type => $this->referenceProvider->getPrompt($request->ref->name),
'ref/resource' === $request->ref->type => $this->referenceProvider->getResourceTemplate($request->ref->uri),
default => null,
};
if (null === $reference) {
return new CompletionCompleteResult([]);
}
$providerClassOrInstance = $reference->completionProviders[$argumentName] ?? null;
if (null === $providerClassOrInstance) {
return new CompletionCompleteResult([]);
}
if (\is_string($providerClassOrInstance)) {
if (!class_exists($providerClassOrInstance)) {
throw new RuntimeException(\sprintf('Completion provider class "%s" does not exist.', $providerClassOrInstance));
}
$provider = $this->container?->has($providerClassOrInstance)
? $this->container->get($providerClassOrInstance)
: new $providerClassOrInstance();
} else {
$provider = $providerClassOrInstance;
}
if (!$provider instanceof ProviderInterface) {
throw new RuntimeException('Completion provider must implement ProviderInterface.');
}
$completions = $provider->getCompletions($currentValue);
$total = \count($completions);
$hasMore = $total > 100;
$pagedCompletions = \array_slice($completions, 0, 100);
return new CompletionCompleteResult($pagedCompletions, $total, $hasMore);
}
}

View file

@ -1,25 +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\Completion;
use Mcp\Schema\Request\CompletionCompleteRequest;
use Mcp\Schema\Result\CompletionCompleteResult;
/**
* Provides completion options for prompt arguments and resource template variables.
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
interface CompleterInterface
{
public function complete(CompletionCompleteRequest $request): CompletionCompleteResult;
}

View file

@ -1,69 +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\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

@ -1,29 +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\Prompt;
use Mcp\Exception\PromptGetException;
use Mcp\Exception\PromptNotFoundException;
use Mcp\Schema\Request\GetPromptRequest;
use Mcp\Schema\Result\GetPromptResult;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
interface PromptGetterInterface
{
/**
* @throws PromptGetException if the prompt execution fails
* @throws PromptNotFoundException if the prompt is not found
*/
public function get(GetPromptRequest $request): GetPromptResult;
}

View file

@ -1,68 +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;
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

@ -1,29 +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;
use Mcp\Exception\ResourceNotFoundException;
use Mcp\Exception\ResourceReadException;
use Mcp\Schema\Request\ReadResourceRequest;
use Mcp\Schema\Result\ReadResourceResult;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
interface ResourceReaderInterface
{
/**
* @throws ResourceReadException if the resource execution fails
* @throws ResourceNotFoundException if the resource is not found
*/
public function read(ReadResourceRequest $request): ReadResourceResult;
}

View file

@ -1,81 +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\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

@ -1,29 +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\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

@ -17,7 +17,6 @@ use Mcp\Schema\Content\Content;
use Mcp\Schema\Content\EmbeddedResource;
use Mcp\Schema\Content\ImageContent;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\JsonRpc\ResultInterface;
/**
@ -32,11 +31,6 @@ use Mcp\Schema\JsonRpc\ResultInterface;
* server does not support tool calls, or any other exceptional conditions,
* should be reported as an MCP error response.
*
* @phpstan-import-type TextContentData from TextContent
* @phpstan-import-type ImageContentData from ImageContent
* @phpstan-import-type AudioContentData from AudioContent
* @phpstan-import-type EmbeddedResourceData from EmbeddedResource
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
class CallToolResult implements ResultInterface
@ -44,8 +38,8 @@ class CallToolResult implements ResultInterface
/**
* Create a new CallToolResult.
*
* @param array<TextContent|ImageContent|AudioContent|EmbeddedResource> $content The content of the tool result
* @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful).
* @param Content[] $content The content of the tool result
* @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful).
*/
public function __construct(
public readonly array $content,
@ -61,7 +55,7 @@ class CallToolResult implements ResultInterface
/**
* Create a new CallToolResult with success status.
*
* @param array<TextContent|ImageContent|AudioContent|EmbeddedResource> $content The content of the tool result
* @param Content[] $content The content of the tool result
*/
public static function success(array $content): self
{
@ -71,7 +65,7 @@ class CallToolResult implements ResultInterface
/**
* Create a new CallToolResult with error status.
*
* @param array<TextContent|ImageContent|AudioContent|EmbeddedResource> $content The content of the tool result
* @param Content[] $content The content of the tool result
*/
public static function error(array $content): self
{
@ -80,7 +74,7 @@ class CallToolResult implements ResultInterface
/**
* @param array{
* content: array<TextContentData|ImageContentData|AudioContentData|EmbeddedResourceData>,
* content: array<mixed>,
* isError?: bool,
* } $data
*/
@ -107,7 +101,7 @@ class CallToolResult implements ResultInterface
/**
* @return array{
* content: array<TextContent|ImageContent|AudioContent|EmbeddedResource>,
* content: array<mixed>,
* isError: bool,
* }
*/

View file

@ -20,27 +20,24 @@ use Mcp\Capability\Discovery\Discoverer;
use Mcp\Capability\Discovery\DocBlockParser;
use Mcp\Capability\Discovery\HandlerResolver;
use Mcp\Capability\Discovery\SchemaGenerator;
use Mcp\Capability\Prompt\PromptGetter;
use Mcp\Capability\Prompt\PromptGetterInterface;
use Mcp\Capability\Registry;
use Mcp\Capability\Registry\Container;
use Mcp\Capability\Registry\ElementReference;
use Mcp\Capability\Registry\ReferenceHandler;
use Mcp\Capability\Resource\ResourceReader;
use Mcp\Capability\Resource\ResourceReaderInterface;
use Mcp\Capability\Tool\ToolCaller;
use Mcp\Capability\Tool\ToolCallerInterface;
use Mcp\Exception\ConfigurationException;
use Mcp\JsonRpc\MessageFactory;
use Mcp\Schema\Annotations;
use Mcp\Schema\Implementation;
use Mcp\Schema\Prompt;
use Mcp\Schema\PromptArgument;
use Mcp\Schema\Resource;
use Mcp\Schema\ResourceTemplate;
use Mcp\Schema\ServerCapabilities;
use Mcp\Schema\Tool;
use Mcp\Schema\ToolAnnotations;
use Mcp\Server;
use Mcp\Server\Handler\JsonRpcHandler;
use Mcp\Server\Handler\MethodHandlerInterface;
use Mcp\Server\Session\InMemorySessionStore;
use Mcp\Server\Session\SessionFactory;
use Mcp\Server\Session\SessionFactoryInterface;
@ -64,12 +61,6 @@ final class Builder
private ?CacheInterface $discoveryCache = null;
private ?ToolCallerInterface $toolCaller = null;
private ?ResourceReaderInterface $resourceReader = null;
private ?PromptGetterInterface $promptGetter = null;
private ?EventDispatcherInterface $eventDispatcher = null;
private ?ContainerInterface $container = null;
@ -84,6 +75,13 @@ final class Builder
private ?string $instructions = null;
private ?ServerCapabilities $explicitCapabilities = null;
/**
* @var array<int, MethodHandlerInterface>
*/
private array $customMethodHandlers = [];
/**
* @var array{
* handler: Handler,
@ -174,6 +172,40 @@ final class Builder
return $this;
}
/**
* Explicitly set server capabilities. If set, this overrides automatic detection.
*/
public function setCapabilities(ServerCapabilities $capabilities): self
{
$this->explicitCapabilities = $capabilities;
return $this;
}
/**
* Register a single custom method handler.
*/
public function addMethodHandler(MethodHandlerInterface $handler): self
{
$this->customMethodHandlers[] = $handler;
return $this;
}
/**
* Register multiple custom method handlers.
*
* @param iterable<int, MethodHandlerInterface> $handlers
*/
public function addMethodHandlers(iterable $handlers): self
{
foreach ($handlers as $handler) {
$this->customMethodHandlers[] = $handler;
}
return $this;
}
/**
* Provides a PSR-3 logger instance. Defaults to NullLogger.
*/
@ -191,27 +223,6 @@ final class Builder
return $this;
}
public function setToolCaller(ToolCallerInterface $toolCaller): self
{
$this->toolCaller = $toolCaller;
return $this;
}
public function setResourceReader(ResourceReaderInterface $resourceReader): self
{
$this->resourceReader = $resourceReader;
return $this;
}
public function setPromptGetter(PromptGetterInterface $promptGetter): self
{
$this->promptGetter = $promptGetter;
return $this;
}
/**
* Provides a PSR-11 DI container, primarily for resolving user-defined handler classes.
* Defaults to a basic internal container.
@ -333,49 +344,60 @@ final class Builder
public function build(): Server
{
$logger = $this->logger ?? new NullLogger();
$container = $this->container ?? new Container();
$registry = new Registry($this->eventDispatcher, $logger);
$referenceHandler = new ReferenceHandler($container);
$toolCaller = $this->toolCaller ??= new ToolCaller($registry, $referenceHandler, $logger);
$resourceReader = $this->resourceReader ??= new ResourceReader($registry, $referenceHandler, $logger);
$promptGetter = $this->promptGetter ??= new PromptGetter($registry, $referenceHandler, $logger);
$this->registerCapabilities($registry, $logger);
if (null !== $this->discoveryBasePath) {
$discovery = new Discoverer($registry, $logger);
if (null !== $this->discoveryCache) {
$discovery = new CachedDiscoverer($discovery, $this->discoveryCache, $logger);
}
$discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs);
$this->performDiscovery($registry, $logger);
}
$sessionTtl = $this->sessionTtl ?? 3600;
$sessionFactory = $this->sessionFactory ?? new SessionFactory();
$sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl);
$messageFactory = MessageFactory::make();
return new Server(
jsonRpcHandler: JsonRpcHandler::make(
registry: $registry,
referenceProvider: $registry,
configuration: new Configuration(
$this->serverInfo,
$registry->getCapabilities(),
$this->paginationLimit, $this->instructions,
),
toolCaller: $toolCaller,
resourceReader: $resourceReader,
promptGetter: $promptGetter,
sessionStore: $sessionStore,
sessionFactory: $sessionFactory,
logger: $logger,
),
$capabilities = $this->explicitCapabilities ?? $registry->getCapabilities();
$configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions);
$referenceHandler = new ReferenceHandler($container);
$methodHandlers = array_merge($this->customMethodHandlers, [
new Handler\Request\PingHandler(),
new Handler\Request\InitializeHandler($configuration),
new Handler\Request\ListToolsHandler($registry, $this->paginationLimit),
new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger),
new Handler\Request\ListResourcesHandler($registry, $this->paginationLimit),
new Handler\Request\ListResourceTemplatesHandler($registry, $this->paginationLimit),
new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger),
new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit),
new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger),
new Handler\Notification\InitializedHandler(),
]);
$jsonRpcHandler = new JsonRpcHandler(
methodHandlers: $methodHandlers,
messageFactory: $messageFactory,
sessionFactory: $sessionFactory,
sessionStore: $sessionStore,
logger: $logger,
);
return new Server($jsonRpcHandler, $logger);
}
private function performDiscovery(
Registry\ReferenceRegistryInterface $registry,
LoggerInterface $logger,
): void {
$discovery = new Discoverer($registry, $logger);
if (null !== $this->discoveryCache) {
$discovery = new CachedDiscoverer($discovery, $this->discoveryCache, $logger);
}
$discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs);
}
/**

View file

@ -11,12 +11,6 @@
namespace Mcp\Server\Handler;
use Mcp\Capability\Completion\Completer;
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;
@ -27,8 +21,6 @@ use Mcp\Schema\JsonRpc\HasMethodInterface;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\InitializeRequest;
use Mcp\Server\Configuration;
use Mcp\Server\Handler;
use Mcp\Server\Session\SessionFactoryInterface;
use Mcp\Server\Session\SessionInterface;
use Mcp\Server\Session\SessionStoreInterface;
@ -44,56 +36,15 @@ use Symfony\Component\Uid\Uuid;
class JsonRpcHandler
{
/**
* @var array<int, MethodHandlerInterface>
*/
private readonly array $methodHandlers;
/**
* @param iterable<int, MethodHandlerInterface> $methodHandlers
* @param array<int, MethodHandlerInterface> $methodHandlers
*/
public function __construct(
private readonly array $methodHandlers,
private readonly MessageFactory $messageFactory,
private readonly SessionFactoryInterface $sessionFactory,
private readonly SessionStoreInterface $sessionStore,
iterable $methodHandlers,
private readonly LoggerInterface $logger = new NullLogger(),
) {
$this->methodHandlers = $methodHandlers instanceof \Traversable ? iterator_to_array(
$methodHandlers,
) : $methodHandlers;
}
public static function make(
ReferenceRegistryInterface $registry,
ReferenceProviderInterface $referenceProvider,
Configuration $configuration,
ToolCallerInterface $toolCaller,
ResourceReaderInterface $resourceReader,
PromptGetterInterface $promptGetter,
SessionStoreInterface $sessionStore,
SessionFactoryInterface $sessionFactory,
LoggerInterface $logger = new NullLogger(),
int $paginationLimit = 50,
): self {
return new self(
messageFactory: MessageFactory::make(),
sessionFactory: $sessionFactory,
sessionStore: $sessionStore,
methodHandlers: [
new Notification\InitializedHandler(),
new Handler\Request\InitializeHandler($configuration),
new Handler\Request\PingHandler(),
new Handler\Request\ListPromptsHandler($referenceProvider, $paginationLimit),
new Handler\Request\GetPromptHandler($promptGetter),
new Handler\Request\ListResourcesHandler($referenceProvider, $paginationLimit),
new Handler\Request\ReadResourceHandler($resourceReader),
new Handler\Request\ListResourceTemplatesHandler($referenceProvider, $paginationLimit),
new Handler\Request\CallToolHandler($toolCaller, $logger),
new Handler\Request\ListToolsHandler($referenceProvider, $paginationLimit),
new Handler\Request\CompletionCompleteHandler(new Completer($referenceProvider)),
],
logger: $logger,
);
}
/**

View file

@ -11,12 +11,16 @@
namespace Mcp\Server\Handler\Request;
use Mcp\Capability\Tool\ToolCallerInterface;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Exception\ExceptionInterface;
use Mcp\Exception\ToolCallException;
use Mcp\Exception\ToolNotFoundException;
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\Handler\MethodHandlerInterface;
use Mcp\Server\Session\SessionInterface;
use Psr\Log\LoggerInterface;
@ -29,7 +33,8 @@ use Psr\Log\NullLogger;
final class CallToolHandler implements MethodHandlerInterface
{
public function __construct(
private readonly ToolCallerInterface $toolCaller,
private readonly ReferenceProviderInterface $referenceProvider,
private readonly ReferenceHandlerInterface $referenceHandler,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}
@ -43,20 +48,44 @@ final class CallToolHandler implements MethodHandlerInterface
{
\assert($message instanceof CallToolRequest);
$toolName = $message->name;
$arguments = $message->arguments ?? [];
$this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]);
try {
$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,
],
);
$reference = $this->referenceProvider->getTool($toolName);
if (null === $reference) {
throw new ToolNotFoundException($message);
}
$result = $this->referenceHandler->handle($reference, $arguments);
$formatted = $reference->formatResult($result);
$this->logger->debug('Tool executed successfully', [
'name' => $toolName,
'result_type' => \gettype($result),
]);
return new Response($message->getId(), new CallToolResult($formatted));
} catch (ToolNotFoundException $e) {
$this->logger->error('Tool not found', ['name' => $toolName]);
return new Error($message->getId(), Error::METHOD_NOT_FOUND, $e->getMessage());
} catch (ToolCallException|ExceptionInterface $e) {
$this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [
'tool' => $toolName,
'arguments' => $arguments,
]);
return Error::forInternalError('Error while executing tool', $message->getId());
} catch (\Throwable $e) {
$this->logger->error('Unhandled error during tool execution', [
'name' => $toolName,
'exception' => $e->getMessage(),
]);
return Error::forInternalError('Error while executing tool', $message->getId());
}
return new Response($message->getId(), $content);
}
}

View file

@ -11,14 +11,16 @@
namespace Mcp\Server\Handler\Request;
use Mcp\Capability\Completion\CompleterInterface;
use Mcp\Exception\ExceptionInterface;
use Mcp\Capability\Completion\ProviderInterface;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\HasMethodInterface;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\CompletionCompleteRequest;
use Mcp\Schema\Result\CompletionCompleteResult;
use Mcp\Server\Handler\MethodHandlerInterface;
use Mcp\Server\Session\SessionInterface;
use Psr\Container\ContainerInterface;
/**
* Handles completion/complete requests.
@ -28,7 +30,8 @@ use Mcp\Server\Session\SessionInterface;
final class CompletionCompleteHandler implements MethodHandlerInterface
{
public function __construct(
private readonly CompleterInterface $completer,
private readonly ReferenceProviderInterface $referenceProvider,
private readonly ?ContainerInterface $container = null,
) {
}
@ -41,12 +44,45 @@ final class CompletionCompleteHandler implements MethodHandlerInterface
{
\assert($message instanceof CompletionCompleteRequest);
try {
$result = $this->completer->complete($message);
} catch (ExceptionInterface) {
return Error::forInternalError('Error while handling completion request', $message->getId());
$name = $message->argument['name'] ?? '';
$value = $message->argument['value'] ?? '';
$reference = match ($message->ref->type) {
'ref/prompt' => $this->referenceProvider->getPrompt($message->ref->name),
'ref/resource' => $this->referenceProvider->getResourceTemplate($message->ref->uri),
default => null,
};
if (null === $reference) {
return new Response($message->getId(), new CompletionCompleteResult([]));
}
return new Response($message->getId(), $result);
$providers = $reference->completionProviders;
$provider = $providers[$name] ?? null;
if (null === $provider) {
return new Response($message->getId(), new CompletionCompleteResult([]));
}
if (\is_string($provider)) {
if (!class_exists($provider)) {
return Error::forInternalError('Invalid completion provider', $message->getId());
}
$provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider();
}
if (!$provider instanceof ProviderInterface) {
return Error::forInternalError('Invalid completion provider type', $message->getId());
}
try {
$completions = $provider->getCompletions($value);
$total = \count($completions);
$hasMore = $total > 100;
$paged = \array_slice($completions, 0, 100);
return new Response($message->getId(), new CompletionCompleteResult($paged, $total, $hasMore));
} catch (\Throwable) {
return Error::forInternalError('Error while handling completion request', $message->getId());
}
}
}

View file

@ -11,14 +11,20 @@
namespace Mcp\Server\Handler\Request;
use Mcp\Capability\Prompt\PromptGetterInterface;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Exception\ExceptionInterface;
use Mcp\Exception\PromptGetException;
use Mcp\Exception\PromptNotFoundException;
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\Handler\MethodHandlerInterface;
use Mcp\Server\Session\SessionInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
@ -26,7 +32,9 @@ use Mcp\Server\Session\SessionInterface;
final class GetPromptHandler implements MethodHandlerInterface
{
public function __construct(
private readonly PromptGetterInterface $promptGetter,
private readonly ReferenceProviderInterface $referenceProvider,
private readonly ReferenceHandlerInterface $referenceHandler,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}
@ -39,12 +47,32 @@ final class GetPromptHandler implements MethodHandlerInterface
{
\assert($message instanceof GetPromptRequest);
$promptName = $message->name;
$arguments = $message->arguments ?? [];
try {
$messages = $this->promptGetter->get($message);
} catch (ExceptionInterface) {
$reference = $this->referenceProvider->getPrompt($promptName);
if (null === $reference) {
throw new PromptNotFoundException($message);
}
$result = $this->referenceHandler->handle($reference, $arguments);
$formatted = $reference->formatResult($result);
return new Response($message->getId(), new GetPromptResult($formatted));
} catch (PromptNotFoundException $e) {
$this->logger->error('Prompt not found', ['prompt_name' => $promptName]);
return new Error($message->getId(), Error::METHOD_NOT_FOUND, $e->getMessage());
} catch (PromptGetException|ExceptionInterface $e) {
$this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]);
return Error::forInternalError('Error while handling prompt', $message->getId());
} catch (\Throwable $e) {
$this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]);
return Error::forInternalError('Error while handling prompt', $message->getId());
}
return new Response($message->getId(), $messages);
}
}

View file

@ -11,15 +11,19 @@
namespace Mcp\Server\Handler\Request;
use Mcp\Capability\Resource\ResourceReaderInterface;
use Mcp\Exception\ExceptionInterface;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Capability\Registry\ResourceTemplateReference;
use Mcp\Exception\ResourceNotFoundException;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\HasMethodInterface;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\ReadResourceRequest;
use Mcp\Schema\Result\ReadResourceResult;
use Mcp\Server\Handler\MethodHandlerInterface;
use Mcp\Server\Session\SessionInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
@ -27,7 +31,9 @@ use Mcp\Server\Session\SessionInterface;
final class ReadResourceHandler implements MethodHandlerInterface
{
public function __construct(
private readonly ResourceReaderInterface $resourceReader,
private readonly ReferenceProviderInterface $referenceProvider,
private readonly ReferenceHandlerInterface $referenceHandler,
private readonly LoggerInterface $logger = new NullLogger(),
) {
}
@ -40,14 +46,33 @@ final class ReadResourceHandler implements MethodHandlerInterface
{
\assert($message instanceof ReadResourceRequest);
$uri = $message->uri;
$this->logger->debug('Reading resource', ['uri' => $uri]);
try {
$contents = $this->resourceReader->read($message);
$reference = $this->referenceProvider->getResource($uri);
if (null === $reference) {
throw new ResourceNotFoundException($message);
}
$result = $this->referenceHandler->handle($reference, ['uri' => $uri]);
if ($reference instanceof ResourceTemplateReference) {
$formatted = $reference->formatResult($result, $uri, $reference->resourceTemplate->mimeType);
} else {
$formatted = $reference->formatResult($result, $uri, $reference->schema->mimeType);
}
return new Response($message->getId(), new ReadResourceResult($formatted));
} catch (ResourceNotFoundException $e) {
$this->logger->error('Resource not found', ['uri' => $uri]);
return new Error($message->getId(), Error::RESOURCE_NOT_FOUND, $e->getMessage());
} catch (ExceptionInterface) {
} catch (\Throwable $e) {
$this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage()));
return Error::forInternalError('Error while reading resource', $message->getId());
}
return new Response($message->getId(), $contents);
}
}

View file

@ -1,85 +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\Tests\Unit\Capability\Prompt\Completion;
use Mcp\Capability\Completion\EnumCompletionProvider;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Tests\Unit\Fixtures\Enum\PriorityEnum;
use Mcp\Tests\Unit\Fixtures\Enum\StatusEnum;
use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum;
use PHPUnit\Framework\TestCase;
class EnumCompletionProviderTest extends TestCase
{
public function testCreatesProviderFromStringBackedEnum()
{
$provider = new EnumCompletionProvider(StatusEnum::class);
$result = $provider->getCompletions('');
$this->assertSame(['draft', 'published', 'archived'], $result);
}
public function testCreatesProviderFromIntBackedEnumUsingNames()
{
$provider = new EnumCompletionProvider(PriorityEnum::class);
$result = $provider->getCompletions('');
$this->assertSame(['LOW', 'MEDIUM', 'HIGH'], $result);
}
public function testCreatesProviderFromUnitEnumUsingNames()
{
$provider = new EnumCompletionProvider(UnitEnum::class);
$result = $provider->getCompletions('');
$this->assertSame(['Yes', 'No'], $result);
}
public function testFiltersStringEnumValuesByPrefix()
{
$provider = new EnumCompletionProvider(StatusEnum::class);
$result = $provider->getCompletions('ar');
$this->assertEquals(['archived'], $result);
}
public function testFiltersUnitEnumValuesByPrefix()
{
$provider = new EnumCompletionProvider(UnitEnum::class);
$result = $provider->getCompletions('Y');
$this->assertSame(['Yes'], $result);
}
public function testReturnsEmptyArrayWhenNoValuesMatchPrefix()
{
$provider = new EnumCompletionProvider(StatusEnum::class);
$result = $provider->getCompletions('xyz');
$this->assertSame([], $result);
}
public function testThrowsExceptionForNonEnumClass()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Class "stdClass" is not an enum.');
new EnumCompletionProvider(\stdClass::class);
}
public function testThrowsExceptionForNonExistentClass()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Class "NonExistentClass" is not an enum.');
new EnumCompletionProvider('NonExistentClass'); /* @phpstan-ignore argument.type */
}
}

View file

@ -1,80 +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\Tests\Unit\Capability\Prompt\Completion;
use Mcp\Capability\Completion\ListCompletionProvider;
use PHPUnit\Framework\TestCase;
class ListCompletionProviderTest extends TestCase
{
public function testReturnsAllValuesWhenCurrentValueIsEmpty()
{
$values = ['apple', 'banana', 'cherry'];
$provider = new ListCompletionProvider($values);
$result = $provider->getCompletions('');
$this->assertSame($values, $result);
}
public function testFiltersValuesBasedOnCurrentValuePrefix()
{
$values = ['apple', 'apricot', 'banana', 'cherry'];
$provider = new ListCompletionProvider($values);
$result = $provider->getCompletions('ap');
$this->assertSame(['apple', 'apricot'], $result);
}
public function testReturnsEmptyArrayWhenNoValuesMatch()
{
$values = ['apple', 'banana', 'cherry'];
$provider = new ListCompletionProvider($values);
$result = $provider->getCompletions('xyz');
$this->assertSame([], $result);
}
public function testWorksWithSingleCharacterPrefix()
{
$values = ['apple', 'banana', 'cherry'];
$provider = new ListCompletionProvider($values);
$result = $provider->getCompletions('a');
$this->assertSame(['apple'], $result);
}
public function testIsCaseSensitiveByDefault()
{
$values = ['Apple', 'apple', 'APPLE'];
$provider = new ListCompletionProvider($values);
$result = $provider->getCompletions('A');
$this->assertEquals(['Apple', 'APPLE'], $result);
}
public function testHandlesEmptyValuesArray()
{
$provider = new ListCompletionProvider([]);
$result = $provider->getCompletions('test');
$this->assertSame([], $result);
}
public function testPreservesArrayOrder()
{
$values = ['zebra', 'apple', 'banana'];
$provider = new ListCompletionProvider($values);
$result = $provider->getCompletions('');
$this->assertSame(['zebra', 'apple', 'banana'], $result);
}
}

View file

@ -1,638 +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\Tests\Unit\Capability\Prompt;
use Mcp\Capability\Completion\EnumCompletionProvider;
use Mcp\Capability\Prompt\PromptGetter;
use Mcp\Capability\Registry\PromptReference;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\Registry\ReferenceProviderInterface;
use Mcp\Exception\PromptGetException;
use Mcp\Exception\PromptNotFoundException;
use Mcp\Exception\RegistryException;
use Mcp\Schema\Content\PromptMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Prompt;
use Mcp\Schema\Request\GetPromptRequest;
use Mcp\Schema\Result\GetPromptResult;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class PromptGetterTest extends TestCase
{
private PromptGetter $promptGetter;
private ReferenceProviderInterface|MockObject $referenceProvider;
private ReferenceHandlerInterface|MockObject $referenceHandler;
protected function setUp(): void
{
$this->referenceProvider = $this->createMock(ReferenceProviderInterface::class);
$this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class);
$this->promptGetter = new PromptGetter(
$this->referenceProvider,
$this->referenceHandler,
);
}
public function testGetExecutesPromptSuccessfully(): void
{
$request = new GetPromptRequest('test_prompt', ['param' => 'value']);
$prompt = $this->createValidPrompt('test_prompt');
$promptReference = new PromptReference($prompt, fn () => 'test result');
$handlerResult = [
'role' => 'user',
'content' => 'Generated prompt content',
];
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('test_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, ['param' => 'value'])
->willReturn($handlerResult);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
$this->assertInstanceOf(PromptMessage::class, $result->messages[0]);
$this->assertEquals(Role::User, $result->messages[0]->role);
$this->assertInstanceOf(TextContent::class, $result->messages[0]->content);
$this->assertEquals('Generated prompt content', $result->messages[0]->content->text);
}
public function testGetWithEmptyArguments(): void
{
$request = new GetPromptRequest('empty_args_prompt', []);
$prompt = $this->createValidPrompt('empty_args_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Empty args content');
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('empty_args_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn([
'role' => 'user',
'content' => 'Empty args content',
]);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
}
public function testGetWithComplexArguments(): void
{
$arguments = [
'string_param' => 'value',
'int_param' => 42,
'bool_param' => true,
'array_param' => ['nested' => 'data'],
'null_param' => null,
];
$request = new GetPromptRequest('complex_prompt', $arguments);
$prompt = $this->createValidPrompt('complex_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Complex content');
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('complex_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, $arguments)
->willReturn([
'role' => 'assistant',
'content' => 'Complex response',
]);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
}
public function testGetThrowsInvalidArgumentExceptionWhenPromptNotFound(): void
{
$request = new GetPromptRequest('nonexistent_prompt', ['param' => 'value']);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('nonexistent_prompt')
->willReturn(null);
$this->referenceHandler
->expects($this->never())
->method('handle');
$this->expectException(PromptNotFoundException::class);
$this->expectExceptionMessage('Prompt not found for name: "nonexistent_prompt".');
$this->promptGetter->get($request);
}
public function testGetThrowsRegistryExceptionWhenHandlerFails(): void
{
$request = new GetPromptRequest('failing_prompt', ['param' => 'value']);
$prompt = $this->createValidPrompt('failing_prompt');
$promptReference = new PromptReference($prompt, fn () => throw new \RuntimeException('Handler failed'));
$handlerException = RegistryException::internalError('Handler failed');
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('failing_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, ['param' => 'value'])
->willThrowException($handlerException);
$this->expectException(PromptGetException::class);
$this->promptGetter->get($request);
}
public function testGetHandlesJsonExceptionDuringFormatting(): void
{
$request = new GetPromptRequest('json_error_prompt', []);
$prompt = $this->createValidPrompt('json_error_prompt');
// Create a mock PromptReference that will throw JsonException during formatResult
$promptReference = $this->createMock(PromptReference::class);
$promptReference->expects($this->once())
->method('formatResult')
->willThrowException(new \JsonException('JSON encoding failed'));
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('json_error_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn('some result');
$this->expectException(PromptGetException::class);
$this->expectExceptionMessage('JSON encoding failed');
$this->promptGetter->get($request);
}
public function testGetHandlesArrayOfMessages(): void
{
$request = new GetPromptRequest('multi_message_prompt', ['context' => 'test']);
$prompt = $this->createValidPrompt('multi_message_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Multiple messages');
$handlerResult = [
[
'role' => 'user',
'content' => 'First message',
],
[
'role' => 'assistant',
'content' => 'Second message',
],
];
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('multi_message_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, ['context' => 'test'])
->willReturn($handlerResult);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(2, $result->messages);
$this->assertEquals(Role::User, $result->messages[0]->role);
$this->assertEquals('First message', $result->messages[0]->content->text);
$this->assertEquals(Role::Assistant, $result->messages[1]->role);
$this->assertEquals('Second message', $result->messages[1]->content->text);
}
public function testGetHandlesPromptMessageObjects(): void
{
$request = new GetPromptRequest('prompt_message_prompt', []);
$prompt = $this->createValidPrompt('prompt_message_prompt');
$promptMessage = new PromptMessage(
Role::User,
new TextContent('Direct prompt message')
);
$promptReference = new PromptReference($prompt, fn () => $promptMessage);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('prompt_message_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn($promptMessage);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
$this->assertSame($promptMessage, $result->messages[0]);
}
public function testGetHandlesUserAssistantStructure(): void
{
$request = new GetPromptRequest('user_assistant_prompt', []);
$prompt = $this->createValidPrompt('user_assistant_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Conversation content');
$handlerResult = [
'user' => 'What is the weather?',
'assistant' => 'I can help you check the weather.',
];
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('user_assistant_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn($handlerResult);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(2, $result->messages);
$this->assertEquals(Role::User, $result->messages[0]->role);
$this->assertEquals('What is the weather?', $result->messages[0]->content->text);
$this->assertEquals(Role::Assistant, $result->messages[1]->role);
$this->assertEquals('I can help you check the weather.', $result->messages[1]->content->text);
}
public function testGetHandlesEmptyArrayResult(): void
{
$request = new GetPromptRequest('empty_array_prompt', []);
$prompt = $this->createValidPrompt('empty_array_prompt');
$promptReference = new PromptReference($prompt, fn () => []);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('empty_array_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn([]);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(0, $result->messages);
}
public function testGetWithTypedContentStructure(): void
{
$request = new GetPromptRequest('typed_content_prompt', []);
$prompt = $this->createValidPrompt('typed_content_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Typed content');
$handlerResult = [
'role' => 'user',
'content' => [
'type' => 'text',
'text' => 'Typed text content',
],
];
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('typed_content_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn($handlerResult);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
$this->assertEquals(Role::User, $result->messages[0]->role);
$this->assertEquals('Typed text content', $result->messages[0]->content->text);
}
public function testGetWithPromptReferenceHavingCompletionProviders(): void
{
$request = new GetPromptRequest('completion_prompt', ['param' => 'value']);
$prompt = $this->createValidPrompt('completion_prompt');
$completionProviders = ['param' => EnumCompletionProvider::class];
$promptReference = new PromptReference(
$prompt,
fn () => 'Completion content',
false,
$completionProviders
);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('completion_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, ['param' => 'value'])
->willReturn([
'role' => 'user',
'content' => 'Completion content',
]);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
}
public function testGetHandlesMixedMessageArray(): void
{
$request = new GetPromptRequest('mixed_prompt', []);
$prompt = $this->createValidPrompt('mixed_prompt');
$promptMessage = new PromptMessage(Role::Assistant, new TextContent('Direct message'));
$promptReference = new PromptReference($prompt, fn () => 'Mixed content');
$handlerResult = [
$promptMessage,
[
'role' => 'user',
'content' => 'Regular message',
],
];
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('mixed_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn($handlerResult);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(2, $result->messages);
$this->assertSame($promptMessage, $result->messages[0]);
$this->assertEquals(Role::User, $result->messages[1]->role);
$this->assertEquals('Regular message', $result->messages[1]->content->text);
}
public function testGetReflectsFormattedMessagesCorrectly(): void
{
$request = new GetPromptRequest('format_test_prompt', []);
$prompt = $this->createValidPrompt('format_test_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Format test');
// Test that the formatted result from PromptReference.formatResult is properly returned
$handlerResult = [
'role' => 'user',
'content' => 'Test formatting',
];
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('format_test_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn($handlerResult);
$result = $this->promptGetter->get($request);
$this->assertInstanceOf(GetPromptResult::class, $result);
$this->assertCount(1, $result->messages);
$this->assertEquals('Test formatting', $result->messages[0]->content->text);
$this->assertEquals(Role::User, $result->messages[0]->role);
}
/**
* Test that invalid handler results throw RuntimeException from PromptReference.formatResult().
*/
public function testGetThrowsRuntimeExceptionForInvalidHandlerResult(): void
{
$request = new GetPromptRequest('invalid_prompt', []);
$prompt = $this->createValidPrompt('invalid_prompt');
$promptReference = new PromptReference($prompt, fn () => 'Invalid content');
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('invalid_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn('This is not a valid prompt format');
$this->expectException(PromptGetException::class);
$this->expectExceptionMessage('Prompt generator method must return an array of messages.');
$this->promptGetter->get($request);
}
/**
* Test that null result from handler throws RuntimeException.
*/
public function testGetThrowsRuntimeExceptionForNullHandlerResult(): void
{
$request = new GetPromptRequest('null_prompt', []);
$prompt = $this->createValidPrompt('null_prompt');
$promptReference = new PromptReference($prompt, fn () => null);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('null_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn(null);
$this->expectException(PromptGetException::class);
$this->expectExceptionMessage('Prompt generator method must return an array of messages.');
$this->promptGetter->get($request);
}
/**
* Test that scalar result from handler throws RuntimeException.
*/
public function testGetThrowsRuntimeExceptionForScalarHandlerResult(): void
{
$request = new GetPromptRequest('scalar_prompt', []);
$prompt = $this->createValidPrompt('scalar_prompt');
$promptReference = new PromptReference($prompt, fn () => 42);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('scalar_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn(42);
$this->expectException(PromptGetException::class);
$this->expectExceptionMessage('Prompt generator method must return an array of messages.');
$this->promptGetter->get($request);
}
/**
* Test that boolean result from handler throws RuntimeException.
*/
public function testGetThrowsRuntimeExceptionForBooleanHandlerResult(): void
{
$request = new GetPromptRequest('boolean_prompt', []);
$prompt = $this->createValidPrompt('boolean_prompt');
$promptReference = new PromptReference($prompt, fn () => true);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('boolean_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn(true);
$this->expectException(PromptGetException::class);
$this->expectExceptionMessage('Prompt generator method must return an array of messages.');
$this->promptGetter->get($request);
}
/**
* Test that object result from handler throws RuntimeException.
*/
public function testGetThrowsRuntimeExceptionForObjectHandlerResult(): void
{
$request = new GetPromptRequest('object_prompt', []);
$prompt = $this->createValidPrompt('object_prompt');
$objectResult = new \stdClass();
$objectResult->property = 'value';
$promptReference = new PromptReference($prompt, fn () => $objectResult);
$this->referenceProvider
->expects($this->once())
->method('getPrompt')
->with('object_prompt')
->willReturn($promptReference);
$this->referenceHandler
->expects($this->once())
->method('handle')
->with($promptReference, [])
->willReturn($objectResult);
$this->expectException(PromptGetException::class);
$this->expectExceptionMessage('Prompt generator method must return an array of messages.');
$this->promptGetter->get($request);
}
public function testConstructorWithDefaultLogger(): void
{
$promptGetter = new PromptGetter(
$this->referenceProvider,
$this->referenceHandler,
);
$this->assertInstanceOf(PromptGetter::class, $promptGetter);
}
public function testConstructorWithCustomLogger(): void
{
$logger = $this->createMock(LoggerInterface::class);
$promptGetter = new PromptGetter(
$this->referenceProvider,
$this->referenceHandler,
$logger,
);
$this->assertInstanceOf(PromptGetter::class, $promptGetter);
}
private function createValidPrompt(string $name): Prompt
{
return new Prompt(
name: $name,
description: "Test prompt: {$name}",
arguments: null,
);
}
}

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