Skip to content

feat: Add comprehensive callable handler support for closures, static methods, and invokable classes #38

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

### 2. 🔧 Manual Registration

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

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

// Register a resource
// Register a closure as tool
->withTool(
function(int $a, int $b): int { // Handler: Closure
return $a + $b;
},
name: 'add_numbers',
description: 'Add two numbers together'
)

// Register a resource with closure
->withResource(
[ConfigHandler::class, 'getConfig'],
uri: 'config://app/settings', // URI (required)
function(): array { // Handler: Closure
return ['timestamp' => time(), 'server' => 'php-mcp'];
},
uri: 'config://runtime/status', // URI (required)
mimeType: 'application/json' // MIME type (optional)
)

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

// Register a prompt
// Register a prompt with closure
->withPrompt(
[PromptHandler::class, 'generateSummary'],
name: 'summarize_text' // Prompt name (optional)
function(string $topic, string $tone = 'professional'): array {
return [
['role' => 'user', 'content' => "Write about {$topic} in a {$tone} tone"]
];
},
name: 'writing_prompt' // Prompt name (optional)
)

->build();
```

**Key Features:**
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.

- **Handler Formats**: Use `[ClassName::class, 'methodName']` or `InvokableClass::class`
- **Dependency Injection**: Handlers resolved via configured PSR-11 container
- **Immediate Registration**: Elements registered when `build()` is called
- **No Caching**: Manual elements are never cached (always fresh)
- **Precedence**: Manual registrations override discovered elements with same identifier
> [!IMPORTANT]
> 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:
>
> - Use the [`#[Schema]` attribute](#-schema-generation-and-validation) for enhanced schema generation
> - Provide a custom `$inputSchema` parameter when registering tools with `->withTool()`

### 🏆 Element Precedence & Discovery

Expand Down Expand Up @@ -1289,8 +1305,3 @@ The MIT License (MIT). See [LICENSE](LICENSE) for details.
- Built on the [Model Context Protocol](https://modelcontextprotocol.io/) specification
- Powered by [ReactPHP](https://reactphp.org/) for async operations
- Uses [PSR standards](https://www.php-fig.org/) for maximum interoperability

---

**Ready to build powerful MCP servers with PHP?** Start with our [Quick Start](#-quick-start-stdio-server-with-discovery) guide! 🚀

42 changes: 42 additions & 0 deletions examples/02-discovery-http-userprofile/server.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,48 @@ public function log($level, \Stringable|string $message, array $context = []): v
->withCapabilities(ServerCapabilities::make(completions: true, logging: true))
->withLogger($logger)
->withContainer($container)
->withTool(
function (float $a, float $b, string $operation = 'add'): array {
$result = match ($operation) {
'add' => $a + $b,
'subtract' => $a - $b,
'multiply' => $a * $b,
'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Cannot divide by zero'),
default => throw new \InvalidArgumentException("Unknown operation: {$operation}")
};

return [
'operation' => $operation,
'operands' => [$a, $b],
'result' => $result
];
},
name: 'calculator',
description: 'Perform basic math operations (add, subtract, multiply, divide)'
)
->withResource(
function (): array {
$memoryUsage = memory_get_usage(true);
$memoryPeak = memory_get_peak_usage(true);
$uptime = time() - $_SERVER['REQUEST_TIME_FLOAT'] ?? time();
$serverSoftware = $_SERVER['SERVER_SOFTWARE'] ?? 'CLI';

return [
'server_time' => date('Y-m-d H:i:s'),
'uptime_seconds' => $uptime,
'memory_usage_mb' => round($memoryUsage / 1024 / 1024, 2),
'memory_peak_mb' => round($memoryPeak / 1024 / 1024, 2),
'php_version' => PHP_VERSION,
'server_software' => $serverSoftware,
'operating_system' => PHP_OS_FAMILY,
'status' => 'healthy'
];
},
uri: 'system://status',
name: 'system_status',
description: 'Current system status and runtime information',
mimeType: 'application/json'
)
->build();

$server->discover(__DIR__, ['.']);
Expand Down
73 changes: 54 additions & 19 deletions src/Elements/RegisteredElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use PhpMcp\Server\Exception\McpServerException;
use Psr\Container\ContainerInterface;
use ReflectionException;
use ReflectionFunctionAbstract;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
Expand All @@ -18,32 +19,43 @@
class RegisteredElement implements JsonSerializable
{
public function __construct(
public readonly string $handlerClass,
public readonly string $handlerMethod,
public readonly \Closure|array|string $handler,
public readonly bool $isManual = false,
) {
}
) {}

public function handle(ContainerInterface $container, array $arguments): mixed
{
$instance = $container->get($this->handlerClass);
$arguments = $this->prepareArguments($instance, $arguments);
$method = $this->handlerMethod;
if (is_string($this->handler)) {
$reflection = new \ReflectionFunction($this->handler);
$arguments = $this->prepareArguments($reflection, $arguments);
$instance = $container->get($this->handler);
return call_user_func($instance, ...$arguments);
}

return $instance->$method(...$arguments);
}
if (is_callable($this->handler)) {
$reflection = $this->getReflectionForCallable($this->handler);
$arguments = $this->prepareArguments($reflection, $arguments);
return call_user_func($this->handler, ...$arguments);
}

protected function prepareArguments(object $instance, array $arguments): array
{
if (! method_exists($instance, $this->handlerMethod)) {
throw new ReflectionException("Method does not exist: {$this->handlerClass}::{$this->handlerMethod}");
if (is_array($this->handler)) {
[$className, $methodName] = $this->handler;
$reflection = new \ReflectionMethod($className, $methodName);
$arguments = $this->prepareArguments($reflection, $arguments);

$instance = $container->get($className);
return call_user_func([$instance, $methodName], ...$arguments);
}

$reflectionMethod = new ReflectionMethod($instance, $this->handlerMethod);
throw new \InvalidArgumentException('Invalid handler type');
}


protected function prepareArguments(\ReflectionFunctionAbstract $reflection, array $arguments): array
{
$finalArgs = [];

foreach ($reflectionMethod->getParameters() as $parameter) {
foreach ($reflection->getParameters() as $parameter) {
// TODO: Handle variadic parameters.
$paramName = $parameter->getName();
$paramPosition = $parameter->getPosition();
Expand All @@ -67,15 +79,39 @@ protected function prepareArguments(object $instance, array $arguments): array
} elseif ($parameter->isOptional()) {
continue;
} else {
$reflectionName = $reflection instanceof \ReflectionMethod
? $reflection->class . '::' . $reflection->name
: 'Closure';
throw McpServerException::internalError(
"Missing required argument `{$paramName}` for {$reflectionMethod->class}::{$this->handlerMethod}."
"Missing required argument `{$paramName}` for {$reflectionName}."
);
}
}

return array_values($finalArgs);
}

/**
* Gets a ReflectionMethod or ReflectionFunction for a callable.
*/
private function getReflectionForCallable(callable $handler): \ReflectionMethod|\ReflectionFunction
{
if (is_string($handler)) {
return new \ReflectionFunction($handler);
}

if ($handler instanceof \Closure) {
return new \ReflectionFunction($handler);
}

if (is_array($handler) && count($handler) === 2) {
[$class, $method] = $handler;
return new \ReflectionMethod($class, $method);
}

throw new \InvalidArgumentException('Cannot create reflection for this callable type');
}

/**
* Attempts type casting based on ReflectionParameter type hints.
*
Expand Down Expand Up @@ -118,7 +154,7 @@ private function castArgumentType(mixed $argument, ReflectionParameter $paramete
return $case;
}
}
$validNames = array_map(fn ($c) => $c->name, $typeName::cases());
$validNames = array_map(fn($c) => $c->name, $typeName::cases());
throw new InvalidArgumentException(
"Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: " . implode(', ', $validNames) . "."
);
Expand Down Expand Up @@ -205,8 +241,7 @@ private function castToArray(mixed $argument): array
public function toArray(): array
{
return [
'handlerClass' => $this->handlerClass,
'handlerMethod' => $this->handlerMethod,
'handler' => $this->handler,
'isManual' => $this->isManual,
];
}
Expand Down
16 changes: 9 additions & 7 deletions src/Elements/RegisteredPrompt.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,16 @@ class RegisteredPrompt extends RegisteredElement
{
public function __construct(
public readonly Prompt $schema,
string $handlerClass,
string $handlerMethod,
\Closure|array|string $handler,
bool $isManual = false,
public readonly array $completionProviders = []
) {
parent::__construct($handlerClass, $handlerMethod, $isManual);
parent::__construct($handler, $isManual);
}

public static function make(Prompt $schema, string $handlerClass, string $handlerMethod, bool $isManual = false, array $completionProviders = []): self
public static function make(Prompt $schema, \Closure|array|string $handler, bool $isManual = false, array $completionProviders = []): self
{
return new self($schema, $handlerClass, $handlerMethod, $isManual, $completionProviders);
return new self($schema, $handler, $isManual, $completionProviders);
}

/**
Expand Down Expand Up @@ -279,10 +278,13 @@ public function toArray(): array
public static function fromArray(array $data): self|false
{
try {
if (! isset($data['schema']) || ! isset($data['handler'])) {
return false;
}

return new self(
Prompt::fromArray($data['schema']),
$data['handlerClass'],
$data['handlerMethod'],
$data['handler'],
$data['isManual'] ?? false,
$data['completionProviders'] ?? [],
);
Expand Down
18 changes: 10 additions & 8 deletions src/Elements/RegisteredResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,15 @@ class RegisteredResource extends RegisteredElement
{
public function __construct(
public readonly Resource $schema,
string $handlerClass,
string $handlerMethod,
\Closure|array|string $handler,
bool $isManual = false,
) {
parent::__construct($handlerClass, $handlerMethod, $isManual);
parent::__construct($handler, $isManual);
}

public static function make(Resource $schema, string $handlerClass, string $handlerMethod, bool $isManual = false): self
public static function make(Resource $schema, \Closure|array|string $handler, bool $isManual = false): self
{
return new self($schema, $handlerClass, $handlerMethod, $isManual);
return new self($schema, $handler, $isManual);
}

/**
Expand Down Expand Up @@ -99,7 +98,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp
}

if ($allAreEmbeddedResource && $hasEmbeddedResource) {
return array_map(fn ($item) => $item->resource, $readResult);
return array_map(fn($item) => $item->resource, $readResult);
}

if ($hasResourceContents || $hasEmbeddedResource) {
Expand Down Expand Up @@ -218,10 +217,13 @@ public function toArray(): array
public static function fromArray(array $data): self|false
{
try {
if (! isset($data['schema']) || ! isset($data['handler'])) {
return false;
}

return new self(
Resource::fromArray($data['schema']),
$data['handlerClass'],
$data['handlerMethod'],
$data['handler'],
$data['isManual'] ?? false,
);
} catch (Throwable $e) {
Expand Down
18 changes: 10 additions & 8 deletions src/Elements/RegisteredResourceTemplate.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,18 @@ class RegisteredResourceTemplate extends RegisteredElement

public function __construct(
public readonly ResourceTemplate $schema,
string $handlerClass,
string $handlerMethod,
\Closure|array|string $handler,
bool $isManual = false,
public readonly array $completionProviders = []
) {
parent::__construct($handlerClass, $handlerMethod, $isManual);
parent::__construct($handler, $isManual);

$this->compileTemplate();
}

public static function make(ResourceTemplate $schema, string $handlerClass, string $handlerMethod, bool $isManual = false, array $completionProviders = []): self
public static function make(ResourceTemplate $schema, \Closure|array|string $handler, bool $isManual = false, array $completionProviders = []): self
{
return new self($schema, $handlerClass, $handlerMethod, $isManual, $completionProviders);
return new self($schema, $handler, $isManual, $completionProviders);
}

/**
Expand Down Expand Up @@ -156,7 +155,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp
}

if ($allAreEmbeddedResource && $hasEmbeddedResource) {
return array_map(fn ($item) => $item->resource, $readResult);
return array_map(fn($item) => $item->resource, $readResult);
}

if ($hasResourceContents || $hasEmbeddedResource) {
Expand Down Expand Up @@ -276,10 +275,13 @@ public function toArray(): array
public static function fromArray(array $data): self|false
{
try {
if (! isset($data['schema']) || ! isset($data['handler'])) {
return false;
}

return new self(
ResourceTemplate::fromArray($data['schema']),
$data['handlerClass'],
$data['handlerMethod'],
$data['handler'],
$data['isManual'] ?? false,
$data['completionProviders'] ?? [],
);
Expand Down
Loading