Skip to content

Commit 0e2472e

Browse files
feat: add comprehensive callable handler support
- Support closures, class methods, static methods, and invokable classes - Reorder handler resolution logic to prioritize container resolution - Add unique naming for closure handlers using spl_object_id() - Prevent closure serialization in cache with validation warnings - Update all registration methods to accept Closure|array|string
1 parent f33db1f commit 0e2472e

23 files changed

+1133
-370
lines changed

README.md

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This SDK enables you to expose your PHP application's functionality as standardi
1414
- **🏗️ Modern Architecture**: Built with PHP 8.1+ features, PSR standards, and modular design
1515
- **📡 Multiple Transports**: Supports `stdio`, `http+sse`, and new **streamable HTTP** with resumability
1616
- **🎯 Attribute-Based Definition**: Use PHP 8 Attributes (`#[McpTool]`, `#[McpResource]`, etc.) for zero-config element registration
17+
- **🔧 Flexible Handlers**: Support for closures, class methods, static methods, and invokable classes
1718
- **📝 Smart Schema Generation**: Automatic JSON schema generation from method signatures with optional `#[Schema]` attribute enhancements
1819
- **⚡ Session Management**: Advanced session handling with multiple storage backends
1920
- **🔄 Event-Driven**: ReactPHP-based for high concurrency and non-blocking operations
@@ -332,7 +333,7 @@ $server->discover(
332333

333334
### 2. 🔧 Manual Registration
334335

335-
Register elements programmatically using the `ServerBuilder` before calling `build()`. Useful for dynamic registration or when you prefer explicit control.
336+
Register elements programmatically using the `ServerBuilder` before calling `build()`. Useful for dynamic registration, closures, or when you prefer explicit control.
336337

337338
```php
338339
use App\Handlers\{EmailHandler, ConfigHandler, UserHandler, PromptHandler};
@@ -354,10 +355,21 @@ $server = Server::make()
354355
// Register invokable class as tool
355356
->withTool(UserHandler::class) // Handler: Invokable class
356357

357-
// Register a resource
358+
// Register a closure as tool
359+
->withTool(
360+
function(int $a, int $b): int { // Handler: Closure
361+
return $a + $b;
362+
},
363+
name: 'add_numbers',
364+
description: 'Add two numbers together'
365+
)
366+
367+
// Register a resource with closure
358368
->withResource(
359-
[ConfigHandler::class, 'getConfig'],
360-
uri: 'config://app/settings', // URI (required)
369+
function(): array { // Handler: Closure
370+
return ['timestamp' => time(), 'server' => 'php-mcp'];
371+
},
372+
uri: 'config://runtime/status', // URI (required)
361373
mimeType: 'application/json' // MIME type (optional)
362374
)
363375

@@ -367,22 +379,26 @@ $server = Server::make()
367379
uriTemplate: 'user://{userId}/profile' // URI template (required)
368380
)
369381

370-
// Register a prompt
382+
// Register a prompt with closure
371383
->withPrompt(
372-
[PromptHandler::class, 'generateSummary'],
373-
name: 'summarize_text' // Prompt name (optional)
384+
function(string $topic, string $tone = 'professional'): array {
385+
return [
386+
['role' => 'user', 'content' => "Write about {$topic} in a {$tone} tone"]
387+
];
388+
},
389+
name: 'writing_prompt' // Prompt name (optional)
374390
)
375391

376392
->build();
377393
```
378394

379-
**Key Features:**
395+
The server supports three flexible handler formats: `[ClassName::class, 'methodName']` for class method handlers, `InvokableClass::class` for invokable class handlers (classes with `__invoke` method), and any PHP callable including closures, static methods like `[SomeClass::class, 'staticMethod']`, or function names. Class-based handlers are resolved via the configured PSR-11 container for dependency injection. Manual registrations are never cached and take precedence over discovered elements with the same identifier.
380396

381-
- **Handler Formats**: Use `[ClassName::class, 'methodName']` or `InvokableClass::class`
382-
- **Dependency Injection**: Handlers resolved via configured PSR-11 container
383-
- **Immediate Registration**: Elements registered when `build()` is called
384-
- **No Caching**: Manual elements are never cached (always fresh)
385-
- **Precedence**: Manual registrations override discovered elements with same identifier
397+
> [!IMPORTANT]
398+
> When using closures as handlers, the server generates minimal JSON schemas based only on PHP type hints since there are no docblocks or class context available. For more detailed schemas with validation constraints, descriptions, and formats, you have two options:
399+
>
400+
> - Use the [`#[Schema]` attribute](#-schema-generation-and-validation) for enhanced schema generation
401+
> - Provide a custom `$inputSchema` parameter when registering tools with `->withTool()`
386402
387403
### 🏆 Element Precedence & Discovery
388404

@@ -1289,8 +1305,3 @@ The MIT License (MIT). See [LICENSE](LICENSE) for details.
12891305
- Built on the [Model Context Protocol](https://modelcontextprotocol.io/) specification
12901306
- Powered by [ReactPHP](https://reactphp.org/) for async operations
12911307
- Uses [PSR standards](https://www.php-fig.org/) for maximum interoperability
1292-
1293-
---
1294-
1295-
**Ready to build powerful MCP servers with PHP?** Start with our [Quick Start](#-quick-start-stdio-server-with-discovery) guide! 🚀
1296-

src/Elements/RegisteredElement.php

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use PhpMcp\Server\Exception\McpServerException;
1010
use Psr\Container\ContainerInterface;
1111
use ReflectionException;
12+
use ReflectionFunctionAbstract;
1213
use ReflectionMethod;
1314
use ReflectionNamedType;
1415
use ReflectionParameter;
@@ -18,32 +19,43 @@
1819
class RegisteredElement implements JsonSerializable
1920
{
2021
public function __construct(
21-
public readonly string $handlerClass,
22-
public readonly string $handlerMethod,
22+
public readonly \Closure|array|string $handler,
2323
public readonly bool $isManual = false,
24-
) {
25-
}
24+
) {}
2625

2726
public function handle(ContainerInterface $container, array $arguments): mixed
2827
{
29-
$instance = $container->get($this->handlerClass);
30-
$arguments = $this->prepareArguments($instance, $arguments);
31-
$method = $this->handlerMethod;
28+
if (is_string($this->handler)) {
29+
$reflection = new \ReflectionFunction($this->handler);
30+
$arguments = $this->prepareArguments($reflection, $arguments);
31+
$instance = $container->get($this->handler);
32+
return call_user_func($instance, ...$arguments);
33+
}
3234

33-
return $instance->$method(...$arguments);
34-
}
35+
if (is_callable($this->handler)) {
36+
$reflection = $this->getReflectionForCallable($this->handler);
37+
$arguments = $this->prepareArguments($reflection, $arguments);
38+
return call_user_func($this->handler, ...$arguments);
39+
}
3540

36-
protected function prepareArguments(object $instance, array $arguments): array
37-
{
38-
if (! method_exists($instance, $this->handlerMethod)) {
39-
throw new ReflectionException("Method does not exist: {$this->handlerClass}::{$this->handlerMethod}");
41+
if (is_array($this->handler)) {
42+
[$className, $methodName] = $this->handler;
43+
$reflection = new \ReflectionMethod($className, $methodName);
44+
$arguments = $this->prepareArguments($reflection, $arguments);
45+
46+
$instance = $container->get($className);
47+
return call_user_func([$instance, $methodName], ...$arguments);
4048
}
4149

42-
$reflectionMethod = new ReflectionMethod($instance, $this->handlerMethod);
50+
throw new \InvalidArgumentException('Invalid handler type');
51+
}
52+
4353

54+
protected function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments): array
55+
{
4456
$finalArgs = [];
4557

46-
foreach ($reflectionMethod->getParameters() as $parameter) {
58+
foreach ($reflection->getParameters() as $parameter) {
4759
// TODO: Handle variadic parameters.
4860
$paramName = $parameter->getName();
4961
$paramPosition = $parameter->getPosition();
@@ -67,15 +79,39 @@ protected function prepareArguments(object $instance, array $arguments): array
6779
} elseif ($parameter->isOptional()) {
6880
continue;
6981
} else {
82+
$reflectionName = $reflection instanceof \ReflectionMethod
83+
? $reflection->class . '::' . $reflection->name
84+
: 'Closure';
7085
throw McpServerException::internalError(
71-
"Missing required argument `{$paramName}` for {$reflectionMethod->class}::{$this->handlerMethod}."
86+
"Missing required argument `{$paramName}` for {$reflectionName}."
7287
);
7388
}
7489
}
7590

7691
return array_values($finalArgs);
7792
}
7893

94+
/**
95+
* Gets a ReflectionMethod or ReflectionFunction for a callable.
96+
*/
97+
private function getReflectionForCallable(callable $handler): \ReflectionMethod|\ReflectionFunction
98+
{
99+
if (is_string($handler)) {
100+
return new \ReflectionFunction($handler);
101+
}
102+
103+
if ($handler instanceof \Closure) {
104+
return new \ReflectionFunction($handler);
105+
}
106+
107+
if (is_array($handler) && count($handler) === 2) {
108+
[$class, $method] = $handler;
109+
return new \ReflectionMethod($class, $method);
110+
}
111+
112+
throw new \InvalidArgumentException('Cannot create reflection for this callable type');
113+
}
114+
79115
/**
80116
* Attempts type casting based on ReflectionParameter type hints.
81117
*
@@ -118,7 +154,7 @@ private function castArgumentType(mixed $argument, ReflectionParameter $paramete
118154
return $case;
119155
}
120156
}
121-
$validNames = array_map(fn ($c) => $c->name, $typeName::cases());
157+
$validNames = array_map(fn($c) => $c->name, $typeName::cases());
122158
throw new InvalidArgumentException(
123159
"Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: " . implode(', ', $validNames) . "."
124160
);
@@ -205,8 +241,7 @@ private function castToArray(mixed $argument): array
205241
public function toArray(): array
206242
{
207243
return [
208-
'handlerClass' => $this->handlerClass,
209-
'handlerMethod' => $this->handlerMethod,
244+
'handler' => $this->handler,
210245
'isManual' => $this->isManual,
211246
];
212247
}

src/Elements/RegisteredPrompt.php

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,16 @@ class RegisteredPrompt extends RegisteredElement
2121
{
2222
public function __construct(
2323
public readonly Prompt $schema,
24-
string $handlerClass,
25-
string $handlerMethod,
24+
\Closure|array|string $handler,
2625
bool $isManual = false,
2726
public readonly array $completionProviders = []
2827
) {
29-
parent::__construct($handlerClass, $handlerMethod, $isManual);
28+
parent::__construct($handler, $isManual);
3029
}
3130

32-
public static function make(Prompt $schema, string $handlerClass, string $handlerMethod, bool $isManual = false, array $completionProviders = []): self
31+
public static function make(Prompt $schema, \Closure|array|string $handler, bool $isManual = false, array $completionProviders = []): self
3332
{
34-
return new self($schema, $handlerClass, $handlerMethod, $isManual, $completionProviders);
33+
return new self($schema, $handler, $isManual, $completionProviders);
3534
}
3635

3736
/**
@@ -279,10 +278,13 @@ public function toArray(): array
279278
public static function fromArray(array $data): self|false
280279
{
281280
try {
281+
if (! isset($data['schema']) || ! isset($data['handler'])) {
282+
return false;
283+
}
284+
282285
return new self(
283286
Prompt::fromArray($data['schema']),
284-
$data['handlerClass'],
285-
$data['handlerMethod'],
287+
$data['handler'],
286288
$data['isManual'] ?? false,
287289
$data['completionProviders'] ?? [],
288290
);

src/Elements/RegisteredResource.php

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,15 @@ class RegisteredResource extends RegisteredElement
1616
{
1717
public function __construct(
1818
public readonly Resource $schema,
19-
string $handlerClass,
20-
string $handlerMethod,
19+
\Closure|array|string $handler,
2120
bool $isManual = false,
2221
) {
23-
parent::__construct($handlerClass, $handlerMethod, $isManual);
22+
parent::__construct($handler, $isManual);
2423
}
2524

26-
public static function make(Resource $schema, string $handlerClass, string $handlerMethod, bool $isManual = false): self
25+
public static function make(Resource $schema, \Closure|array|string $handler, bool $isManual = false): self
2726
{
28-
return new self($schema, $handlerClass, $handlerMethod, $isManual);
27+
return new self($schema, $handler, $isManual);
2928
}
3029

3130
/**
@@ -99,7 +98,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp
9998
}
10099

101100
if ($allAreEmbeddedResource && $hasEmbeddedResource) {
102-
return array_map(fn ($item) => $item->resource, $readResult);
101+
return array_map(fn($item) => $item->resource, $readResult);
103102
}
104103

105104
if ($hasResourceContents || $hasEmbeddedResource) {
@@ -218,10 +217,13 @@ public function toArray(): array
218217
public static function fromArray(array $data): self|false
219218
{
220219
try {
220+
if (! isset($data['schema']) || ! isset($data['handler'])) {
221+
return false;
222+
}
223+
221224
return new self(
222225
Resource::fromArray($data['schema']),
223-
$data['handlerClass'],
224-
$data['handlerMethod'],
226+
$data['handler'],
225227
$data['isManual'] ?? false,
226228
);
227229
} catch (Throwable $e) {

src/Elements/RegisteredResourceTemplate.php

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,18 @@ class RegisteredResourceTemplate extends RegisteredElement
2020

2121
public function __construct(
2222
public readonly ResourceTemplate $schema,
23-
string $handlerClass,
24-
string $handlerMethod,
23+
\Closure|array|string $handler,
2524
bool $isManual = false,
2625
public readonly array $completionProviders = []
2726
) {
28-
parent::__construct($handlerClass, $handlerMethod, $isManual);
27+
parent::__construct($handler, $isManual);
2928

3029
$this->compileTemplate();
3130
}
3231

33-
public static function make(ResourceTemplate $schema, string $handlerClass, string $handlerMethod, bool $isManual = false, array $completionProviders = []): self
32+
public static function make(ResourceTemplate $schema, \Closure|array|string $handler, bool $isManual = false, array $completionProviders = []): self
3433
{
35-
return new self($schema, $handlerClass, $handlerMethod, $isManual, $completionProviders);
34+
return new self($schema, $handler, $isManual, $completionProviders);
3635
}
3736

3837
/**
@@ -156,7 +155,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp
156155
}
157156

158157
if ($allAreEmbeddedResource && $hasEmbeddedResource) {
159-
return array_map(fn ($item) => $item->resource, $readResult);
158+
return array_map(fn($item) => $item->resource, $readResult);
160159
}
161160

162161
if ($hasResourceContents || $hasEmbeddedResource) {
@@ -276,10 +275,13 @@ public function toArray(): array
276275
public static function fromArray(array $data): self|false
277276
{
278277
try {
278+
if (! isset($data['schema']) || ! isset($data['handler'])) {
279+
return false;
280+
}
281+
279282
return new self(
280283
ResourceTemplate::fromArray($data['schema']),
281-
$data['handlerClass'],
282-
$data['handlerMethod'],
284+
$data['handler'],
283285
$data['isManual'] ?? false,
284286
$data['completionProviders'] ?? [],
285287
);

src/Elements/RegisteredTool.php

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,15 @@ class RegisteredTool extends RegisteredElement
1414
{
1515
public function __construct(
1616
public readonly Tool $schema,
17-
string $handlerClass,
18-
string $handlerMethod,
17+
\Closure|array|string $handler,
1918
bool $isManual = false,
2019
) {
21-
parent::__construct($handlerClass, $handlerMethod, $isManual);
20+
parent::__construct($handler, $isManual);
2221
}
2322

24-
public static function make(Tool $schema, string $handlerClass, string $handlerMethod, bool $isManual = false): self
23+
public static function make(Tool $schema, \Closure|array|string $handler, bool $isManual = false): self
2524
{
26-
return new self($schema, $handlerClass, $handlerMethod, $isManual);
25+
return new self($schema, $handler, $isManual);
2726
}
2827

2928
/**
@@ -125,10 +124,13 @@ public function toArray(): array
125124
public static function fromArray(array $data): self|false
126125
{
127126
try {
127+
if (! isset($data['schema']) || ! isset($data['handler'])) {
128+
return false;
129+
}
130+
128131
return new self(
129132
Tool::fromArray($data['schema']),
130-
$data['handlerClass'],
131-
$data['handlerMethod'],
133+
$data['handler'],
132134
$data['isManual'] ?? false,
133135
);
134136
} catch (Throwable $e) {

0 commit comments

Comments
 (0)