Skip to content

Commit e173367

Browse files
authored
[Server] Keep injectable parameters out of the published inputSchema (#482)
1 parent ff0b5fa commit e173367

8 files changed

Lines changed: 156 additions & 18 deletions

File tree

src/Capability/Discovery/SchemaGenerator.php

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313

1414
use Mcp\Capability\Attribute\McpTool;
1515
use Mcp\Capability\Attribute\Schema;
16+
use Mcp\Capability\Registry\InjectableParameters;
1617
use Mcp\Exception\BadMethodCallException;
1718
use Mcp\Exception\InvalidArgumentException;
18-
use Mcp\Server\RequestContext;
1919
use phpDocumentor\Reflection\DocBlock\Tags\Param;
2020

2121
/**
@@ -528,12 +528,10 @@ private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $refl
528528
foreach ($reflection->getParameters() as $rp) {
529529
$reflectionType = $rp->getType();
530530

531-
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
532-
$typeName = $reflectionType->getName();
533-
534-
if (is_a($typeName, RequestContext::class, true)) {
535-
continue;
536-
}
531+
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()
532+
&& InjectableParameters::supports($reflectionType->getName())
533+
) {
534+
continue;
537535
}
538536

539537
$paramName = $rp->getName();
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Capability\Registry;
13+
14+
use Mcp\Server\ClientGateway;
15+
use Mcp\Server\RequestContext;
16+
17+
/**
18+
* Single source of truth for handler parameter types the SDK injects itself.
19+
*
20+
* Both schema generation (which must exclude these parameters from the
21+
* published inputSchema) and argument preparation (which must inject them)
22+
* read this list, so the two cannot drift apart.
23+
*
24+
* @internal
25+
*/
26+
final class InjectableParameters
27+
{
28+
private const TYPES = [
29+
RequestContext::class,
30+
ClientGateway::class,
31+
];
32+
33+
private function __construct()
34+
{
35+
}
36+
37+
public static function supports(string $typeName): bool
38+
{
39+
foreach (self::TYPES as $type) {
40+
if (is_a($typeName, $type, true)) {
41+
return true;
42+
}
43+
}
44+
45+
return false;
46+
}
47+
48+
/**
49+
* @param array<string, mixed> $arguments the raw argument bag including the internal "_session" and "_request" entries
50+
*/
51+
public static function resolve(string $typeName, array $arguments): ?object
52+
{
53+
if (RequestContext::class === $typeName && isset($arguments['_session'], $arguments['_request'])) {
54+
return new RequestContext($arguments['_session'], $arguments['_request']);
55+
}
56+
57+
if (ClientGateway::class === $typeName && isset($arguments['_session'])) {
58+
return new ClientGateway($arguments['_session']);
59+
}
60+
61+
return null;
62+
}
63+
}

src/Capability/Registry/ReferenceHandler.php

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313

1414
use Mcp\Exception\InvalidArgumentException;
1515
use Mcp\Exception\RegistryException;
16-
use Mcp\Server\ClientGateway;
17-
use Mcp\Server\RequestContext;
1816
use Mcp\Server\Session\SessionInterface;
1917
use Psr\Container\ContainerInterface;
2018

@@ -106,15 +104,9 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array
106104
// Check if parameter is a special injectable type
107105
$type = $parameter->getType();
108106
if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
109-
$typeName = $type->getName();
110-
111-
if (RequestContext::class === $typeName && isset($arguments['_session'], $arguments['_request'])) {
112-
$finalArgs[$paramPosition] = new RequestContext($arguments['_session'], $arguments['_request']);
113-
continue;
114-
}
115-
116-
if (ClientGateway::class === $typeName && isset($arguments['_session'])) {
117-
$finalArgs[$paramPosition] = new ClientGateway($arguments['_session']);
107+
$injected = InjectableParameters::resolve($type->getName(), $arguments);
108+
if (null !== $injected) {
109+
$finalArgs[$paramPosition] = $injected;
118110
continue;
119111
}
120112
}

tests/Integration/Fixture/sampling.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Mcp\Exception\ClientException;
1717
use Mcp\Schema\Content\TextContent;
1818
use Mcp\Server;
19+
use Mcp\Server\ClientGateway;
1920
use Mcp\Server\RequestContext;
2021
use Mcp\Server\Transport\StdioTransport;
2122

@@ -38,5 +39,20 @@ static function (RequestContext $context, string $text): string {
3839
name: 'summarize',
3940
description: 'Summarizes text by asking the client to sample.',
4041
)
42+
->addTool(
43+
static function (ClientGateway $client, string $text): string {
44+
try {
45+
$result = $client->sample($text, maxTokens: 64);
46+
} catch (ClientException $e) {
47+
return $e->getMessage();
48+
}
49+
50+
assert($result->content instanceof TextContent);
51+
52+
return sprintf('%s said: %s', $result->model, $result->content->text);
53+
},
54+
name: 'summarize_via_gateway',
55+
description: 'Summarizes text through a directly injected gateway.',
56+
)
4157
->build()
4258
->run(new StdioTransport());

tests/Integration/SamplingTest.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,29 @@ public function testPromptReachesTheClient(): void
5656
$this->assertSame(64, $seen[0]->maxTokens);
5757
}
5858

59+
#[TestDox('a gateway parameter is injected, not published in the schema')]
60+
public function testGatewayParameterIsInjectedNotPublished(): void
61+
{
62+
$client = $this->connect('sampling', $this->clientSampling());
63+
64+
$tool = null;
65+
foreach ($client->listTools()->tools as $candidate) {
66+
if ('summarize_via_gateway' === $candidate->name) {
67+
$tool = $candidate;
68+
}
69+
}
70+
71+
$this->assertNotNull($tool);
72+
$this->assertArrayNotHasKey('client', $tool->inputSchema['properties']);
73+
$this->assertArrayHasKey('text', $tool->inputSchema['properties']);
74+
$this->assertSame(['text'], $tool->inputSchema['required']);
75+
76+
$result = $client->callTool('summarize_via_gateway', ['text' => 'a long report']);
77+
78+
$this->assertInstanceOf(TextContent::class, $result->content[0]);
79+
$this->assertSame('test-model said: a long report', $result->content[0]->text);
80+
}
81+
5982
#[TestDox('a client that cannot sample refuses instead of stalling the tool')]
6083
public function testClientWithoutSamplingRefuses(): void
6184
{

tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
use Mcp\Capability\Attribute\McpTool;
1515
use Mcp\Capability\Attribute\Schema;
16+
use Mcp\Server\ClientGateway;
17+
use Mcp\Server\RequestContext;
1618
use Mcp\Tests\Unit\Fixtures\Enum\BackedIntEnum;
1719
use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum;
1820
use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum;
@@ -519,6 +521,13 @@ public function withParameterNamedRequest(string $_request): void
519521
{
520522
}
521523

524+
/**
525+
* @param string $query The search query
526+
*/
527+
public function withInjectableParameters(string $query, ClientGateway $gateway, RequestContext $context, int $limit = 10): void
528+
{
529+
}
530+
522531
// ===== OUTPUT SCHEMA FIXTURES =====
523532
#[McpTool(
524533
outputSchema: [

tests/Unit/Capability/Discovery/SchemaGeneratorTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,17 @@ public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven(): void
413413
$this->assertEquals(['inferredParam'], $schema['required']);
414414
}
415415

416+
public function testExcludesInjectableParameterTypesFromSchema(): void
417+
{
418+
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'withInjectableParameters');
419+
$schema = $this->schemaGenerator->generate($method);
420+
$this->assertArrayNotHasKey('gateway', $schema['properties']);
421+
$this->assertArrayNotHasKey('context', $schema['properties']);
422+
$this->assertEquals(['type' => 'string', 'description' => 'The search query'], $schema['properties']['query']);
423+
$this->assertEquals(['type' => 'integer', 'default' => 10], $schema['properties']['limit']);
424+
$this->assertEquals(['query'], $schema['required']);
425+
}
426+
416427
public static function methodsWithForbiddenParameter(): array
417428
{
418429
return [

tests/Unit/Capability/Registry/ReferenceHandlerTest.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,32 @@ public function read(string $uri, ClientGateway $gateway): mixed
108108
$this->assertInstanceOf(ClientGateway::class, $resourceHandler->receivedGateway);
109109
}
110110

111+
public function testHandleInjectsClientGatewayIntoReflectedHandler(): void
112+
{
113+
$session = $this->createMock(SessionInterface::class);
114+
$session->method('getId')->willReturn(Uuid::v4());
115+
116+
$handler = new class {
117+
public ?ClientGateway $receivedGateway = null;
118+
119+
public function search(string $query, ClientGateway $gateway): string
120+
{
121+
$this->receivedGateway = $gateway;
122+
123+
return 'found: '.$query;
124+
}
125+
};
126+
127+
$result = (new ReferenceHandler())->handle(new ElementReference([$handler, 'search']), [
128+
'_session' => $session,
129+
'_request' => new \stdClass(),
130+
'query' => 'foo',
131+
]);
132+
133+
$this->assertSame('found: foo', $result);
134+
$this->assertInstanceOf(ClientGateway::class, $handler->receivedGateway);
135+
}
136+
111137
public function testHandleStillReflectsOrdinaryClosuresAndDoesNotInjectArgumentBag(): void
112138
{
113139
$session = $this->createMock(SessionInterface::class);

0 commit comments

Comments
 (0)