[Server] Keep injectable parameters out of the published inputSchema (#482)
This commit is contained in:
parent
ff0b5fadc8
commit
e173367c5f
8 changed files with 156 additions and 18 deletions
|
|
@ -13,9 +13,9 @@ namespace Mcp\Capability\Discovery;
|
||||||
|
|
||||||
use Mcp\Capability\Attribute\McpTool;
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
use Mcp\Capability\Attribute\Schema;
|
use Mcp\Capability\Attribute\Schema;
|
||||||
|
use Mcp\Capability\Registry\InjectableParameters;
|
||||||
use Mcp\Exception\BadMethodCallException;
|
use Mcp\Exception\BadMethodCallException;
|
||||||
use Mcp\Exception\InvalidArgumentException;
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
use Mcp\Server\RequestContext;
|
|
||||||
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -528,12 +528,10 @@ final class SchemaGenerator implements SchemaGeneratorInterface
|
||||||
foreach ($reflection->getParameters() as $rp) {
|
foreach ($reflection->getParameters() as $rp) {
|
||||||
$reflectionType = $rp->getType();
|
$reflectionType = $rp->getType();
|
||||||
|
|
||||||
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
|
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()
|
||||||
$typeName = $reflectionType->getName();
|
&& InjectableParameters::supports($reflectionType->getName())
|
||||||
|
) {
|
||||||
if (is_a($typeName, RequestContext::class, true)) {
|
continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$paramName = $rp->getName();
|
$paramName = $rp->getName();
|
||||||
|
|
|
||||||
63
src/Capability/Registry/InjectableParameters.php
Normal file
63
src/Capability/Registry/InjectableParameters.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?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;
|
||||||
|
|
||||||
|
use Mcp\Server\ClientGateway;
|
||||||
|
use Mcp\Server\RequestContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for handler parameter types the SDK injects itself.
|
||||||
|
*
|
||||||
|
* Both schema generation (which must exclude these parameters from the
|
||||||
|
* published inputSchema) and argument preparation (which must inject them)
|
||||||
|
* read this list, so the two cannot drift apart.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class InjectableParameters
|
||||||
|
{
|
||||||
|
private const TYPES = [
|
||||||
|
RequestContext::class,
|
||||||
|
ClientGateway::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function supports(string $typeName): bool
|
||||||
|
{
|
||||||
|
foreach (self::TYPES as $type) {
|
||||||
|
if (is_a($typeName, $type, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,8 +13,6 @@ namespace Mcp\Capability\Registry;
|
||||||
|
|
||||||
use Mcp\Exception\InvalidArgumentException;
|
use Mcp\Exception\InvalidArgumentException;
|
||||||
use Mcp\Exception\RegistryException;
|
use Mcp\Exception\RegistryException;
|
||||||
use Mcp\Server\ClientGateway;
|
|
||||||
use Mcp\Server\RequestContext;
|
|
||||||
use Mcp\Server\Session\SessionInterface;
|
use Mcp\Server\Session\SessionInterface;
|
||||||
use Psr\Container\ContainerInterface;
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
|
|
@ -106,15 +104,9 @@ final class ReferenceHandler implements ReferenceHandlerInterface
|
||||||
// Check if parameter is a special injectable type
|
// Check if parameter is a special injectable type
|
||||||
$type = $parameter->getType();
|
$type = $parameter->getType();
|
||||||
if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
|
if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
|
||||||
$typeName = $type->getName();
|
$injected = InjectableParameters::resolve($type->getName(), $arguments);
|
||||||
|
if (null !== $injected) {
|
||||||
if (RequestContext::class === $typeName && isset($arguments['_session'], $arguments['_request'])) {
|
$finalArgs[$paramPosition] = $injected;
|
||||||
$finalArgs[$paramPosition] = new RequestContext($arguments['_session'], $arguments['_request']);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ClientGateway::class === $typeName && isset($arguments['_session'])) {
|
|
||||||
$finalArgs[$paramPosition] = new ClientGateway($arguments['_session']);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
use Mcp\Exception\ClientException;
|
use Mcp\Exception\ClientException;
|
||||||
use Mcp\Schema\Content\TextContent;
|
use Mcp\Schema\Content\TextContent;
|
||||||
use Mcp\Server;
|
use Mcp\Server;
|
||||||
|
use Mcp\Server\ClientGateway;
|
||||||
use Mcp\Server\RequestContext;
|
use Mcp\Server\RequestContext;
|
||||||
use Mcp\Server\Transport\StdioTransport;
|
use Mcp\Server\Transport\StdioTransport;
|
||||||
|
|
||||||
|
|
@ -38,5 +39,20 @@ Server::builder()
|
||||||
name: 'summarize',
|
name: 'summarize',
|
||||||
description: 'Summarizes text by asking the client to sample.',
|
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()
|
->build()
|
||||||
->run(new StdioTransport());
|
->run(new StdioTransport());
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,29 @@ final class SamplingTest extends IntegrationTestCase
|
||||||
$this->assertSame(64, $seen[0]->maxTokens);
|
$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')]
|
#[TestDox('a client that cannot sample refuses instead of stalling the tool')]
|
||||||
public function testClientWithoutSamplingRefuses(): void
|
public function testClientWithoutSamplingRefuses(): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ namespace Mcp\Tests\Unit\Capability\Discovery;
|
||||||
|
|
||||||
use Mcp\Capability\Attribute\McpTool;
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
use Mcp\Capability\Attribute\Schema;
|
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\BackedIntEnum;
|
||||||
use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum;
|
use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum;
|
||||||
use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum;
|
use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum;
|
||||||
|
|
@ -519,6 +521,13 @@ class SchemaGeneratorFixture
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $query The search query
|
||||||
|
*/
|
||||||
|
public function withInjectableParameters(string $query, ClientGateway $gateway, RequestContext $context, int $limit = 10): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
// ===== OUTPUT SCHEMA FIXTURES =====
|
// ===== OUTPUT SCHEMA FIXTURES =====
|
||||||
#[McpTool(
|
#[McpTool(
|
||||||
outputSchema: [
|
outputSchema: [
|
||||||
|
|
|
||||||
|
|
@ -413,6 +413,17 @@ final class SchemaGeneratorTest extends TestCase
|
||||||
$this->assertEquals(['inferredParam'], $schema['required']);
|
$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
|
public static function methodsWithForbiddenParameter(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,32 @@ final class ReferenceHandlerTest extends TestCase
|
||||||
$this->assertInstanceOf(ClientGateway::class, $resourceHandler->receivedGateway);
|
$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
|
public function testHandleStillReflectsOrdinaryClosuresAndDoesNotInjectArgumentBag(): void
|
||||||
{
|
{
|
||||||
$session = $this->createMock(SessionInterface::class);
|
$session = $this->createMock(SessionInterface::class);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue