diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 911b3d52..1bb2690d 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -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 diff --git a/examples/http-client-communication/server.php b/examples/http-client-communication/server.php index 2acce334..8191a8e2 100644 --- a/examples/http-client-communication/server.php +++ b/examples/http-client-communication/server.php @@ -14,6 +14,7 @@ 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 @@ function (string $projectName, array $milestones, ClientGateway $client): array ); 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; diff --git a/examples/http-discovery-userprofile/McpElements.php b/examples/http-discovery-userprofile/McpElements.php index e763891a..933bd51e 100644 --- a/examples/http-discovery-userprofile/McpElements.php +++ b/examples/http-discovery-userprofile/McpElements.php @@ -16,7 +16,8 @@ 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 @@ public function __construct( * * @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 @@ public function getUserProfile( ): 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 @@ public function testToolWithoutParams(): array * * @return array[] 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 @@ public function generateBio( ): 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]; diff --git a/examples/stdio-cached-discovery/CachedCalculatorElements.php b/examples/stdio-cached-discovery/CachedCalculatorElements.php index 2d5249df..1da3d277 100644 --- a/examples/stdio-cached-discovery/CachedCalculatorElements.php +++ b/examples/stdio-cached-discovery/CachedCalculatorElements.php @@ -14,6 +14,7 @@ namespace Mcp\Example\StdioCachedDiscovery; use Mcp\Capability\Attribute\McpTool; +use Mcp\Exception\ToolCallException; /** * Example MCP elements for demonstrating cached discovery. @@ -39,7 +40,7 @@ public function multiply(int $a, int $b): int 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; diff --git a/examples/stdio-discovery-calculator/McpElements.php b/examples/stdio-discovery-calculator/McpElements.php index 71aea372..21330313 100644 --- a/examples/stdio-discovery-calculator/McpElements.php +++ b/examples/stdio-discovery-calculator/McpElements.php @@ -13,6 +13,7 @@ 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 @@ public function __construct( * @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 @@ public function calculate(float $a, float $b, string $operation): float|string 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']); diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 94db079f..3a13c323 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -23,6 +23,9 @@ 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 @@ public function clear(): void } } - 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 diff --git a/src/Capability/Registry/ReferenceProviderInterface.php b/src/Capability/Registry/ReferenceProviderInterface.php index 2f60014b..0af66e1f 100644 --- a/src/Capability/Registry/ReferenceProviderInterface.php +++ b/src/Capability/Registry/ReferenceProviderInterface.php @@ -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. diff --git a/src/Exception/PromptGetException.php b/src/Exception/PromptGetException.php index 8970ea58..7eec0daf 100644 --- a/src/Exception/PromptGetException.php +++ b/src/Exception/PromptGetException.php @@ -11,17 +11,9 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\GetPromptRequest; - /** * @author Tobias Nyholm */ 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); - } } diff --git a/src/Exception/PromptNotFoundException.php b/src/Exception/PromptNotFoundException.php index 82872e8b..81b7c6e5 100644 --- a/src/Exception/PromptNotFoundException.php +++ b/src/Exception/PromptNotFoundException.php @@ -11,16 +11,14 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\GetPromptRequest; - /** * @author Tobias Nyholm */ 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)); } } diff --git a/src/Exception/ResourceNotFoundException.php b/src/Exception/ResourceNotFoundException.php index b5624bbc..420ac1a8 100644 --- a/src/Exception/ResourceNotFoundException.php +++ b/src/Exception/ResourceNotFoundException.php @@ -11,16 +11,14 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\ReadResourceRequest; - /** * @author Tobias Nyholm */ 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)); } } diff --git a/src/Exception/ResourceReadException.php b/src/Exception/ResourceReadException.php index 913064b2..a89dec8e 100644 --- a/src/Exception/ResourceReadException.php +++ b/src/Exception/ResourceReadException.php @@ -11,17 +11,9 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\ReadResourceRequest; - /** * @author Tobias Nyholm */ 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); - } } diff --git a/src/Exception/ToolCallException.php b/src/Exception/ToolCallException.php index 71978d9d..01ba9f45 100644 --- a/src/Exception/ToolCallException.php +++ b/src/Exception/ToolCallException.php @@ -11,17 +11,9 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\CallToolRequest; - /** * @author Tobias Nyholm */ 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); - } } diff --git a/src/Exception/ToolNotFoundException.php b/src/Exception/ToolNotFoundException.php index 3795d74e..0a864e75 100644 --- a/src/Exception/ToolNotFoundException.php +++ b/src/Exception/ToolNotFoundException.php @@ -11,16 +11,14 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\CallToolRequest; - /** * @author Tobias Nyholm */ 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)); } } diff --git a/src/Schema/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php index ae802580..d5273eb1 100644 --- a/src/Schema/JsonRpc/Error.php +++ b/src/Schema/JsonRpc/Error.php @@ -106,6 +106,11 @@ public static function forServerError(string $message, string|int $id = ''): sel 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; diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 79413908..df4aff51 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -13,9 +13,9 @@ 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 @@ public function handle(Request $request, SessionInterface $session): Response|Er try { $reference = $this->referenceProvider->getTool($toolName); - if (null === $reference) { - throw new ToolNotFoundException($request); - } $arguments['_session'] = $session; @@ -77,17 +74,19 @@ public function handle(Request $request, SessionInterface $session): Response|Er ]); 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, diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index c3d9f844..f1c1b9d6 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -13,10 +13,14 @@ 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 @@ public function handle(Request $request, SessionInterface $session): Response|Er $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([])); - } + 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([])); - } + $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()); + 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(); } - $provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider(); - } - if (!$provider instanceof ProviderInterface) { - return Error::forInternalError('Invalid completion provider type', $request->getId()); - } + if (!$provider instanceof ProviderInterface) { + return Error::forInternalError('Invalid completion provider type', $request->getId()); + } - try { $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()); } } diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index 28e5e909..1c8758ab 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -13,7 +13,6 @@ 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 @@ public function handle(Request $request, SessionInterface $session): Response|Er try { $reference = $this->referenceProvider->getPrompt($promptName); - if (null === $reference) { - throw new PromptNotFoundException($request); - } $arguments['_session'] = $session; @@ -67,18 +63,18 @@ public function handle(Request $request, SessionInterface $session): Response|Er $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()); } } } diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index 19e426aa..c5160cd5 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -15,6 +15,7 @@ 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 @@ public function handle(Request $request, SessionInterface $session): Response|Er try { $reference = $this->referenceProvider->getResource($uri); - if (null === $reference) { - throw new ResourceNotFoundException($request); - } $arguments = [ 'uri' => $uri, @@ -77,12 +75,16 @@ public function handle(Request $request, SessionInterface $session): Response|Er } 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()); } diff --git a/tests/Unit/Capability/Registry/RegistryProviderTest.php b/tests/Unit/Capability/Registry/RegistryProviderTest.php index b1eaa857..cebf474c 100644 --- a/tests/Unit/Capability/Registry/RegistryProviderTest.php +++ b/tests/Unit/Capability/Registry/RegistryProviderTest.php @@ -16,6 +16,9 @@ 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 @@ public function testGetToolReturnsRegisteredTool(): void $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 @@ public function testGetResourceReturnsRegisteredResource(): void $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 @@ public function testGetResourceMatchesResourceTemplate(): void $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 @@ public function testGetResourceTemplateReturnsRegisteredTemplate(): void $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 @@ public function testGetPromptReturnsRegisteredPrompt(): void $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 diff --git a/tests/Unit/Capability/Registry/RegistryTest.php b/tests/Unit/Capability/Registry/RegistryTest.php index e1f47689..33cb967e 100644 --- a/tests/Unit/Capability/Registry/RegistryTest.php +++ b/tests/Unit/Capability/Registry/RegistryTest.php @@ -13,6 +13,9 @@ 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 @@ public function testClearRemovesOnlyDiscoveredElements(): void $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 @@ public function testClearLogsNothingWhenNoDiscoveredElements(): void $this->registry->clear(); - $this->assertNotNull($this->registry->getTool('manual_tool')); + $this->registry->getTool('manual_tool'); } public function testRegisterToolHandlesStringHandler(): void diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 359afa1b..00b410f2 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -164,7 +164,7 @@ public function testHandleToolNotFoundExceptionReturnsError(): void ->expects($this->once()) ->method('getTool') ->with('nonexistent_tool') - ->willThrowException(new ToolNotFoundException($request)); + ->willThrowException(new ToolNotFoundException('nonexistent_tool')); $this->logger ->expects($this->once()) @@ -177,10 +177,10 @@ public function testHandleToolNotFoundExceptionReturnsError(): void $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); } - public function testHandleToolExecutionExceptionReturnsError(): void + public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): void { $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); - $exception = new ToolCallException($request, new \RuntimeException('Tool execution failed')); + $exception = new ToolCallException('Tool execution failed'); $toolReference = $this->createMock(ToolReference::class); $this->referenceProvider @@ -201,9 +201,15 @@ public function testHandleToolExecutionExceptionReturnsError(): void $response = $this->handler->handle($request, $this->session); - $this->assertInstanceOf(Error::class, $response); + $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + + $result = $response->result; + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('Tool execution failed', $result->content[0]->text); } public function testHandleWithNullResult(): void @@ -246,7 +252,7 @@ public function testConstructorWithDefaultLogger(): void public function testHandleLogsErrorWithCorrectParameters(): void { $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); - $exception = new ToolCallException($request, new \RuntimeException('Custom error message')); + $exception = new ToolCallException('Custom error message'); $toolReference = $this->createMock(ToolReference::class); $this->referenceProvider @@ -265,7 +271,7 @@ public function testHandleLogsErrorWithCorrectParameters(): void ->expects($this->once()) ->method('error') ->with( - 'Error while executing tool "test_tool": "Tool call "test_tool" failed with error: "Custom error message".".', + 'Error while executing tool "test_tool": "Custom error message".', [ 'tool' => 'test_tool', 'arguments' => ['key1' => 'value1', 'key2' => 42, '_session' => $this->session], @@ -274,8 +280,43 @@ public function testHandleLogsErrorWithCorrectParameters(): void $response = $this->handler->handle($request, $this->session); + // ToolCallException should now return Response with CallToolResult having isError=true + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + + $result = $response->result; + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('Custom error message', $result->content[0]->text); + } + + public function testHandleGenericExceptionReturnsError(): void + { + $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); + $exception = new \RuntimeException('Internal database connection failed'); + + $toolReference = $this->createMock(ToolReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('failing_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['param' => 'value', '_session' => $this->session]) + ->willThrowException($exception); + + $response = $this->handler->handle($request, $this->session); + + // Generic exceptions should return Error, not Response $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while executing tool', $response->message); } public function testHandleWithSpecialCharactersInToolName(): void diff --git a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php index b7f5d259..03abe085 100644 --- a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -231,7 +231,7 @@ public function testHandlePromptGetWithMultipleMessages(): void public function testHandlePromptNotFoundExceptionReturnsError(): void { $request = $this->createGetPromptRequest('nonexistent_prompt'); - $exception = new PromptNotFoundException($request); + $exception = new PromptNotFoundException('nonexistent_prompt'); $this->referenceProvider ->expects($this->once()) @@ -243,14 +243,14 @@ public function testHandlePromptNotFoundExceptionReturnsError(): void $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); - $this->assertEquals('Prompt not found for name: "nonexistent_prompt".', $response->message); + $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals('Prompt not found: "nonexistent_prompt".', $response->message); } public function testHandlePromptGetExceptionReturnsError(): void { $request = $this->createGetPromptRequest('failing_prompt'); - $exception = new PromptGetException($request, new \RuntimeException('Failed to get prompt')); + $exception = new PromptGetException('Failed to get prompt'); $this->referenceProvider ->expects($this->once()) @@ -263,7 +263,7 @@ public function testHandlePromptGetExceptionReturnsError(): void $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::INTERNAL_ERROR, $response->code); - $this->assertEquals('Error while handling prompt: Handling prompt "failing_prompt" failed with error: "Failed to get prompt".', $response->message); + $this->assertEquals('Failed to get prompt', $response->message); } public function testHandlePromptGetWithComplexArguments(): void diff --git a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php index 2c54110d..92b5a6f2 100644 --- a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -178,7 +178,7 @@ public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void { $uri = 'file://nonexistent/file.txt'; $request = $this->createReadResourceRequest($uri); - $exception = new ResourceNotFoundException($request); + $exception = new ResourceNotFoundException($uri); $this->referenceProvider ->expects($this->once()) @@ -194,14 +194,31 @@ public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); } - public function testHandleResourceReadExceptionReturnsGenericError(): void + public function testHandleResourceReadExceptionReturnsActualErrorMessage(): void { $uri = 'file://corrupted/file.txt'; $request = $this->createReadResourceRequest($uri); - $exception = new ResourceReadException( - $request, - new \RuntimeException('Failed to read resource: corrupted data'), - ); + $exception = new ResourceReadException('Failed to read resource: corrupted data'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willThrowException($exception); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Failed to read resource: corrupted data', $response->message); + } + + public function testHandleGenericExceptionReturnsGenericError(): void + { + $uri = 'file://problematic/file.txt'; + $request = $this->createReadResourceRequest($uri); + $exception = new \RuntimeException('Internal database connection failed'); $this->referenceProvider ->expects($this->once()) @@ -382,7 +399,7 @@ public function testHandleResourceNotFoundWithCustomMessage(): void { $uri = 'file://custom/missing.txt'; $request = $this->createReadResourceRequest($uri); - $exception = new ResourceNotFoundException($request); + $exception = new ResourceNotFoundException($uri); $this->referenceProvider ->expects($this->once())