Skip to content

Commit ca18caf

Browse files
[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>
1 parent 8885d29 commit ca18caf

File tree

23 files changed

+278
-185
lines changed

23 files changed

+278
-185
lines changed

docs/mcp-elements.md

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -154,31 +154,37 @@ public function getMultipleContent(): array
154154

155155
#### Error Handling
156156

157-
Tools can throw exceptions which are automatically converted to proper JSON-RPC error responses:
157+
Tool handlers can throw any exception, but the type determines how it's handled:
158+
159+
- **`ToolCallException`**: Converted to JSON-RPC response with `CallToolResult` where `isError: true`, allowing the LLM to see the error message and self-correct
160+
- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message
158161

159162
```php
163+
use Mcp\Exception\ToolCallException;
164+
160165
#[McpTool]
161166
public function divideNumbers(float $a, float $b): float
162167
{
163168
if ($b === 0.0) {
164-
throw new \InvalidArgumentException('Division by zero is not allowed');
169+
throw new ToolCallException('Division by zero is not allowed');
165170
}
166-
171+
167172
return $a / $b;
168173
}
169174

170175
#[McpTool]
171176
public function processFile(string $filename): string
172177
{
173178
if (!file_exists($filename)) {
174-
throw new \InvalidArgumentException("File not found: {$filename}");
179+
throw new ToolCallException("File not found: {$filename}");
175180
}
176-
181+
177182
return file_get_contents($filename);
178183
}
179184
```
180185

181-
The SDK will convert these exceptions into appropriate JSON-RPC error responses that MCP clients can understand.
186+
**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.
187+
182188

183189
## Resources
184190

@@ -298,24 +304,31 @@ public function getMultipleResources(): array
298304

299305
#### Error Handling
300306

301-
Resource handlers can throw exceptions for error cases:
307+
Resource handlers can throw any exception, but the type determines how it's handled:
308+
309+
- **`ResourceReadException`**: Converted to JSON-RPC error response with the actual exception message
310+
- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message
302311

303312
```php
313+
use Mcp\Exception\ResourceReadException;
314+
304315
#[McpResource(uri: 'file://{path}')]
305316
public function getFile(string $path): string
306317
{
307318
if (!file_exists($path)) {
308-
throw new \InvalidArgumentException("File not found: {$path}");
319+
throw new ResourceReadException("File not found: {$path}");
309320
}
310-
321+
311322
if (!is_readable($path)) {
312-
throw new \RuntimeException("File not readable: {$path}");
323+
throw new ResourceReadException("File not readable: {$path}");
313324
}
314-
325+
315326
return file_get_contents($path);
316327
}
317328
```
318329

330+
**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.
331+
319332
## Resource Templates
320333

321334
Resource templates are **dynamic resources** that use parameterized URIs with variables. They follow all the same rules
@@ -449,40 +462,44 @@ public function explicitMessages(): array
449462
}
450463
```
451464

465+
The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format.
466+
452467
#### Valid Message Roles
453468

454469
- **`user`**: User input or questions
455470
- **`assistant`**: Assistant responses/system
456471

457472
#### Error Handling
458473

459-
Prompt handlers can throw exceptions for invalid inputs:
474+
Prompt handlers can throw any exception, but the type determines how it's handled:
475+
- **`PromptGetException`**: Converted to JSON-RPC error response with the actual exception message
476+
- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message
460477

461478
```php
479+
use Mcp\Exception\PromptGetException;
480+
462481
#[McpPrompt]
463482
public function generatePrompt(string $topic, string $style): array
464483
{
465484
$validStyles = ['casual', 'formal', 'technical'];
466-
485+
467486
if (!in_array($style, $validStyles)) {
468-
throw new \InvalidArgumentException(
487+
throw new PromptGetException(
469488
"Invalid style '{$style}'. Must be one of: " . implode(', ', $validStyles)
470489
);
471490
}
472-
491+
473492
return [
474493
['role' => 'user', 'content' => "Write about {$topic} in a {$style} style"]
475494
];
476495
}
477496
```
478497

479-
The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format.
498+
**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.
480499

481500
## Completion Providers
482501

483-
Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools
484-
and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have
485-
dynamic parameters that benefit from completion hints.
502+
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.
486503

487504
### Completion Provider Types
488505

examples/http-client-communication/server.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
use Http\Discovery\Psr17Factory;
1616
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
17+
use Mcp\Exception\ToolCallException;
1718
use Mcp\Schema\Content\TextContent;
1819
use Mcp\Schema\Enum\LoggingLevel;
1920
use Mcp\Schema\JsonRpc\Error as JsonRpcError;
@@ -64,7 +65,7 @@ function (string $projectName, array $milestones, ClientGateway $client): array
6465
);
6566

6667
if ($response instanceof JsonRpcError) {
67-
throw new RuntimeException(sprintf('Sampling request failed (%d): %s', $response->code, $response->message));
68+
throw new ToolCallException(sprintf('Sampling request failed (%d): %s', $response->code, $response->message));
6869
}
6970

7071
$result = $response->result;

examples/http-discovery-userprofile/McpElements.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
use Mcp\Capability\Attribute\McpResource;
1717
use Mcp\Capability\Attribute\McpResourceTemplate;
1818
use Mcp\Capability\Attribute\McpTool;
19-
use Mcp\Exception\InvalidArgumentException;
19+
use Mcp\Exception\PromptGetException;
20+
use Mcp\Exception\ResourceReadException;
2021
use Psr\Log\LoggerInterface;
2122

2223
/**
@@ -48,7 +49,7 @@ public function __construct(
4849
*
4950
* @return User user profile data
5051
*
51-
* @throws InvalidArgumentException if the user is not found
52+
* @throws ResourceReadException if the user is not found
5253
*/
5354
#[McpResourceTemplate(
5455
uriTemplate: 'user://{userId}/profile',
@@ -62,7 +63,7 @@ public function getUserProfile(
6263
): array {
6364
$this->logger->info('Reading resource: user profile', ['userId' => $userId]);
6465
if (!isset($this->users[$userId])) {
65-
throw new InvalidArgumentException("User profile not found for ID: {$userId}");
66+
throw new ResourceReadException("User not found for ID: {$userId}");
6667
}
6768

6869
return $this->users[$userId];
@@ -130,7 +131,7 @@ public function testToolWithoutParams(): array
130131
*
131132
* @return array<string, string>[] prompt messages
132133
*
133-
* @throws InvalidArgumentException if user not found
134+
* @throws PromptGetException if user not found
134135
*/
135136
#[McpPrompt(name: 'generate_bio_prompt')]
136137
public function generateBio(
@@ -140,7 +141,7 @@ public function generateBio(
140141
): array {
141142
$this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]);
142143
if (!isset($this->users[$userId])) {
143-
throw new InvalidArgumentException("User not found for bio prompt: {$userId}");
144+
throw new PromptGetException("User not found for bio prompt: {$userId}");
144145
}
145146
$user = $this->users[$userId];
146147

examples/stdio-cached-discovery/CachedCalculatorElements.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace Mcp\Example\StdioCachedDiscovery;
1515

1616
use Mcp\Capability\Attribute\McpTool;
17+
use Mcp\Exception\ToolCallException;
1718

1819
/**
1920
* Example MCP elements for demonstrating cached discovery.
@@ -39,7 +40,7 @@ public function multiply(int $a, int $b): int
3940
public function divide(int $a, int $b): float
4041
{
4142
if (0 === $b) {
42-
throw new \InvalidArgumentException('Division by zero is not allowed');
43+
throw new ToolCallException('Division by zero is not allowed');
4344
}
4445

4546
return $a / $b;

examples/stdio-discovery-calculator/McpElements.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Mcp\Capability\Attribute\McpResource;
1515
use Mcp\Capability\Attribute\McpTool;
16+
use Mcp\Exception\ToolCallException;
1617
use Psr\Log\LoggerInterface;
1718
use Psr\Log\NullLogger;
1819

@@ -44,10 +45,10 @@ public function __construct(
4445
* @param float $b the second operand
4546
* @param string $operation the operation ('add', 'subtract', 'multiply', 'divide')
4647
*
47-
* @return float|string the result of the calculation, or an error message string
48+
* @return float the result of the calculation
4849
*/
4950
#[McpTool(name: 'calculate')]
50-
public function calculate(float $a, float $b, string $operation): float|string
51+
public function calculate(float $a, float $b, string $operation): float
5152
{
5253
$this->logger->info(\sprintf('Calculating: %f %s %f', $a, $operation, $b));
5354

@@ -65,16 +66,16 @@ public function calculate(float $a, float $b, string $operation): float|string
6566
break;
6667
case 'divide':
6768
if (0 == $b) {
68-
return 'Error: Division by zero.';
69+
throw new ToolCallException('Division by zero is not allowed.');
6970
}
7071
$result = $a / $b;
7172
break;
7273
default:
73-
return "Error: Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide.";
74+
throw new ToolCallException("Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide.");
7475
}
7576

7677
if (!$this->config['allow_negative'] && $result < 0) {
77-
return 'Error: Negative results are disabled.';
78+
throw new ToolCallException('Negative results are disabled.');
7879
}
7980

8081
return round($result, $this->config['precision']);

src/Capability/Registry.php

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
use Mcp\Event\ResourceTemplateListChangedEvent;
2424
use Mcp\Event\ToolListChangedEvent;
2525
use Mcp\Exception\InvalidCursorException;
26+
use Mcp\Exception\PromptNotFoundException;
27+
use Mcp\Exception\ResourceNotFoundException;
28+
use Mcp\Exception\ToolNotFoundException;
2629
use Mcp\Schema\Page;
2730
use Mcp\Schema\Prompt;
2831
use Mcp\Schema\Resource;
@@ -209,43 +212,41 @@ public function clear(): void
209212
}
210213
}
211214

212-
public function getTool(string $name): ?ToolReference
215+
public function getTool(string $name): ToolReference
213216
{
214-
return $this->tools[$name] ?? null;
217+
return $this->tools[$name] ?? throw new ToolNotFoundException($name);
215218
}
216219

217220
public function getResource(
218221
string $uri,
219222
bool $includeTemplates = true,
220-
): ResourceReference|ResourceTemplateReference|null {
223+
): ResourceReference|ResourceTemplateReference {
221224
$registration = $this->resources[$uri] ?? null;
222225
if ($registration) {
223226
return $registration;
224227
}
225228

226-
if (!$includeTemplates) {
227-
return null;
228-
}
229-
230-
foreach ($this->resourceTemplates as $template) {
231-
if ($template->matches($uri)) {
232-
return $template;
229+
if ($includeTemplates) {
230+
foreach ($this->resourceTemplates as $template) {
231+
if ($template->matches($uri)) {
232+
return $template;
233+
}
233234
}
234235
}
235236

236237
$this->logger->debug('No resource matched URI.', ['uri' => $uri]);
237238

238-
return null;
239+
throw new ResourceNotFoundException($uri);
239240
}
240241

241-
public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateReference
242+
public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference
242243
{
243-
return $this->resourceTemplates[$uriTemplate] ?? null;
244+
return $this->resourceTemplates[$uriTemplate] ?? throw new ResourceNotFoundException($uriTemplate);
244245
}
245246

246-
public function getPrompt(string $name): ?PromptReference
247+
public function getPrompt(string $name): PromptReference
247248
{
248-
return $this->prompts[$name] ?? null;
249+
return $this->prompts[$name] ?? throw new PromptNotFoundException($name);
249250
}
250251

251252
public function getTools(?int $limit = null, ?string $cursor = null): Page

src/Capability/Registry/ReferenceProviderInterface.php

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111

1212
namespace Mcp\Capability\Registry;
1313

14+
use Mcp\Exception\PromptNotFoundException;
15+
use Mcp\Exception\ResourceNotFoundException;
16+
use Mcp\Exception\ToolNotFoundException;
1417
use Mcp\Schema\Page;
1518

1619
/**
@@ -23,23 +26,31 @@ interface ReferenceProviderInterface
2326
{
2427
/**
2528
* Gets a tool reference by name.
29+
*
30+
* @throws ToolNotFoundException
2631
*/
27-
public function getTool(string $name): ?ToolReference;
32+
public function getTool(string $name): ToolReference;
2833

2934
/**
3035
* Gets a resource reference by URI (includes template matching if enabled).
36+
*
37+
* @throws ResourceNotFoundException
3138
*/
32-
public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference|null;
39+
public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference;
3340

3441
/**
3542
* Gets a resource template reference by URI template.
43+
*
44+
* @throws ResourceNotFoundException
3645
*/
37-
public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateReference;
46+
public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference;
3847

3948
/**
4049
* Gets a prompt reference by name.
50+
*
51+
* @throws PromptNotFoundException
4152
*/
42-
public function getPrompt(string $name): ?PromptReference;
53+
public function getPrompt(string $name): PromptReference;
4354

4455
/**
4556
* Gets all registered tools.

src/Exception/PromptGetException.php

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,9 @@
1111

1212
namespace Mcp\Exception;
1313

14-
use Mcp\Schema\Request\GetPromptRequest;
15-
1614
/**
1715
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
1816
*/
1917
final class PromptGetException extends \RuntimeException implements ExceptionInterface
2018
{
21-
public function __construct(
22-
public readonly GetPromptRequest $request,
23-
?\Throwable $previous = null,
24-
) {
25-
parent::__construct(\sprintf('Handling prompt "%s" failed with error: "%s".', $request->name, $previous->getMessage()), previous: $previous);
26-
}
2719
}

0 commit comments

Comments
 (0)