From 1b98f1a124cd946886cc0841068e23a7cd43f4eb Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 22 Aug 2026 04:06:15 +0200 Subject: [PATCH] [Server] Keep injectable parameters out of the published inputSchema --- src/Capability/Discovery/SchemaGenerator.php | 12 ++-- .../Registry/InjectableParameters.php | 63 +++++++++++++++++++ src/Capability/Registry/ReferenceHandler.php | 14 +---- tests/Integration/Fixture/sampling.php | 16 +++++ tests/Integration/SamplingTest.php | 23 +++++++ .../Discovery/SchemaGeneratorFixture.php | 9 +++ .../Discovery/SchemaGeneratorTest.php | 11 ++++ .../Registry/ReferenceHandlerTest.php | 26 ++++++++ 8 files changed, 156 insertions(+), 18 deletions(-) create mode 100644 src/Capability/Registry/InjectableParameters.php diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 673477db..357c9e98 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -13,9 +13,9 @@ use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; +use Mcp\Capability\Registry\InjectableParameters; use Mcp\Exception\BadMethodCallException; use Mcp\Exception\InvalidArgumentException; -use Mcp\Server\RequestContext; use phpDocumentor\Reflection\DocBlock\Tags\Param; /** @@ -528,12 +528,10 @@ private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $refl foreach ($reflection->getParameters() as $rp) { $reflectionType = $rp->getType(); - if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { - $typeName = $reflectionType->getName(); - - if (is_a($typeName, RequestContext::class, true)) { - continue; - } + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin() + && InjectableParameters::supports($reflectionType->getName()) + ) { + continue; } $paramName = $rp->getName(); diff --git a/src/Capability/Registry/InjectableParameters.php b/src/Capability/Registry/InjectableParameters.php new file mode 100644 index 00000000..4e8eba24 --- /dev/null +++ b/src/Capability/Registry/InjectableParameters.php @@ -0,0 +1,63 @@ + $arguments the raw argument bag including the internal "_session" and "_request" entries + */ + public static function resolve(string $typeName, array $arguments): ?object + { + if (RequestContext::class === $typeName && isset($arguments['_session'], $arguments['_request'])) { + return new RequestContext($arguments['_session'], $arguments['_request']); + } + + if (ClientGateway::class === $typeName && isset($arguments['_session'])) { + return new ClientGateway($arguments['_session']); + } + + return null; + } +} diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 99e58442..ba5a18bf 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,8 +13,6 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; -use Mcp\Server\ClientGateway; -use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -106,15 +104,9 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array // Check if parameter is a special injectable type $type = $parameter->getType(); if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { - $typeName = $type->getName(); - - if (RequestContext::class === $typeName && isset($arguments['_session'], $arguments['_request'])) { - $finalArgs[$paramPosition] = new RequestContext($arguments['_session'], $arguments['_request']); - continue; - } - - if (ClientGateway::class === $typeName && isset($arguments['_session'])) { - $finalArgs[$paramPosition] = new ClientGateway($arguments['_session']); + $injected = InjectableParameters::resolve($type->getName(), $arguments); + if (null !== $injected) { + $finalArgs[$paramPosition] = $injected; continue; } } diff --git a/tests/Integration/Fixture/sampling.php b/tests/Integration/Fixture/sampling.php index bb37bbfa..5ea62c8a 100644 --- a/tests/Integration/Fixture/sampling.php +++ b/tests/Integration/Fixture/sampling.php @@ -16,6 +16,7 @@ use Mcp\Exception\ClientException; use Mcp\Schema\Content\TextContent; use Mcp\Server; +use Mcp\Server\ClientGateway; use Mcp\Server\RequestContext; use Mcp\Server\Transport\StdioTransport; @@ -38,5 +39,20 @@ static function (RequestContext $context, string $text): string { name: 'summarize', description: 'Summarizes text by asking the client to sample.', ) + ->addTool( + static function (ClientGateway $client, string $text): string { + try { + $result = $client->sample($text, maxTokens: 64); + } catch (ClientException $e) { + return $e->getMessage(); + } + + assert($result->content instanceof TextContent); + + return sprintf('%s said: %s', $result->model, $result->content->text); + }, + name: 'summarize_via_gateway', + description: 'Summarizes text through a directly injected gateway.', + ) ->build() ->run(new StdioTransport()); diff --git a/tests/Integration/SamplingTest.php b/tests/Integration/SamplingTest.php index c581cf86..0f452865 100644 --- a/tests/Integration/SamplingTest.php +++ b/tests/Integration/SamplingTest.php @@ -56,6 +56,29 @@ public function testPromptReachesTheClient(): void $this->assertSame(64, $seen[0]->maxTokens); } + #[TestDox('a gateway parameter is injected, not published in the schema')] + public function testGatewayParameterIsInjectedNotPublished(): void + { + $client = $this->connect('sampling', $this->clientSampling()); + + $tool = null; + foreach ($client->listTools()->tools as $candidate) { + if ('summarize_via_gateway' === $candidate->name) { + $tool = $candidate; + } + } + + $this->assertNotNull($tool); + $this->assertArrayNotHasKey('client', $tool->inputSchema['properties']); + $this->assertArrayHasKey('text', $tool->inputSchema['properties']); + $this->assertSame(['text'], $tool->inputSchema['required']); + + $result = $client->callTool('summarize_via_gateway', ['text' => 'a long report']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('test-model said: a long report', $result->content[0]->text); + } + #[TestDox('a client that cannot sample refuses instead of stalling the tool')] public function testClientWithoutSamplingRefuses(): void { diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 14f2d8f2..613369b2 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -13,6 +13,8 @@ use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; +use Mcp\Server\ClientGateway; +use Mcp\Server\RequestContext; use Mcp\Tests\Unit\Fixtures\Enum\BackedIntEnum; use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum; use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum; @@ -519,6 +521,13 @@ public function withParameterNamedRequest(string $_request): void { } + /** + * @param string $query The search query + */ + public function withInjectableParameters(string $query, ClientGateway $gateway, RequestContext $context, int $limit = 10): void + { + } + // ===== OUTPUT SCHEMA FIXTURES ===== #[McpTool( outputSchema: [ diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 51d73d2b..32817f64 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -413,6 +413,17 @@ public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven(): void $this->assertEquals(['inferredParam'], $schema['required']); } + public function testExcludesInjectableParameterTypesFromSchema(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'withInjectableParameters'); + $schema = $this->schemaGenerator->generate($method); + $this->assertArrayNotHasKey('gateway', $schema['properties']); + $this->assertArrayNotHasKey('context', $schema['properties']); + $this->assertEquals(['type' => 'string', 'description' => 'The search query'], $schema['properties']['query']); + $this->assertEquals(['type' => 'integer', 'default' => 10], $schema['properties']['limit']); + $this->assertEquals(['query'], $schema['required']); + } + public static function methodsWithForbiddenParameter(): array { return [ diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php index dadca9f5..f18196d8 100644 --- a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -108,6 +108,32 @@ public function read(string $uri, ClientGateway $gateway): mixed $this->assertInstanceOf(ClientGateway::class, $resourceHandler->receivedGateway); } + public function testHandleInjectsClientGatewayIntoReflectedHandler(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $handler = new class { + public ?ClientGateway $receivedGateway = null; + + public function search(string $query, ClientGateway $gateway): string + { + $this->receivedGateway = $gateway; + + return 'found: '.$query; + } + }; + + $result = (new ReferenceHandler())->handle(new ElementReference([$handler, 'search']), [ + '_session' => $session, + '_request' => new \stdClass(), + 'query' => 'foo', + ]); + + $this->assertSame('found: foo', $result); + $this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway); + } + public function testHandleStillReflectsOrdinaryClosuresAndDoesNotInjectArgumentBag(): void { $session = $this->createMock(SessionInterface::class);