-
Notifications
You must be signed in to change notification settings - Fork 135
[Server] Allow runtime-resolved element handlers #294
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
base: main
Are you sure you want to change the base?
Changes from all commits
d85861e
3bb94b4
f4663b5
95984e5
867a40c
d10719c
37c2b5e
6488049
866b85b
5c05240
2725b32
219cfdf
1193528
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please default line length to 120 chars |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -362,6 +362,67 @@ the handler's method name and docblock. | |||||
|
|
||||||
| For more details on MCP elements, handlers, and attribute-based discovery, see [MCP Elements](mcp-elements.md). | ||||||
|
|
||||||
| ### Explicit element registration | ||||||
|
|
||||||
| When an element's name, schema, or description is only known at runtime | ||||||
| (for example, a Drupal module bridging configuration entities into MCP | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would favor to name drop the Drupal mcp module in the front readme instead:
Suggested change
|
||||||
| tools), pair an `Mcp\Schema\*` value object with one of the four handler | ||||||
| interfaces below and register it through `Builder::add()`. | ||||||
|
|
||||||
| | Element kind | Handler interface | | ||||||
| |-------------------|-------------------------------------------------------| | ||||||
| | Tool | `Mcp\Server\Handler\ToolHandlerInterface` | | ||||||
| | Resource | `Mcp\Server\Handler\ResourceHandlerInterface` | | ||||||
| | Resource template | `Mcp\Server\Handler\ResourceTemplateHandlerInterface` | | ||||||
| | Prompt | `Mcp\Server\Handler\PromptHandlerInterface` | | ||||||
|
|
||||||
| Each handler interface declares a single execution method. Tool and | ||||||
| prompt handlers receive an arguments map and a `ClientGateway`. Resource | ||||||
| handlers receive the requested URI; resource template handlers | ||||||
| additionally receive the parsed template variables. | ||||||
|
|
||||||
| ```php | ||||||
| use Mcp\Schema\Tool; | ||||||
| use Mcp\Server; | ||||||
| use Mcp\Server\ClientGateway; | ||||||
| use Mcp\Server\Handler\ToolHandlerInterface; | ||||||
|
|
||||||
| final class WeatherHandler implements ToolHandlerInterface | ||||||
| { | ||||||
| public function execute(array $arguments, ClientGateway $gateway): mixed | ||||||
| { | ||||||
| return ['temperature' => 21, 'unit' => 'C']; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| $tool = new Tool( | ||||||
| name: 'get_weather', | ||||||
| title: null, | ||||||
| inputSchema: [ | ||||||
| 'type' => 'object', | ||||||
| 'properties' => ['city' => ['type' => 'string']], | ||||||
| 'required' => ['city'], | ||||||
| ], | ||||||
| description: 'Returns the current weather for a city.', | ||||||
| annotations: null, | ||||||
| ); | ||||||
|
|
||||||
| $server = Server::builder() | ||||||
| ->add($tool, new WeatherHandler()) | ||||||
| ->build(); | ||||||
| ``` | ||||||
|
|
||||||
| `Builder::add()` validates the pairing at registration time. Pairing a | ||||||
| `Tool` definition with, for example, a `PromptHandlerInterface` raises | ||||||
| `Mcp\Exception\ConfigurationException`. The schema value object validates | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is actually implemented differently => raising a |
||||||
| its own inputs (name pattern, schema shape, etc.), so passing an | ||||||
| incomplete definition fails before `add()` returns. | ||||||
|
|
||||||
| Use `add()` when the metadata cannot be inferred from a handler class via | ||||||
| reflection. For statically-known elements, prefer | ||||||
| `addTool/addResource/addResourceTemplate/addPrompt`, which can derive | ||||||
| metadata from the handler's signature and docblock. | ||||||
|
|
||||||
| ## Service Dependencies | ||||||
|
|
||||||
| ### Container | ||||||
|
|
@@ -583,4 +644,5 @@ $server = Server::builder() | |||||
| | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | ||||||
| | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | | ||||||
| | `addPrompt()` | handler, name?, description? | Register prompt | | ||||||
| | `add()` | definition, handler | Register an element from a schema VO + handler pair | | ||||||
| | `build()` | - | Create the server instance | | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,8 +11,11 @@ | |
|
|
||
| namespace Mcp\Capability\Registry; | ||
|
|
||
| use Mcp\Server\Handler\ElementHandlerInterface; | ||
|
|
||
| /** | ||
| * @phpstan-type Handler \Closure|array{0: object|string, 1: string}|string | ||
| * @phpstan-type CallableHandler \Closure|array{0: object|string, 1: string}|string | ||
| * @phpstan-type Handler CallableHandler|ElementHandlerInterface | ||
|
Comment on lines
+17
to
+18
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am splitting this in two for convenience and documentation brevity. |
||
| * | ||
| * @author Kyrian Obikwelu <koshnawaza@gmail.com> | ||
| */ | ||
|
|
@@ -22,7 +25,7 @@ class ElementReference | |
| * @param Handler $handler | ||
| */ | ||
| public function __construct( | ||
| public readonly \Closure|array|string $handler, | ||
| public readonly \Closure|array|string|ElementHandlerInterface $handler, | ||
| public readonly bool $isManual = false, | ||
| ) { | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of the official PHP MCP SDK. | ||
| * | ||
| * A collaboration between Symfony and the PHP Foundation. | ||
| * | ||
| * For the full copyright and license information, please view the LICENSE | ||
| * file that was distributed with this source code. | ||
| */ | ||
|
|
||
| namespace Mcp\Capability\Registry\Loader; | ||
|
|
||
| use Mcp\Capability\RegistryInterface; | ||
| use Mcp\Schema\Prompt; | ||
| use Mcp\Schema\ResourceTemplate; | ||
| use Mcp\Schema\Tool; | ||
| use Mcp\Server\Handler\PromptHandlerInterface; | ||
| use Mcp\Server\Handler\ResourceHandlerInterface; | ||
| use Mcp\Server\Handler\ResourceTemplateHandlerInterface; | ||
| use Mcp\Server\Handler\ToolHandlerInterface; | ||
|
|
||
| /** | ||
| * Translates `Builder::add()` definition+handler pairs into Registry entries. | ||
| * | ||
| * Each pair is registered with `isManual: true`, matching the precedence used | ||
| * by the `ArrayLoader` for closure/array/string handlers. | ||
| * | ||
| * @author Mateu Aguiló Bosch <mateu.aguilo.bosch@gmail.com> | ||
| */ | ||
| final class ExplicitElementLoader implements LoaderInterface | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMHO this feels a bit excessive indirection, but I believe this is the idiomatic way to accomplish this. Perhaps we can avoid this if we add the elements to the registry directly in the builder. However, that approach will break the current responsibility encapsulation. Guidance/validation here is appreciated.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like having this as a basic loader, I wonder tho if this should be the |
||
| { | ||
| /** | ||
| * @param list<array{definition: Tool, handler: ToolHandlerInterface}> $tools | ||
| * @param list<array{definition: \Mcp\Schema\Resource, handler: ResourceHandlerInterface}> $resources | ||
| * @param list<array{definition: ResourceTemplate, handler: ResourceTemplateHandlerInterface}> $resourceTemplates | ||
| * @param list<array{definition: Prompt, handler: PromptHandlerInterface}> $prompts | ||
| */ | ||
| public function __construct( | ||
| private readonly array $tools = [], | ||
| private readonly array $resources = [], | ||
| private readonly array $resourceTemplates = [], | ||
| private readonly array $prompts = [], | ||
| ) { | ||
| } | ||
|
|
||
| public function load(RegistryInterface $registry): void | ||
| { | ||
| foreach ($this->tools as $entry) { | ||
| $registry->registerTool($entry['definition'], $entry['handler'], true); | ||
| } | ||
|
|
||
| foreach ($this->resources as $entry) { | ||
| $registry->registerResource($entry['definition'], $entry['handler'], true); | ||
| } | ||
|
|
||
| foreach ($this->resourceTemplates as $entry) { | ||
| $registry->registerResourceTemplate($entry['definition'], $entry['handler'], [], true); | ||
| } | ||
|
|
||
| foreach ($this->prompts as $entry) { | ||
| $registry->registerPrompt($entry['definition'], $entry['handler'], [], true); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,12 @@ | |
|
|
||
| use Mcp\Exception\InvalidArgumentException; | ||
| use Mcp\Exception\RegistryException; | ||
| use Mcp\Server\ClientGateway; | ||
| use Mcp\Server\Handler\ElementHandlerInterface; | ||
| use Mcp\Server\Handler\PromptHandlerInterface; | ||
| use Mcp\Server\Handler\ResourceHandlerInterface; | ||
| use Mcp\Server\Handler\ResourceTemplateHandlerInterface; | ||
| use Mcp\Server\Handler\ToolHandlerInterface; | ||
| use Mcp\Server\RequestContext; | ||
| use Mcp\Server\Session\SessionInterface; | ||
| use Psr\Container\ContainerInterface; | ||
|
|
@@ -33,33 +39,54 @@ public function __construct( | |
| public function handle(ElementReference $reference, array $arguments): mixed | ||
| { | ||
| $session = $arguments['_session']; | ||
| $handler = $reference->handler; | ||
|
|
||
| if ($handler instanceof ElementHandlerInterface) { | ||
| $client = new ClientGateway($session); | ||
| $callArgs = array_diff_key($arguments, array_flip(['_session', '_request'])); | ||
|
|
||
| return match (true) { | ||
| $handler instanceof ToolHandlerInterface => $handler->execute($callArgs, $client), | ||
| $handler instanceof PromptHandlerInterface => $handler->get($callArgs, $client), | ||
| $handler instanceof ResourceHandlerInterface => $handler->read( | ||
| $arguments['uri'] ?? throw new InvalidArgumentException('Resource dispatch requires a "uri" argument.'), | ||
| $client, | ||
| ), | ||
| $handler instanceof ResourceTemplateHandlerInterface => $handler->read( | ||
| $arguments['uri'] ?? throw new InvalidArgumentException('Resource template dispatch requires a "uri" argument.'), | ||
| array_diff_key($callArgs, ['uri' => null]), | ||
| $client, | ||
| ), | ||
| default => throw new InvalidArgumentException(\sprintf('Unsupported %s implementation: %s.', ElementHandlerInterface::class, $handler::class)), | ||
| }; | ||
| } | ||
|
|
||
| if (\is_string($reference->handler)) { | ||
| if (class_exists($reference->handler) && method_exists($reference->handler, '__invoke')) { | ||
| $reflection = new \ReflectionMethod($reference->handler, '__invoke'); | ||
| $instance = $this->getClassInstance($reference->handler); | ||
| if (\is_string($handler)) { | ||
| if (class_exists($handler) && method_exists($handler, '__invoke')) { | ||
| $reflection = new \ReflectionMethod($handler, '__invoke'); | ||
| $instance = $this->getClassInstance($handler); | ||
| $arguments = $this->prepareArguments($reflection, $arguments); | ||
|
|
||
| return \call_user_func($instance, ...$arguments); | ||
| } | ||
|
|
||
| if (\function_exists($reference->handler)) { | ||
| $reflection = new \ReflectionFunction($reference->handler); | ||
| if (\function_exists($handler)) { | ||
| $reflection = new \ReflectionFunction($handler); | ||
| $arguments = $this->prepareArguments($reflection, $arguments); | ||
|
|
||
| return \call_user_func($reference->handler, ...$arguments); | ||
| return \call_user_func($handler, ...$arguments); | ||
| } | ||
| } | ||
|
|
||
| if (\is_callable($reference->handler)) { | ||
| $reflection = $this->getReflectionForCallable($reference->handler, $session); | ||
| if (\is_callable($handler)) { | ||
| $reflection = $this->getReflectionForCallable($handler, $session); | ||
| $arguments = $this->prepareArguments($reflection, $arguments); | ||
|
|
||
| return \call_user_func($reference->handler, ...$arguments); | ||
| return \call_user_func($handler, ...$arguments); | ||
| } | ||
|
|
||
| if (\is_array($reference->handler)) { | ||
| [$className, $methodName] = $reference->handler; | ||
| if (\is_array($handler)) { | ||
| [$className, $methodName] = $handler; | ||
| $reflection = new \ReflectionMethod($className, $methodName); | ||
| $instance = $this->getClassInstance($className); | ||
|
Comment on lines
-37
to
91
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks disruptive at first glance but it's just using the new variable for
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. bloating the diff a bit, but fair enough to me |
||
| $arguments = $this->prepareArguments($reflection, $arguments); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can use more width, i basically default to 120 chars