From 923e80f1543fb99ffecf8cdf3fb67db9ec4cf112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20R=C3=BCtten?= Date: Mon, 24 Nov 2025 14:47:03 +0100 Subject: [PATCH] Add support for request metadata injection into tool handlers. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marvin Rütten --- README.md | 57 ++++++++++++++ src/Capability/Registry/ReferenceHandler.php | 16 ++++ src/Schema/Metadata.php | 53 +++++++++++++ .../Handler/Request/CallToolHandler.php | 5 ++ .../Registry/ReferenceHandlerTest.php | 78 +++++++++++++++++++ 5 files changed, 209 insertions(+) create mode 100644 src/Schema/Metadata.php create mode 100644 tests/Unit/Capability/Registry/ReferenceHandlerTest.php diff --git a/README.md b/README.md index 52469357..f3793190 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,63 @@ $server = Server::builder() ->build(); ``` +### Request Metadata Injection + +You can access request-scoped metadata inside tool handlers by type-hinting the `Mcp\Schema\Metadata` value object. The SDK will inject it automatically when present on the request. + +How it works: +- Clients may send arbitrary metadata in `params._meta` of the JSON-RPC request. +- The server forwards this metadata for tool calls and the `ReferenceHandler` injects it into parameters typed as `Metadata` or `?Metadata`. +- If your handler declares a non-nullable `Metadata` parameter but the request contains no `params._meta`, the SDK returns an internal error. Use `?Metadata` if it is optional. + +Example handler usage: + +```php +get('securitySchema'); + + return [ + 'result' => 'ok', + 'securitySchema' => $schema, + ]; + } + + #[McpTool(name: 'example_optional')] + public function exampleOptional(string $input, ?Metadata $meta = null): string + { + return $meta?->get('traceId') ?? 'no-meta'; + } +} +``` + +Calling a tool with metadata (JSON-RPC example): + +```json +{ + "jsonrpc": "2.0", + "id": "1", + "method": "tools/call", + "params": { + "name": "example_action", + "arguments": { "input": "hello" }, + "_meta": { "securitySchema": "secure-123", "traceId": "abc-xyz" } + } +} +``` + +Notes: +- For non-nullable `Metadata` parameters, the client must provide `params._meta`; otherwise an internal error is returned. +- For nullable `?Metadata` parameters, `null` will be injected when `params._meta` is absent. + ## Documentation **Core Concepts:** diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 7ce8c737..6f708e5f 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,6 +13,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; +use Mcp\Schema\Metadata; use Mcp\Server\ClientAwareInterface; use Mcp\Server\ClientGateway; use Mcp\Server\Session\SessionInterface; @@ -112,6 +113,21 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array $finalArgs[$paramPosition] = new ClientGateway($arguments['_session']); continue; } + + // Inject request metadata if requested + if (Metadata::class === $typeName) { + if (isset($arguments['_meta']) && \is_array($arguments['_meta'])) { + $finalArgs[$paramPosition] = new Metadata($arguments['_meta']); + } elseif ($parameter->allowsNull()) { + $finalArgs[$paramPosition] = null; + } else { + $reflectionName = $reflection instanceof \ReflectionMethod + ? $reflection->class.'::'.$reflection->name + : 'Closure'; + throw RegistryException::internalError("Missing required request metadata for parameter `{$paramName}` in {$reflectionName}. Provide `_meta` in request params or make the parameter nullable."); + } + continue; + } } if (isset($arguments[$paramName])) { diff --git a/src/Schema/Metadata.php b/src/Schema/Metadata.php new file mode 100644 index 00000000..db00bae2 --- /dev/null +++ b/src/Schema/Metadata.php @@ -0,0 +1,53 @@ +get('securitySchema'); + * return ['result' => 'ok', 'securitySchema' => $schema]; + * } + * + * The SDK will inject an instance automatically when the parameter is type-hinted + * with this class. If no metadata is present on the request and the parameter + * allows null, null will be passed; otherwise, an internal error will be thrown. + */ +final class Metadata +{ + /** + * @param array $data + */ + public function __construct(private array $data = []) + { + } + + /** + * @return array + */ + public function all(): array + { + return $this->data; + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->data); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->data[$key] ?? $default; + } +} diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index e0430802..40b904ee 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -61,6 +61,11 @@ public function handle(Request $request, SessionInterface $session): Response|Er $reference = $this->registry->getTool($toolName); $arguments['_session'] = $session; + // Pass request metadata through to the handler so it can be injected + // into method parameters when requested by type-hint. + if (null !== $request->getMeta()) { + $arguments['_meta'] = $request->getMeta(); + } $result = $this->referenceHandler->handle($reference, $arguments); diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php new file mode 100644 index 00000000..6e2bb384 --- /dev/null +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -0,0 +1,78 @@ +handler = new ReferenceHandler(); + $this->session = $this->createMock(SessionInterface::class); + } + + public function testInjectsMetadataIntoTypedParameter(): void + { + $fn = function (Metadata $meta): string { + return (string) $meta->get('securitySchema'); + }; + + $ref = new ElementReference($fn); + + $result = $this->handler->handle($ref, [ + '_session' => $this->session, + '_meta' => ['securitySchema' => 'secure-123'], + ]); + + $this->assertSame('secure-123', $result); + } + + public function testNullableMetadataReceivesNullWhenNotProvided(): void + { + $fn = function (?Metadata $meta): string { + return null === $meta ? 'no-meta' : 'has-meta'; + }; + + $ref = new ElementReference($fn); + + $result = $this->handler->handle($ref, [ + '_session' => $this->session, + ]); + + $this->assertSame('no-meta', $result); + } + + public function testRequiredMetadataThrowsInternalErrorWhenNotProvided(): void + { + $fn = function (Metadata $meta): array { + return $meta->all(); + }; + + $ref = new ElementReference($fn); + + $this->expectException(\Mcp\Exception\RegistryException::class); + $this->expectExceptionMessage('Missing required request metadata'); + + $this->handler->handle($ref, [ + '_session' => $this->session, + ]); + } +}