[Server] Fix: standardize error handling across handlers with MCP specificatio… (#124)
* fix: standardize error handling across handlers with MCP specification compliance * Update mcp-elements.md Co-authored-by: Christopher Hertel <mail@christopher-hertel.de> * docs: clarify error handling behavior for all element types * docs: remove unnecessary whitespace in mcp-elements.md * fix: simplify exception signatures to accept simple messages * refactor: use specific exceptions in examples for better error handling * feat: throw exceptions from reference provider instead of returning null --------- Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
This commit is contained in:
parent
8885d29a4e
commit
ca18caf29d
23 changed files with 278 additions and 185 deletions
|
|
@ -154,16 +154,21 @@ public function getMultipleContent(): array
|
|||
|
||||
#### Error Handling
|
||||
|
||||
Tools can throw exceptions which are automatically converted to proper JSON-RPC error responses:
|
||||
Tool handlers can throw any exception, but the type determines how it's handled:
|
||||
|
||||
- **`ToolCallException`**: Converted to JSON-RPC response with `CallToolResult` where `isError: true`, allowing the LLM to see the error message and self-correct
|
||||
- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message
|
||||
|
||||
```php
|
||||
use Mcp\Exception\ToolCallException;
|
||||
|
||||
#[McpTool]
|
||||
public function divideNumbers(float $a, float $b): float
|
||||
{
|
||||
if ($b === 0.0) {
|
||||
throw new \InvalidArgumentException('Division by zero is not allowed');
|
||||
throw new ToolCallException('Division by zero is not allowed');
|
||||
}
|
||||
|
||||
|
||||
return $a / $b;
|
||||
}
|
||||
|
||||
|
|
@ -171,14 +176,15 @@ public function divideNumbers(float $a, float $b): float
|
|||
public function processFile(string $filename): string
|
||||
{
|
||||
if (!file_exists($filename)) {
|
||||
throw new \InvalidArgumentException("File not found: {$filename}");
|
||||
throw new ToolCallException("File not found: {$filename}");
|
||||
}
|
||||
|
||||
|
||||
return file_get_contents($filename);
|
||||
}
|
||||
```
|
||||
|
||||
The SDK will convert these exceptions into appropriate JSON-RPC error responses that MCP clients can understand.
|
||||
**Recommendation**: Use `ToolCallException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages.
|
||||
|
||||
|
||||
## Resources
|
||||
|
||||
|
|
@ -298,24 +304,31 @@ public function getMultipleResources(): array
|
|||
|
||||
#### Error Handling
|
||||
|
||||
Resource handlers can throw exceptions for error cases:
|
||||
Resource handlers can throw any exception, but the type determines how it's handled:
|
||||
|
||||
- **`ResourceReadException`**: Converted to JSON-RPC error response with the actual exception message
|
||||
- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message
|
||||
|
||||
```php
|
||||
use Mcp\Exception\ResourceReadException;
|
||||
|
||||
#[McpResource(uri: 'file://{path}')]
|
||||
public function getFile(string $path): string
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
throw new \InvalidArgumentException("File not found: {$path}");
|
||||
throw new ResourceReadException("File not found: {$path}");
|
||||
}
|
||||
|
||||
|
||||
if (!is_readable($path)) {
|
||||
throw new \RuntimeException("File not readable: {$path}");
|
||||
throw new ResourceReadException("File not readable: {$path}");
|
||||
}
|
||||
|
||||
|
||||
return file_get_contents($path);
|
||||
}
|
||||
```
|
||||
|
||||
**Recommendation**: Use `ResourceReadException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages.
|
||||
|
||||
## Resource Templates
|
||||
|
||||
Resource templates are **dynamic resources** that use parameterized URIs with variables. They follow all the same rules
|
||||
|
|
@ -449,6 +462,8 @@ public function explicitMessages(): array
|
|||
}
|
||||
```
|
||||
|
||||
The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format.
|
||||
|
||||
#### Valid Message Roles
|
||||
|
||||
- **`user`**: User input or questions
|
||||
|
|
@ -456,33 +471,35 @@ public function explicitMessages(): array
|
|||
|
||||
#### Error Handling
|
||||
|
||||
Prompt handlers can throw exceptions for invalid inputs:
|
||||
Prompt handlers can throw any exception, but the type determines how it's handled:
|
||||
- **`PromptGetException`**: Converted to JSON-RPC error response with the actual exception message
|
||||
- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message
|
||||
|
||||
```php
|
||||
use Mcp\Exception\PromptGetException;
|
||||
|
||||
#[McpPrompt]
|
||||
public function generatePrompt(string $topic, string $style): array
|
||||
{
|
||||
$validStyles = ['casual', 'formal', 'technical'];
|
||||
|
||||
|
||||
if (!in_array($style, $validStyles)) {
|
||||
throw new \InvalidArgumentException(
|
||||
throw new PromptGetException(
|
||||
"Invalid style '{$style}'. Must be one of: " . implode(', ', $validStyles)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return [
|
||||
['role' => 'user', 'content' => "Write about {$topic} in a {$style} style"]
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format.
|
||||
**Recommendation**: Use `PromptGetException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages.
|
||||
|
||||
## Completion Providers
|
||||
|
||||
Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools
|
||||
and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have
|
||||
dynamic parameters that benefit from completion hints.
|
||||
Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have dynamic parameters that benefit from completion hints.
|
||||
|
||||
### Completion Provider Types
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ chdir(__DIR__);
|
|||
|
||||
use Http\Discovery\Psr17Factory;
|
||||
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
|
||||
use Mcp\Exception\ToolCallException;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Enum\LoggingLevel;
|
||||
use Mcp\Schema\JsonRpc\Error as JsonRpcError;
|
||||
|
|
@ -64,7 +65,7 @@ $server = Server::builder()
|
|||
);
|
||||
|
||||
if ($response instanceof JsonRpcError) {
|
||||
throw new RuntimeException(sprintf('Sampling request failed (%d): %s', $response->code, $response->message));
|
||||
throw new ToolCallException(sprintf('Sampling request failed (%d): %s', $response->code, $response->message));
|
||||
}
|
||||
|
||||
$result = $response->result;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ use Mcp\Capability\Attribute\McpPrompt;
|
|||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Capability\Attribute\McpResourceTemplate;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Exception\PromptGetException;
|
||||
use Mcp\Exception\ResourceReadException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
|
|
@ -48,7 +49,7 @@ final class McpElements
|
|||
*
|
||||
* @return User user profile data
|
||||
*
|
||||
* @throws InvalidArgumentException if the user is not found
|
||||
* @throws ResourceReadException if the user is not found
|
||||
*/
|
||||
#[McpResourceTemplate(
|
||||
uriTemplate: 'user://{userId}/profile',
|
||||
|
|
@ -62,7 +63,7 @@ final class McpElements
|
|||
): array {
|
||||
$this->logger->info('Reading resource: user profile', ['userId' => $userId]);
|
||||
if (!isset($this->users[$userId])) {
|
||||
throw new InvalidArgumentException("User profile not found for ID: {$userId}");
|
||||
throw new ResourceReadException("User not found for ID: {$userId}");
|
||||
}
|
||||
|
||||
return $this->users[$userId];
|
||||
|
|
@ -130,7 +131,7 @@ final class McpElements
|
|||
*
|
||||
* @return array<string, string>[] prompt messages
|
||||
*
|
||||
* @throws InvalidArgumentException if user not found
|
||||
* @throws PromptGetException if user not found
|
||||
*/
|
||||
#[McpPrompt(name: 'generate_bio_prompt')]
|
||||
public function generateBio(
|
||||
|
|
@ -140,7 +141,7 @@ final class McpElements
|
|||
): array {
|
||||
$this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]);
|
||||
if (!isset($this->users[$userId])) {
|
||||
throw new InvalidArgumentException("User not found for bio prompt: {$userId}");
|
||||
throw new PromptGetException("User not found for bio prompt: {$userId}");
|
||||
}
|
||||
$user = $this->users[$userId];
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ declare(strict_types=1);
|
|||
namespace Mcp\Example\StdioCachedDiscovery;
|
||||
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Exception\ToolCallException;
|
||||
|
||||
/**
|
||||
* Example MCP elements for demonstrating cached discovery.
|
||||
|
|
@ -39,7 +40,7 @@ class CachedCalculatorElements
|
|||
public function divide(int $a, int $b): float
|
||||
{
|
||||
if (0 === $b) {
|
||||
throw new \InvalidArgumentException('Division by zero is not allowed');
|
||||
throw new ToolCallException('Division by zero is not allowed');
|
||||
}
|
||||
|
||||
return $a / $b;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ namespace Mcp\Example\StdioDiscoveryCalculator;
|
|||
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Exception\ToolCallException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
|
|
@ -44,10 +45,10 @@ final class McpElements
|
|||
* @param float $b the second operand
|
||||
* @param string $operation the operation ('add', 'subtract', 'multiply', 'divide')
|
||||
*
|
||||
* @return float|string the result of the calculation, or an error message string
|
||||
* @return float the result of the calculation
|
||||
*/
|
||||
#[McpTool(name: 'calculate')]
|
||||
public function calculate(float $a, float $b, string $operation): float|string
|
||||
public function calculate(float $a, float $b, string $operation): float
|
||||
{
|
||||
$this->logger->info(\sprintf('Calculating: %f %s %f', $a, $operation, $b));
|
||||
|
||||
|
|
@ -65,16 +66,16 @@ final class McpElements
|
|||
break;
|
||||
case 'divide':
|
||||
if (0 == $b) {
|
||||
return 'Error: Division by zero.';
|
||||
throw new ToolCallException('Division by zero is not allowed.');
|
||||
}
|
||||
$result = $a / $b;
|
||||
break;
|
||||
default:
|
||||
return "Error: Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide.";
|
||||
throw new ToolCallException("Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide.");
|
||||
}
|
||||
|
||||
if (!$this->config['allow_negative'] && $result < 0) {
|
||||
return 'Error: Negative results are disabled.';
|
||||
throw new ToolCallException('Negative results are disabled.');
|
||||
}
|
||||
|
||||
return round($result, $this->config['precision']);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ use Mcp\Event\ResourceListChangedEvent;
|
|||
use Mcp\Event\ResourceTemplateListChangedEvent;
|
||||
use Mcp\Event\ToolListChangedEvent;
|
||||
use Mcp\Exception\InvalidCursorException;
|
||||
use Mcp\Exception\PromptNotFoundException;
|
||||
use Mcp\Exception\ResourceNotFoundException;
|
||||
use Mcp\Exception\ToolNotFoundException;
|
||||
use Mcp\Schema\Page;
|
||||
use Mcp\Schema\Prompt;
|
||||
use Mcp\Schema\Resource;
|
||||
|
|
@ -209,43 +212,41 @@ final class Registry implements ReferenceProviderInterface, ReferenceRegistryInt
|
|||
}
|
||||
}
|
||||
|
||||
public function getTool(string $name): ?ToolReference
|
||||
public function getTool(string $name): ToolReference
|
||||
{
|
||||
return $this->tools[$name] ?? null;
|
||||
return $this->tools[$name] ?? throw new ToolNotFoundException($name);
|
||||
}
|
||||
|
||||
public function getResource(
|
||||
string $uri,
|
||||
bool $includeTemplates = true,
|
||||
): ResourceReference|ResourceTemplateReference|null {
|
||||
): ResourceReference|ResourceTemplateReference {
|
||||
$registration = $this->resources[$uri] ?? null;
|
||||
if ($registration) {
|
||||
return $registration;
|
||||
}
|
||||
|
||||
if (!$includeTemplates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->resourceTemplates as $template) {
|
||||
if ($template->matches($uri)) {
|
||||
return $template;
|
||||
if ($includeTemplates) {
|
||||
foreach ($this->resourceTemplates as $template) {
|
||||
if ($template->matches($uri)) {
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->debug('No resource matched URI.', ['uri' => $uri]);
|
||||
|
||||
return null;
|
||||
throw new ResourceNotFoundException($uri);
|
||||
}
|
||||
|
||||
public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateReference
|
||||
public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference
|
||||
{
|
||||
return $this->resourceTemplates[$uriTemplate] ?? null;
|
||||
return $this->resourceTemplates[$uriTemplate] ?? throw new ResourceNotFoundException($uriTemplate);
|
||||
}
|
||||
|
||||
public function getPrompt(string $name): ?PromptReference
|
||||
public function getPrompt(string $name): PromptReference
|
||||
{
|
||||
return $this->prompts[$name] ?? null;
|
||||
return $this->prompts[$name] ?? throw new PromptNotFoundException($name);
|
||||
}
|
||||
|
||||
public function getTools(?int $limit = null, ?string $cursor = null): Page
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
|
||||
namespace Mcp\Capability\Registry;
|
||||
|
||||
use Mcp\Exception\PromptNotFoundException;
|
||||
use Mcp\Exception\ResourceNotFoundException;
|
||||
use Mcp\Exception\ToolNotFoundException;
|
||||
use Mcp\Schema\Page;
|
||||
|
||||
/**
|
||||
|
|
@ -23,23 +26,31 @@ interface ReferenceProviderInterface
|
|||
{
|
||||
/**
|
||||
* Gets a tool reference by name.
|
||||
*
|
||||
* @throws ToolNotFoundException
|
||||
*/
|
||||
public function getTool(string $name): ?ToolReference;
|
||||
public function getTool(string $name): ToolReference;
|
||||
|
||||
/**
|
||||
* Gets a resource reference by URI (includes template matching if enabled).
|
||||
*
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference|null;
|
||||
public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference;
|
||||
|
||||
/**
|
||||
* Gets a resource template reference by URI template.
|
||||
*
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateReference;
|
||||
public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference;
|
||||
|
||||
/**
|
||||
* Gets a prompt reference by name.
|
||||
*
|
||||
* @throws PromptNotFoundException
|
||||
*/
|
||||
public function getPrompt(string $name): ?PromptReference;
|
||||
public function getPrompt(string $name): PromptReference;
|
||||
|
||||
/**
|
||||
* Gets all registered tools.
|
||||
|
|
|
|||
|
|
@ -11,17 +11,9 @@
|
|||
|
||||
namespace Mcp\Exception;
|
||||
|
||||
use Mcp\Schema\Request\GetPromptRequest;
|
||||
|
||||
/**
|
||||
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
|
||||
*/
|
||||
final class PromptGetException extends \RuntimeException implements ExceptionInterface
|
||||
{
|
||||
public function __construct(
|
||||
public readonly GetPromptRequest $request,
|
||||
?\Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct(\sprintf('Handling prompt "%s" failed with error: "%s".', $request->name, $previous->getMessage()), previous: $previous);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,16 +11,14 @@
|
|||
|
||||
namespace Mcp\Exception;
|
||||
|
||||
use Mcp\Schema\Request\GetPromptRequest;
|
||||
|
||||
/**
|
||||
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
|
||||
*/
|
||||
final class PromptNotFoundException extends \RuntimeException implements NotFoundExceptionInterface
|
||||
{
|
||||
public function __construct(
|
||||
public readonly GetPromptRequest $request,
|
||||
public readonly string $name,
|
||||
) {
|
||||
parent::__construct(\sprintf('Prompt not found for name: "%s".', $request->name));
|
||||
parent::__construct(\sprintf('Prompt not found: "%s".', $name));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,16 +11,14 @@
|
|||
|
||||
namespace Mcp\Exception;
|
||||
|
||||
use Mcp\Schema\Request\ReadResourceRequest;
|
||||
|
||||
/**
|
||||
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
|
||||
*/
|
||||
final class ResourceNotFoundException extends \RuntimeException implements NotFoundExceptionInterface
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ReadResourceRequest $request,
|
||||
public readonly string $uri,
|
||||
) {
|
||||
parent::__construct(\sprintf('Resource not found for uri: "%s".', $request->uri));
|
||||
parent::__construct(\sprintf('Resource not found for uri: "%s".', $uri));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,17 +11,9 @@
|
|||
|
||||
namespace Mcp\Exception;
|
||||
|
||||
use Mcp\Schema\Request\ReadResourceRequest;
|
||||
|
||||
/**
|
||||
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
|
||||
*/
|
||||
final class ResourceReadException extends \RuntimeException implements ExceptionInterface
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ReadResourceRequest $request,
|
||||
?\Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct(\sprintf('Reading resource "%s" failed with error: "%s".', $request->uri, $previous?->getMessage() ?? ''), previous: $previous);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,17 +11,9 @@
|
|||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,16 +11,14 @@
|
|||
|
||||
namespace Mcp\Exception;
|
||||
|
||||
use Mcp\Schema\Request\CallToolRequest;
|
||||
|
||||
/**
|
||||
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
|
||||
*/
|
||||
final class ToolNotFoundException extends \RuntimeException implements NotFoundExceptionInterface
|
||||
{
|
||||
public function __construct(
|
||||
public readonly CallToolRequest $request,
|
||||
public readonly string $name,
|
||||
) {
|
||||
parent::__construct(\sprintf('Tool not found for call: "%s".', $request->name));
|
||||
parent::__construct(\sprintf('Tool not found: "%s".', $name));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,11 @@ class Error implements MessageInterface
|
|||
return new self($id, self::SERVER_ERROR, $message);
|
||||
}
|
||||
|
||||
public static function forResourceNotFound(string $message, string|int $id = ''): self
|
||||
{
|
||||
return new self($id, self::RESOURCE_NOT_FOUND, $message);
|
||||
}
|
||||
|
||||
public function getId(): string|int
|
||||
{
|
||||
return $this->id;
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ namespace Mcp\Server\Handler\Request;
|
|||
|
||||
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\Content\TextContent;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
|
|
@ -59,9 +59,6 @@ final class CallToolHandler implements RequestHandlerInterface
|
|||
|
||||
try {
|
||||
$reference = $this->referenceProvider->getTool($toolName);
|
||||
if (null === $reference) {
|
||||
throw new ToolNotFoundException($request);
|
||||
}
|
||||
|
||||
$arguments['_session'] = $session;
|
||||
|
||||
|
|
@ -77,17 +74,19 @@ final class CallToolHandler implements RequestHandlerInterface
|
|||
]);
|
||||
|
||||
return new Response($request->getId(), $result);
|
||||
} catch (ToolNotFoundException $e) {
|
||||
$this->logger->error('Tool not found', ['name' => $toolName]);
|
||||
|
||||
return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage());
|
||||
} catch (ToolCallException|ExceptionInterface $e) {
|
||||
} catch (ToolCallException $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', $request->getId());
|
||||
$errorContent = [new TextContent($e->getMessage())];
|
||||
|
||||
return new Response($request->getId(), CallToolResult::error($errorContent));
|
||||
} catch (ToolNotFoundException $e) {
|
||||
$this->logger->error('Tool not found', ['name' => $toolName]);
|
||||
|
||||
return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Unhandled error during tool execution', [
|
||||
'name' => $toolName,
|
||||
|
|
|
|||
|
|
@ -13,10 +13,14 @@ namespace Mcp\Server\Handler\Request;
|
|||
|
||||
use Mcp\Capability\Completion\ProviderInterface;
|
||||
use Mcp\Capability\Registry\ReferenceProviderInterface;
|
||||
use Mcp\Exception\PromptNotFoundException;
|
||||
use Mcp\Exception\ResourceNotFoundException;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\PromptReference;
|
||||
use Mcp\Schema\Request\CompletionCompleteRequest;
|
||||
use Mcp\Schema\ResourceReference;
|
||||
use Mcp\Schema\Result\CompletionCompleteResult;
|
||||
use Mcp\Server\Session\SessionInterface;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
|
@ -51,41 +55,38 @@ final class CompletionCompleteHandler implements RequestHandlerInterface
|
|||
$name = $request->argument['name'] ?? '';
|
||||
$value = $request->argument['value'] ?? '';
|
||||
|
||||
$reference = match ($request->ref->type) {
|
||||
'ref/prompt' => $this->referenceProvider->getPrompt($request->ref->name),
|
||||
'ref/resource' => $this->referenceProvider->getResourceTemplate($request->ref->uri),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null === $reference) {
|
||||
return new Response($request->getId(), new CompletionCompleteResult([]));
|
||||
}
|
||||
|
||||
$providers = $reference->completionProviders;
|
||||
$provider = $providers[$name] ?? null;
|
||||
if (null === $provider) {
|
||||
return new Response($request->getId(), new CompletionCompleteResult([]));
|
||||
}
|
||||
|
||||
if (\is_string($provider)) {
|
||||
if (!class_exists($provider)) {
|
||||
return Error::forInternalError('Invalid completion provider', $request->getId());
|
||||
}
|
||||
$provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider();
|
||||
}
|
||||
|
||||
if (!$provider instanceof ProviderInterface) {
|
||||
return Error::forInternalError('Invalid completion provider type', $request->getId());
|
||||
}
|
||||
|
||||
try {
|
||||
$reference = match (true) {
|
||||
$request->ref instanceof PromptReference => $this->referenceProvider->getPrompt($request->ref->name),
|
||||
$request->ref instanceof ResourceReference => $this->referenceProvider->getResource($request->ref->uri),
|
||||
};
|
||||
|
||||
$providers = $reference->completionProviders;
|
||||
$provider = $providers[$name] ?? null;
|
||||
if (null === $provider) {
|
||||
return new Response($request->getId(), new CompletionCompleteResult([]));
|
||||
}
|
||||
|
||||
if (\is_string($provider)) {
|
||||
if (!class_exists($provider)) {
|
||||
return Error::forInternalError('Invalid completion provider', $request->getId());
|
||||
}
|
||||
$provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider();
|
||||
}
|
||||
|
||||
if (!$provider instanceof ProviderInterface) {
|
||||
return Error::forInternalError('Invalid completion provider type', $request->getId());
|
||||
}
|
||||
|
||||
$completions = $provider->getCompletions($value);
|
||||
$total = \count($completions);
|
||||
$hasMore = $total > 100;
|
||||
$paged = \array_slice($completions, 0, 100);
|
||||
|
||||
return new Response($request->getId(), new CompletionCompleteResult($paged, $total, $hasMore));
|
||||
} catch (\Throwable) {
|
||||
} catch (PromptNotFoundException|ResourceNotFoundException $e) {
|
||||
return Error::forResourceNotFound($e->getMessage(), $request->getId());
|
||||
} catch (\Throwable $e) {
|
||||
return Error::forInternalError('Error while handling completion request', $request->getId());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ namespace Mcp\Server\Handler\Request;
|
|||
|
||||
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;
|
||||
|
|
@ -56,9 +55,6 @@ final class GetPromptHandler implements RequestHandlerInterface
|
|||
|
||||
try {
|
||||
$reference = $this->referenceProvider->getPrompt($promptName);
|
||||
if (null === $reference) {
|
||||
throw new PromptNotFoundException($request);
|
||||
}
|
||||
|
||||
$arguments['_session'] = $session;
|
||||
|
||||
|
|
@ -67,18 +63,18 @@ final class GetPromptHandler implements RequestHandlerInterface
|
|||
$formatted = $reference->formatResult($result);
|
||||
|
||||
return new Response($request->getId(), new GetPromptResult($formatted));
|
||||
} catch (PromptGetException $e) {
|
||||
$this->logger->error(\sprintf('Error while handling prompt "%s": "%s".', $promptName, $e->getMessage()));
|
||||
|
||||
return Error::forInternalError($e->getMessage(), $request->getId());
|
||||
} catch (PromptNotFoundException $e) {
|
||||
$this->logger->error('Prompt not found', ['prompt_name' => $promptName]);
|
||||
|
||||
return new Error($request->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: '.$e->getMessage(), $request->getId());
|
||||
return Error::forResourceNotFound($e->getMessage(), $request->getId());
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]);
|
||||
$this->logger->error(\sprintf('Unexpected error while handling prompt "%s": "%s".', $promptName, $e->getMessage()));
|
||||
|
||||
return Error::forInternalError('Error while handling prompt: '.$e->getMessage(), $request->getId());
|
||||
return Error::forInternalError('Error while handling prompt', $request->getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface;
|
|||
use Mcp\Capability\Registry\ReferenceProviderInterface;
|
||||
use Mcp\Capability\Registry\ResourceTemplateReference;
|
||||
use Mcp\Exception\ResourceNotFoundException;
|
||||
use Mcp\Exception\ResourceReadException;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
|
|
@ -56,9 +57,6 @@ final class ReadResourceHandler implements RequestHandlerInterface
|
|||
|
||||
try {
|
||||
$reference = $this->referenceProvider->getResource($uri);
|
||||
if (null === $reference) {
|
||||
throw new ResourceNotFoundException($request);
|
||||
}
|
||||
|
||||
$arguments = [
|
||||
'uri' => $uri,
|
||||
|
|
@ -77,12 +75,16 @@ final class ReadResourceHandler implements RequestHandlerInterface
|
|||
}
|
||||
|
||||
return new Response($request->getId(), new ReadResourceResult($formatted));
|
||||
} catch (ResourceReadException $e) {
|
||||
$this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage()));
|
||||
|
||||
return Error::forInternalError($e->getMessage(), $request->getId());
|
||||
} catch (ResourceNotFoundException $e) {
|
||||
$this->logger->error('Resource not found', ['uri' => $uri]);
|
||||
|
||||
return new Error($request->getId(), Error::RESOURCE_NOT_FOUND, $e->getMessage());
|
||||
return Error::forResourceNotFound($e->getMessage(), $request->getId());
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage()));
|
||||
$this->logger->error(\sprintf('Unexpected error while reading resource "%s": "%s".', $uri, $e->getMessage()));
|
||||
|
||||
return Error::forInternalError('Error while reading resource', $request->getId());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ use Mcp\Capability\Registry\PromptReference;
|
|||
use Mcp\Capability\Registry\ResourceReference;
|
||||
use Mcp\Capability\Registry\ResourceTemplateReference;
|
||||
use Mcp\Capability\Registry\ToolReference;
|
||||
use Mcp\Exception\PromptNotFoundException;
|
||||
use Mcp\Exception\ResourceNotFoundException;
|
||||
use Mcp\Exception\ToolNotFoundException;
|
||||
use Mcp\Schema\Prompt;
|
||||
use Mcp\Schema\Resource;
|
||||
use Mcp\Schema\ResourceTemplate;
|
||||
|
|
@ -45,10 +48,12 @@ class RegistryProviderTest extends TestCase
|
|||
$this->assertFalse($toolRef->isManual);
|
||||
}
|
||||
|
||||
public function testGetToolReturnsNullForUnregisteredTool(): void
|
||||
public function testGetToolThrowsExceptionForUnregisteredTool(): void
|
||||
{
|
||||
$toolRef = $this->registry->getTool('non_existent_tool');
|
||||
$this->assertNull($toolRef);
|
||||
$this->expectException(ToolNotFoundException::class);
|
||||
$this->expectExceptionMessage('Tool not found: "non_existent_tool".');
|
||||
|
||||
$this->registry->getTool('non_existent_tool');
|
||||
}
|
||||
|
||||
public function testGetResourceReturnsRegisteredResource(): void
|
||||
|
|
@ -65,10 +70,12 @@ class RegistryProviderTest extends TestCase
|
|||
$this->assertFalse($resourceRef->isManual);
|
||||
}
|
||||
|
||||
public function testGetResourceReturnsNullForUnregisteredResource(): void
|
||||
public function testGetResourceThrowsExceptionForUnregisteredResource(): void
|
||||
{
|
||||
$resourceRef = $this->registry->getResource('test://non_existent');
|
||||
$this->assertNull($resourceRef);
|
||||
$this->expectException(ResourceNotFoundException::class);
|
||||
$this->expectExceptionMessage('Resource not found for uri: "test://non_existent".');
|
||||
|
||||
$this->registry->getResource('test://non_existent');
|
||||
}
|
||||
|
||||
public function testGetResourceMatchesResourceTemplate(): void
|
||||
|
|
@ -84,15 +91,17 @@ class RegistryProviderTest extends TestCase
|
|||
$this->assertEquals($handler, $resourceRef->handler);
|
||||
}
|
||||
|
||||
public function testGetResourceWithIncludeTemplatesFalse(): void
|
||||
public function testGetResourceWithIncludeTemplatesFalseThrowsException(): void
|
||||
{
|
||||
$template = $this->createValidResourceTemplate('test://{id}');
|
||||
$handler = fn (string $id) => "content for {$id}";
|
||||
|
||||
$this->registry->registerResourceTemplate($template, $handler);
|
||||
|
||||
$resourceRef = $this->registry->getResource('test://123', false);
|
||||
$this->assertNull($resourceRef);
|
||||
$this->expectException(ResourceNotFoundException::class);
|
||||
$this->expectExceptionMessage('Resource not found for uri: "test://123".');
|
||||
|
||||
$this->registry->getResource('test://123', false);
|
||||
}
|
||||
|
||||
public function testGetResourcePrefersDirectResourceOverTemplate(): void
|
||||
|
|
@ -125,10 +134,12 @@ class RegistryProviderTest extends TestCase
|
|||
$this->assertFalse($templateRef->isManual);
|
||||
}
|
||||
|
||||
public function testGetResourceTemplateReturnsNullForUnregisteredTemplate(): void
|
||||
public function testGetResourceTemplateThrowsExceptionForUnregisteredTemplate(): void
|
||||
{
|
||||
$templateRef = $this->registry->getResourceTemplate('test://{non_existent}');
|
||||
$this->assertNull($templateRef);
|
||||
$this->expectException(ResourceNotFoundException::class);
|
||||
$this->expectExceptionMessage('Resource not found for uri: "test://{non_existent}".');
|
||||
|
||||
$this->registry->getResourceTemplate('test://{non_existent}');
|
||||
}
|
||||
|
||||
public function testGetPromptReturnsRegisteredPrompt(): void
|
||||
|
|
@ -145,10 +156,12 @@ class RegistryProviderTest extends TestCase
|
|||
$this->assertFalse($promptRef->isManual);
|
||||
}
|
||||
|
||||
public function testGetPromptReturnsNullForUnregisteredPrompt(): void
|
||||
public function testGetPromptThrowsExceptionForUnregisteredPrompt(): void
|
||||
{
|
||||
$promptRef = $this->registry->getPrompt('non_existent_prompt');
|
||||
$this->assertNull($promptRef);
|
||||
$this->expectException(PromptNotFoundException::class);
|
||||
$this->expectExceptionMessage('Prompt not found: "non_existent_prompt".');
|
||||
|
||||
$this->registry->getPrompt('non_existent_prompt');
|
||||
}
|
||||
|
||||
public function testGetToolsReturnsAllRegisteredTools(): void
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ namespace Mcp\Tests\Unit\Capability\Registry;
|
|||
|
||||
use Mcp\Capability\Completion\EnumCompletionProvider;
|
||||
use Mcp\Capability\Registry;
|
||||
use Mcp\Exception\PromptNotFoundException;
|
||||
use Mcp\Exception\ResourceNotFoundException;
|
||||
use Mcp\Exception\ToolNotFoundException;
|
||||
use Mcp\Schema\Prompt;
|
||||
use Mcp\Schema\Resource;
|
||||
use Mcp\Schema\ResourceTemplate;
|
||||
|
|
@ -264,23 +267,36 @@ class RegistryTest extends TestCase
|
|||
$this->registry->registerResourceTemplate($manualTemplate, fn () => 'manual', [], true);
|
||||
$this->registry->registerResourceTemplate($discoveredTemplate, fn () => 'discovered', [], false);
|
||||
|
||||
$this->logger
|
||||
->expects($this->once())
|
||||
->method('debug')
|
||||
->with('Removed 4 discovered elements from internal registry.');
|
||||
// Test that all elements exist
|
||||
$this->registry->getTool('manual_tool');
|
||||
$this->registry->getResource('test://manual');
|
||||
$this->registry->getPrompt('manual_prompt');
|
||||
$this->registry->getResourceTemplate('manual://{id}');
|
||||
$this->registry->getTool('discovered_tool');
|
||||
$this->registry->getResource('test://discovered');
|
||||
$this->registry->getPrompt('discovered_prompt');
|
||||
$this->registry->getResourceTemplate('discovered://{id}');
|
||||
|
||||
$this->registry->clear();
|
||||
|
||||
$this->assertNotNull($this->registry->getTool('manual_tool'));
|
||||
$this->assertNull($this->registry->getTool('discovered_tool'));
|
||||
$this->assertNotNull($this->registry->getResource('test://manual'));
|
||||
$this->assertNull(
|
||||
$this->registry->getResource('test://discovered', false),
|
||||
); // Don't include templates to avoid debug log
|
||||
$this->assertNotNull($this->registry->getPrompt('manual_prompt'));
|
||||
$this->assertNull($this->registry->getPrompt('discovered_prompt'));
|
||||
$this->assertNotNull($this->registry->getResourceTemplate('manual://{id}'));
|
||||
$this->assertNull($this->registry->getResourceTemplate('discovered://{id}'));
|
||||
// Manual elements should still exist
|
||||
$this->registry->getTool('manual_tool');
|
||||
$this->registry->getResource('test://manual');
|
||||
$this->registry->getPrompt('manual_prompt');
|
||||
$this->registry->getResourceTemplate('manual://{id}');
|
||||
|
||||
// Test that all discovered elements throw exceptions
|
||||
$this->expectException(ToolNotFoundException::class);
|
||||
$this->registry->getTool('discovered_tool');
|
||||
|
||||
$this->expectException(ResourceNotFoundException::class);
|
||||
$this->registry->getResource('test://discovered');
|
||||
|
||||
$this->expectException(PromptNotFoundException::class);
|
||||
$this->registry->getPrompt('discovered_prompt');
|
||||
|
||||
$this->expectException(ResourceNotFoundException::class);
|
||||
$this->registry->getResourceTemplate('discovered://{id}');
|
||||
}
|
||||
|
||||
public function testClearLogsNothingWhenNoDiscoveredElements(): void
|
||||
|
|
@ -294,7 +310,7 @@ class RegistryTest extends TestCase
|
|||
|
||||
$this->registry->clear();
|
||||
|
||||
$this->assertNotNull($this->registry->getTool('manual_tool'));
|
||||
$this->registry->getTool('manual_tool');
|
||||
}
|
||||
|
||||
public function testRegisterToolHandlesStringHandler(): void
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue