Skip to content

Commit fcf605d

Browse files
[Server] Simplify server extensibility with pluggable method handlers (#100)
* refactor: use a custom method handler registration system * refactor: remove redundant Completer and CompleterInterface * docs: update server-builder documentation to reflect custom method handler changes
1 parent e94fd82 commit fcf605d

26 files changed

+896
-2865
lines changed

docs/server-builder.md

Lines changed: 31 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ various aspects of the server behavior.
1212
- [Session Management](#session-management)
1313
- [Manual Capability Registration](#manual-capability-registration)
1414
- [Service Dependencies](#service-dependencies)
15-
- [Custom Capability Handlers](#custom-capability-handlers)
15+
- [Custom Method Handlers](#custom-method-handlers)
1616
- [Complete Example](#complete-example)
1717
- [Method Reference](#method-reference)
1818

@@ -344,102 +344,50 @@ $server = Server::builder()
344344
->setEventDispatcher($eventDispatcher);
345345
```
346346

347-
## Custom Capability Handlers
347+
## Custom Method Handlers
348348

349-
**Advanced customization for specific use cases.** Override the default capability handlers when you need completely custom
350-
behavior for how tools are executed, resources are read, or prompts are generated. Most users should stick with the default implementations.
349+
**Low-level escape hatch.** Custom method handlers run before the SDK’s built-in handlers and give you total control over
350+
individual JSON-RPC methods. They do not receive the builder’s registry, container, or discovery output unless you pass
351+
those dependencies in yourself.
351352

352-
The default handlers work by:
353-
1. Looking up registered tools/resources/prompts by name/URI
354-
2. Resolving the handler from the container
355-
3. Executing the handler with the provided arguments
356-
4. Formatting the result and handling errors
357-
358-
### Custom Tool Caller
359-
360-
Replace how tool execution requests are processed. Your custom `ToolCallerInterface` receives a `CallToolRequest` (with
361-
tool name and arguments) and must return a `CallToolResult`.
353+
Attach handlers with `addMethodHandler()` (single) or `addMethodHandlers()` (multiple). You can call these methods as
354+
many times as needed; each call prepends the handlers so they execute before the defaults:
362355

363356
```php
364-
use Mcp\Capability\Tool\ToolCallerInterface;
365-
use Mcp\Schema\Request\CallToolRequest;
366-
use Mcp\Schema\Result\CallToolResult;
367-
368-
class CustomToolCaller implements ToolCallerInterface
369-
{
370-
public function call(CallToolRequest $request): CallToolResult
371-
{
372-
// Custom tool routing, execution, authentication, caching, etc.
373-
// You handle finding the tool, executing it, and formatting results
374-
$toolName = $request->name;
375-
$arguments = $request->arguments ?? [];
376-
377-
// Your custom logic here
378-
return new CallToolResult([/* content */]);
379-
}
380-
}
381-
382357
$server = Server::builder()
383-
->setToolCaller(new CustomToolCaller());
358+
->addMethodHandler(new AuditHandler())
359+
->addMethodHandlers([
360+
new CustomListToolsHandler(),
361+
new CustomCallToolHandler(),
362+
])
363+
->build();
384364
```
385365

386-
### Custom Resource Reader
387-
388-
Replace how resource reading requests are processed. Your custom `ResourceReaderInterface` receives a `ReadResourceRequest`
389-
(with URI) and must return a `ReadResourceResult`.
366+
Custom handlers implement `MethodHandlerInterface`:
390367

391368
```php
392-
use Mcp\Capability\Resource\ResourceReaderInterface;
393-
use Mcp\Schema\Request\ReadResourceRequest;
394-
use Mcp\Schema\Result\ReadResourceResult;
369+
use Mcp\Schema\JsonRpc\HasMethodInterface;
370+
use Mcp\Server\Handler\MethodHandlerInterface;
371+
use Mcp\Server\Session\SessionInterface;
395372

396-
class CustomResourceReader implements ResourceReaderInterface
373+
interface MethodHandlerInterface
397374
{
398-
public function read(ReadResourceRequest $request): ReadResourceResult
399-
{
400-
// Custom resource resolution, caching, access control, etc.
401-
$uri = $request->uri;
402-
403-
// Your custom logic here
404-
return new ReadResourceResult([/* content */]);
405-
}
406-
}
375+
public function supports(HasMethodInterface $message): bool;
407376

408-
$server = Server::builder()
409-
->setResourceReader(new CustomResourceReader());
377+
public function handle(HasMethodInterface $message, SessionInterface $session);
378+
}
410379
```
411380

412-
### Custom Prompt Getter
413-
414-
Replace how prompt generation requests are processed. Your custom `PromptGetterInterface` receives a `GetPromptRequest`
415-
(with prompt name and arguments) and must return a `GetPromptResult`.
416-
417-
```php
418-
use Mcp\Capability\Prompt\PromptGetterInterface;
419-
use Mcp\Schema\Request\GetPromptRequest;
420-
use Mcp\Schema\Result\GetPromptResult;
421-
422-
class CustomPromptGetter implements PromptGetterInterface
423-
{
424-
public function get(GetPromptRequest $request): GetPromptResult
425-
{
426-
// Custom prompt generation, template engines, dynamic content, etc.
427-
$promptName = $request->name;
428-
$arguments = $request->arguments ?? [];
429-
430-
// Your custom logic here
431-
return new GetPromptResult([/* messages */]);
432-
}
433-
}
381+
- `supports()` decides if the handler should look at the incoming message.
382+
- `handle()` must return a JSON-RPC `Response`, an `Error`, or `null`.
434383

435-
$server = Server::builder()
436-
->setPromptGetter(new CustomPromptGetter());
437-
```
384+
Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement
385+
custom `tool/list` and `tool/call` methods independently of the registry.
438386

439-
> **Warning**: Custom capability handlers bypass the entire default registration system (discovered attributes, manual
440-
> registration, container resolution, etc.). You become responsible for all aspect of execution, including error handling,
441-
> logging, and result formatting. Only use this for very specific advanced use cases like custom authentication, complex
442-
> routing, or integration with external systems.
387+
> **Warning**: Custom method handlers bypass discovery, manual capability registration, and container lookups (unlesss
388+
> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler
389+
> loads and executes them manually.
390+
> Reach for this API only when you need that level of control and are comfortable taking on the additional plumbing.
443391
444392
## Complete Example
445393

@@ -505,9 +453,8 @@ $server = Server::builder()
505453
| `setLogger()` | logger | Set PSR-3 logger |
506454
| `setContainer()` | container | Set PSR-11 container |
507455
| `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher |
508-
| `setToolCaller()` | caller | Set custom tool caller |
509-
| `setResourceReader()` | reader | Set custom resource reader |
510-
| `setPromptGetter()` | getter | Set custom prompt getter |
456+
| `addMethodHandler()` | handler | Prepend a single custom method handler |
457+
| `addMethodHandlers()` | handlers | Prepend multiple custom method handlers |
511458
| `addTool()` | handler, name?, description?, annotations?, inputSchema? | Register tool |
512459
| `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource |
513460
| `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template |
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
/*
5+
* This file is part of the official PHP MCP SDK.
6+
*
7+
* A collaboration between Symfony and the PHP Foundation.
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
require_once dirname(__DIR__).'/bootstrap.php';
14+
chdir(__DIR__);
15+
16+
use Mcp\Schema\Content\TextContent;
17+
use Mcp\Schema\JsonRpc\Error;
18+
use Mcp\Schema\JsonRpc\HasMethodInterface;
19+
use Mcp\Schema\JsonRpc\Response;
20+
use Mcp\Schema\Request\CallToolRequest;
21+
use Mcp\Schema\Request\ListToolsRequest;
22+
use Mcp\Schema\Result\CallToolResult;
23+
use Mcp\Schema\Result\ListToolsResult;
24+
use Mcp\Schema\ServerCapabilities;
25+
use Mcp\Schema\Tool;
26+
use Mcp\Server;
27+
use Mcp\Server\Handler\MethodHandlerInterface;
28+
use Mcp\Server\Session\SessionInterface;
29+
use Mcp\Server\Transport\StdioTransport;
30+
31+
logger()->info('Starting MCP Custom Method Handlers (Stdio) Server...');
32+
33+
$toolDefinitions = [
34+
'say_hello' => new Tool(
35+
name: 'say_hello',
36+
inputSchema: [
37+
'type' => 'object',
38+
'properties' => [
39+
'name' => ['type' => 'string', 'description' => 'Name to greet'],
40+
],
41+
'required' => ['name'],
42+
],
43+
description: 'Greets a user by name.',
44+
annotations: null,
45+
),
46+
'sum' => new Tool(
47+
name: 'sum',
48+
inputSchema: [
49+
'type' => 'object',
50+
'properties' => [
51+
'a' => ['type' => 'number'],
52+
'b' => ['type' => 'number'],
53+
],
54+
'required' => ['a', 'b'],
55+
],
56+
description: 'Returns a+b.',
57+
annotations: null,
58+
),
59+
];
60+
61+
$listToolsHandler = new class($toolDefinitions) implements MethodHandlerInterface {
62+
/**
63+
* @param array<string, Tool> $toolDefinitions
64+
*/
65+
public function __construct(private array $toolDefinitions)
66+
{
67+
}
68+
69+
public function supports(HasMethodInterface $message): bool
70+
{
71+
return $message instanceof ListToolsRequest;
72+
}
73+
74+
public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response
75+
{
76+
assert($message instanceof ListToolsRequest);
77+
78+
return new Response($message->getId(), new ListToolsResult(array_values($this->toolDefinitions), null));
79+
}
80+
};
81+
82+
$callToolHandler = new class($toolDefinitions) implements MethodHandlerInterface {
83+
/**
84+
* @param array<string, Tool> $toolDefinitions
85+
*/
86+
public function __construct(private array $toolDefinitions)
87+
{
88+
}
89+
90+
public function supports(HasMethodInterface $message): bool
91+
{
92+
return $message instanceof CallToolRequest;
93+
}
94+
95+
public function handle(CallToolRequest|HasMethodInterface $message, SessionInterface $session): Response|Error
96+
{
97+
assert($message instanceof CallToolRequest);
98+
99+
$name = $message->name;
100+
$args = $message->arguments ?? [];
101+
102+
if (!isset($this->toolDefinitions[$name])) {
103+
return new Error($message->getId(), Error::METHOD_NOT_FOUND, sprintf('Tool not found: %s', $name));
104+
}
105+
106+
try {
107+
switch ($name) {
108+
case 'say_hello':
109+
$greetName = (string) ($args['name'] ?? 'world');
110+
$result = [new TextContent(sprintf('Hello, %s!', $greetName))];
111+
break;
112+
case 'sum':
113+
$a = (float) ($args['a'] ?? 0);
114+
$b = (float) ($args['b'] ?? 0);
115+
$result = [new TextContent((string) ($a + $b))];
116+
break;
117+
default:
118+
$result = [new TextContent('Unknown tool')];
119+
}
120+
121+
return new Response($message->getId(), new CallToolResult($result));
122+
} catch (Throwable $e) {
123+
return new Response($message->getId(), new CallToolResult([new TextContent('Tool execution failed')], true));
124+
}
125+
}
126+
};
127+
128+
$capabilities = new ServerCapabilities(tools: true, resources: false, prompts: false);
129+
130+
$server = Server::builder()
131+
->setServerInfo('Custom Handlers Server', '1.0.0')
132+
->setLogger(logger())
133+
->setContainer(container())
134+
->setCapabilities($capabilities)
135+
->addMethodHandlers([$listToolsHandler, $callToolHandler])
136+
->build();
137+
138+
$transport = new StdioTransport(logger: logger());
139+
140+
$server->connect($transport);
141+
142+
$transport->listen();
143+
144+
logger()->info('Server listener stopped gracefully.');

src/Capability/Completion/Completer.php

Lines changed: 0 additions & 75 deletions
This file was deleted.

src/Capability/Completion/CompleterInterface.php

Lines changed: 0 additions & 25 deletions
This file was deleted.

0 commit comments

Comments
 (0)