Skip to content

[Server] Allow runtime-resolved element handlers#294

Merged
soyuka merged 21 commits into
modelcontextprotocol:mainfrom
e0ipso:allow-runtime-tools
May 29, 2026
Merged

[Server] Allow runtime-resolved element handlers#294
soyuka merged 21 commits into
modelcontextprotocol:mainfrom
e0ipso:allow-runtime-tools

Conversation

@e0ipso
Copy link
Copy Markdown
Contributor

@e0ipso e0ipso commented Apr 29, 2026

Summary

Adds Builder::add(Tool|ResourceDefinition|ResourceTemplate|Prompt $definition, ElementHandlerInterface $handler) for cases where an element's name, schema, or description is only known at runtime. The existing addTool/addResource/addResourceTemplate/addPrompt methods, which derive metadata from reflection, keep working unchanged.

Four handler interfaces back the new entry point:

  • ToolHandlerInterface::execute(array $arguments, ClientGateway $gateway)
  • ResourceHandlerInterface::read(string $uri, ClientGateway $gateway)
  • ResourceTemplateHandlerInterface::read(string $uri, array $variables, ClientGateway $gateway)
  • PromptHandlerInterface::get(array $arguments, ClientGateway $gateway)

All four extend an empty ElementHandlerInterface marker so Builder::add() can accept any of them through one signature. Mismatched pairings (for example a Tool with a PromptHandlerInterface) raise Mcp\Exception\InvalidArgumentException at registration.

A new ExplicitElementLoader translates these pairs into Registry entries. It is wired in Builder::build() alongside ReflectedElementLoader (renamed from ArrayLoader), so the reflection-based loader is unchanged. ReferenceHandler::handle() builds a ClientGateway from the active session and injects it into handlers that declare a ClientGateway parameter.

Motivation and Context

Reflection-based discovery does not fit config-driven integrations where parameter names, schemas, and arguments are not known at the time the code is written. Two concrete blockers:

  • ReferenceHandler::prepareArguments() binds incoming arguments to handler parameters by name via reflection, so the handler signature has to mirror the input schema field names.
  • HandlerResolver::resolve() only accepts [ClassName::class, 'method'] strings, which rules out DI-constructed handler instances with constructor arguments.

The new entry point gives those integrations a typed registration path without changing the existing one. Standalone usage with attributes or callables is unaffected.

How Has This Been Tested?

  • ExplicitElementLoaderTest covers tool, resource, resource template, and prompt registration through Builder::add(), plus type-mismatch failures.
  • ReferenceHandlerTest covers the explicit-handler dispatch branch in ReferenceHandler::handle() for all four element kinds.
  • Existing tests still pass.

Breaking Changes

  • Renamed Mcp\Schema\Resource to Mcp\Schema\ResourceDefinition. No alias.
  • Renamed Mcp\Capability\Registry\Loader\ArrayLoader to Mcp\Capability\Registry\Loader\ReflectedElementLoader.

Existing closure/array/string handler forms keep working through ReflectedElementLoader.

Types of changes

  • Bug fix
  • New feature (non-breaking)
  • Breaking change
  • Documentation update

Checklist

  • My code follows the repository style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Copy link
Copy Markdown

@mglaman mglaman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can simplify the array loader

Comment thread src/Capability/Registry/Loader/ArrayLoader.php Outdated
@mglaman
Copy link
Copy Markdown

mglaman commented Apr 29, 2026

Actually I was looking at \Mcp\Capability\Registry\Loader\ArrayLoader::load:

It respects everything from $data, which allows dynamic inputSchema and outputSchema if you use \Mcp\Server\Builder::addTool. The reflection is still made, but can be ignored. Do we need a new interface for this? Because the runtime information can be generated when the server is built

@e0ipso
Copy link
Copy Markdown
Contributor Author

e0ipso commented Apr 30, 2026

Thanks for the review @mglaman! I have updated the code based on your suggestions. I like it much better now!


Here is my answer to your question above on "what is the point of this PR?". I should have addressed that more clearly in the PR description.

Dispatch binds by parameter name, not by schema. ReferenceHandler::prepareArguments() iterates the handler's ReflectionParameters and looks up $arguments[$paramName]. Variadic is explicitly TODO. So if your tool's inputSchema is {title: string, body: string}, the handler method has to be fn(string $title, string $body). With a config-driven schema you don't know those parameter names at the time of writing the code.

Additionally HandlerResolver::resolve() requires both elements to be strings, so [$instance, 'execute'] is rejected. Only [ClassName::class, 'method'] works. For DI-constructed objects with constructor args, that doesn't work. That could be addressed differently, but this PR removes that need.

Finally, prompts/templates have their PromptArguments built from reflection only. There is no field in the manual-prompt array for arguments or completion providers.

I am not saying the interface isn't the only way, but it is typically the way to write a contract for code integration.

@mglaman
Copy link
Copy Markdown

mglaman commented Apr 30, 2026

So if your tool's inputSchema is {title: string, body: string}, the handler method has to be fn(string $title, string $body). With a config-driven schema you don't know those parameter names at the time of writing the code.

Perfect, that's what I was missing. Handler signature must match the schema. But in my opinion that would always be the case. I guess it's just hard for me to visualize the contract of something using this. The tests and docs don't cover array $arguments.

I still don't understand what a "from configuration" tool would look like. I know Drupal does fancy stuff but I am failing to visualize an example of the code to help me grok this change. I am still on the "I'd have a plugin that maps Drupalisms to the SDK"

@chr-hertel
Copy link
Copy Markdown
Member

chr-hertel commented Apr 30, 2026

Hi @e0ipso, interesting approach and quite valid use case I understand.
I'm not sure I get what's the benefit of reusing the ArrayLoader?

With api-platform/mcp @soyuka had quite a similar problem - that's why we have that loader extension point IIRC. however, he needed to go with an extra handler as well and I feel like it is desirable to solve that use case on SDK level already 🤔

Maybe we can even think of inverting the default - for standalone usage the discovery feature is great, but in most other cases i saw, it's basically worked around - those RuntimeXyzInterface implementation feel similarly to me, WDYT?


Edit, second thought:
It could be convenient to have a Builder::add(new Prompt(...), new MyPromptHandler() implements PromptHandlerInterface) maybe

Comment thread docs/server-builder.md Outdated
@e0ipso
Copy link
Copy Markdown
Contributor Author

e0ipso commented May 1, 2026

@chr-hertel if I understand correctly your preference is:

  1. We add a new helper Builder::add(ElementInterface $element, ElementHandlerInterface $handler) that is documented as the primary registration.
  2. The reflection-based method is documented as sugar syntax.
  3. This Builder::add(...) registers using a dedicated, and new, loader. This leaves the ArrayLoader alone (instead of branching inside and sniffing interfaces).

@chr-hertel
Copy link
Copy Markdown
Member

Might be worth exploring if working, but would like to have more opinions here @soyuka @Nyholm @CodeWithKyrian

@e0ipso
Copy link
Copy Markdown
Contributor Author

e0ipso commented May 1, 2026

@chr-hertel I think implemented your suggestion. I learned more about the project in the process ☺️

@e0ipso
Copy link
Copy Markdown
Contributor Author

e0ipso commented May 1, 2026

@chr-hertel I had an LLM update the PR description to reflect the conversation so far, and the new direction. I'm notifying you in case you want to revise your 👍

Comment thread src/Capability/Registry/ElementReference.php Outdated
Comment thread src/Capability/Registry/ReferenceHandler.php
Comment thread src/Capability/Registry/Loader/ExplicitElementLoader.php
Copy link
Copy Markdown

@mglaman mglaman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems much better

Comment thread src/Server/Handler/ElementHandlerInterface.php
@e0ipso
Copy link
Copy Markdown
Contributor Author

e0ipso commented May 8, 2026

@chr-hertel @Nyholm et al. Please let me know if you prefer that I take this in a different direction instead.

@chr-hertel
Copy link
Copy Markdown
Member

Will come back to this in a minute 👍 first sorting small stuff right now, thanks already!

@chr-hertel chr-hertel added Server Issues & PRs related to the Server component enhancement Request for a new feature that's not currently supported labels May 8, 2026
@chr-hertel chr-hertel added this to the 0.6.0 milestone May 8, 2026
Copy link
Copy Markdown
Member

@chr-hertel chr-hertel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this very much already - switched to nit-pick-mode while reviewing - first round of comments tho. Thanks @e0ipso!

Comment thread docs/mcp-elements.md Outdated
Comment thread docs/server-builder.md Outdated
Comment thread docs/server-builder.md
Comment thread src/Capability/Registry/Loader/ExplicitElementLoader.php
Comment thread src/Capability/Registry/ReferenceHandler.php
@chr-hertel
Copy link
Copy Markdown
Member

Also needs a CHANGELOG.md entry please 👍

Comment thread docs/server-builder.md Outdated
Comment thread src/Server/Builder.php Outdated
@chr-hertel chr-hertel added needs more work Not ready to be merged yet, needs additional follow-up from the author(s). labels May 8, 2026
e0ipso and others added 10 commits May 20, 2026 15:37
Uuid::v7() was added in symfony/uid 6.2 and breaks the Symfony 5.4
matrix job. Match the convention used elsewhere in the suite.
Extract per-element preparation into dedicated private prepare*
methods for tools, resources, templates, and prompts. Factor the
shared required-field assertion and reflected name/description
resolution into helpers.
Drop nullable return types on RuntimeToolHandlerInterface::
getInputSchema(), RuntimePromptHandlerInterface::
getPromptArguments()/getCompletionProviders(), and
RuntimeResourceTemplateHandlerInterface::
getCompletionProviders() in favor of plain arrays.
ArrayLoader no longer needs the null-coalescing
fallbacks, and NullSchemaToolHandler plus its
ConfigurationException test become redundant.
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
Introduce a new `Builder::add(...)` pattern for a more generic runtime
handler.
Move per-interface handler dispatch from ReferenceHandler into
ExplicitElementLoader by wrapping each ElementHandlerInterface in a
Closure that satisfies the existing callable contract, revert the
*Reference / Registry handler-type widening PR 294 introduced, throw
Mcp\Exception\InvalidArgumentException (not \TypeError) on mismatched
Builder::add() pairings, and rename two classes for clarity:

* Mcp\Capability\Registry\Loader\ArrayLoader -> ReflectedElementLoader
* Mcp\Schema\Resource -> Mcp\Schema\ResourceDefinition (no alias)

ReferenceHandler::prepareArguments() now injects ClientGateway by type
and binds an `array $arguments` / `array $variables` closure parameter
from the leftover inbound arguments. This addresses Antoine's coupling
concern (ReferenceHandler no longer knows about the four handler
interfaces) and Chris's package-exception preference, and clears the
phpdoc reserved-word collision Antoine flagged on the resource schema
class. Existing tests are re-targeted to exercise the closure path
through ExplicitElementLoader / ReferenceHandler.
Apply Chris's line-level suggestions on docs/server-builder.md
(rewrap to 120 chars, smoothed Drupal mcp module name-drop in the
explicit-element registration section, exception name reads
Mcp\Exception\InvalidArgumentException), rewrap docs/mcp-elements.md
to 120 chars, and add 0.6.0 CHANGELOG entries covering the
Builder::add() entry point, the new handler interfaces, the new
ExplicitElementLoader, plus three BC breaks (Resource ->
ResourceDefinition rename, ArrayLoader -> ReflectedElementLoader
rename, mismatched-pairing exception swap).
* CHANGELOG: drop spurious [BC Break] entry for the exception
  type Builder::add() raises (method is new in this PR).
* docs/server-builder: revert wrap-only edits to pre-existing
  main content; keep 120ch wrapping only for new sections.
* ExplicitElementLoader: trim verbose class docblock.
* ReferenceHandler: rename SPLAT_PARAMETER_NAMES constant to
  ARGUMENT_BAG_PARAMETERS.
@e0ipso e0ipso force-pushed the allow-runtime-tools branch from aae7a3a to 7fcbcdc Compare May 20, 2026 13:37
@e0ipso e0ipso requested review from chr-hertel and soyuka May 20, 2026 14:12
@e0ipso
Copy link
Copy Markdown
Contributor Author

e0ipso commented May 20, 2026

I think this should be pretty close. The Drupal implementation is quite clean when using this patch, I am so happy about this one!!

Comment on lines +127 to +136
} elseif (
$type instanceof \ReflectionNamedType
&& 'array' === $type->getName()
&& \in_array($paramName, self::ARGUMENT_BAG_PARAMETERS, true)
) {
$skipKeys = array_merge(
self::RESERVED_ARGUMENT_KEYS,
array_values(array_diff($allParamNames, [$paramName])),
);
$finalArgs[$paramPosition] = array_diff_key($arguments, array_flip($skipKeys));
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the ugly part. I am open to suggestions.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm a bit worried about the impact to tools, that actually have array $argument, but we don't know at this level if we're dealing with an interface implementation, right?

just copying your concern i guess ... 🤔

Copy link
Copy Markdown
Member

@chr-hertel chr-hertel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit pick level, only the "ugly part" got me thinking as well ...
Need to let that sink in, but those small items is actionable already.
Thanks @e0ipso!

Comment on lines +66 to +69
$registry->registerResourceTemplate(
$entry['definition'],
static fn (string $uri, array $variables, ClientGateway $client) => $handler->read($uri, $variables, $client),
);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third argument completionProviders basically lost, not sure how to tackle that - would accept that for now. Same for prompts below.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we just add the completionProvider? there'd be a feature gap between the two loaders if not?

Copy link
Copy Markdown
Member

@chr-hertel chr-hertel May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was my thought as well, but we only have add(Prompt, PromptHandler) at the moment.

i don't think it should be part of the Mcp\Schema\Prompt instance, so it could be an optional third argument?
add(Prompt, PromptHandler, ?CompletionProvider)

Comment thread src/Server/Builder.php Outdated

// ArrayLoader runs before DiscoveryLoader so manual entries are seen first; DiscoveryLoader's
// identity check then preserves them against same-name discovered entries.
// ReflectedElementLoader runs before DiscoveryLoader so manual entries are seen first;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// ReflectedElementLoader runs before DiscoveryLoader so manual entries are seen first;
// ExplicitElementLoader and ReflectedElementLoader run before DiscoveryLoader so manual entries are seen first;

Comment thread docs/server-builder.md Outdated

### Explicit element registration

When an element's name, schema, or description is only known at runtime (for example, the Drupal `mcp` module exposing
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move the Drupal reference to README section "PHP Libraries Using the MCP SDK"

Comment on lines +127 to +136
} elseif (
$type instanceof \ReflectionNamedType
&& 'array' === $type->getName()
&& \in_array($paramName, self::ARGUMENT_BAG_PARAMETERS, true)
) {
$skipKeys = array_merge(
self::RESERVED_ARGUMENT_KEYS,
array_values(array_diff($allParamNames, [$paramName])),
);
$finalArgs[$paramPosition] = array_diff_key($arguments, array_flip($skipKeys));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm a bit worried about the impact to tools, that actually have array $argument, but we don't know at this level if we're dealing with an interface implementation, right?

just copying your concern i guess ... 🤔

self::RESERVED_ARGUMENT_KEYS,
array_values(array_diff($allParamNames, [$paramName])),
);
$finalArgs[$paramPosition] = array_diff_key($arguments, array_flip($skipKeys));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proposed fix pushed at soyuka/php-sdk@c214a5d.

The idea is to bind ExplicitElementLoader closures to ReferenceHandler::class as their closure scope. ReferenceHandler::handle() short-circuits when getClosureScopeClass()?->getName() === self::class and invokes the closure with the raw argument bag. This drops the magic-name heuristic (array $arguments / array $variables matched by parameter name) and the RESERVED_ARGUMENT_KEYS / ARGUMENT_BAG_PARAMETERS consts.

An alternative would be a wrapper class + widening, but its really nitpicking and probably not worth it.

Feel free to cherry-pick the commit.

@soyuka
Copy link
Copy Markdown
Contributor

soyuka commented May 21, 2026

Nice I like this implementation better! We could've left the Resource rename in another PR to lower the diff but we should've taken care of that anyways...

e0ipso and others added 3 commits May 28, 2026 16:41
docs: mention ExplicitElementLoader in comment

docs: move Drupal reference to README list

docs: drop implementation-only changelog entry

docs: revert rewrap noise from mcp-elements.md
Copy link
Copy Markdown
Member

@chr-hertel chr-hertel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @e0ipso - works for me like this 👍

@chr-hertel chr-hertel requested a review from soyuka May 28, 2026 20:34
@soyuka soyuka merged commit 0347dc8 into modelcontextprotocol:main May 29, 2026
18 checks passed
@soyuka
Copy link
Copy Markdown
Contributor

soyuka commented May 29, 2026

Thanks!

@e0ipso
Copy link
Copy Markdown
Contributor Author

e0ipso commented May 29, 2026

Thank you!

@e0ipso
Copy link
Copy Markdown
Contributor Author

e0ipso commented May 29, 2026

Crossing fingers for a v0.6.0 soon 🤞

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Request for a new feature that's not currently supported needs more work Not ready to be merged yet, needs additional follow-up from the author(s). Server Issues & PRs related to the Server component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants