[Capability] Cast variadic tool parameters element-by-element (#465)

* Cast variadic tool parameters element-by-element

SchemaGenerator::buildVariadicParameterSchema() already advertises a
variadic parameter (e.g. `int ...$scores`) as a JSON "array" schema, so
a spec-conformant client sends an array for it. But
ReferenceHandler::prepareArguments() never special-cased
ReflectionParameter::isVariadic(): it fell through to the regular
single-value branch and passed the whole array to castArgumentType(),
which tried to cast it as one scalar and failed - e.g. "Cannot cast
value to integer" for `int ...$scores` given [1, 2, 3].

Any tool handler declaring a variadic parameter was therefore
unreachable through the advertised schema, confirmed by direct
reproduction against ReferenceHandler::handle() before this change.

Casts each array element individually via the existing
castArgumentType() (unchanged - it already inspects the variadic
parameter's element type, not "array", per PHP reflection semantics)
and appends to the result in order; variadic is always the last
parameter, so this preserves correct positional ordering. A non-array
value for a variadic argument now fails clearly with an invalid-params
error instead of the confusing cast message.

* Address chr-hertel's review: wrap scalars, reject objects, index cast errors

- Non-array variadic argument is now wrapped as a single-element list
  instead of being rejected: MCP prompt arguments are always
  Record<string,string> per protocol, so a variadic prompt parameter
  legitimately receives a plain string, not an array. Rejecting it
  regressed behaviour that worked on main.
- A JSON object (non-list array) is now explicitly rejected instead of
  being silently flattened into positional args by array_values().
- Cast-failure errors now include the parameter name and element index,
  so a failure inside a large array is actually actionable.
- Trimmed the inline rationale comment; the PR description already
  covers it.

Tests updated: replaced the "throws on non-array" test (no longer
correct) with tests for the wrap-as-single-element, reject-object, and
indexed-error-message behaviours.

* Pin argument order for an injectable before a variadic

---------

Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
This commit is contained in:
Sviatoslav 2026-08-29 16:46:40 +03:00 • committed by GitHub
commit 46628fbe6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 119 additions and 1 deletions

View file

@ -97,10 +97,32 @@ final class ReferenceHandler implements ReferenceHandlerInterface
$finalArgs = []; $finalArgs = [];
foreach ($reflection->getParameters() as $parameter) { foreach ($reflection->getParameters() as $parameter) {
// TODO: Handle variadic parameters.
$paramName = $parameter->getName(); $paramName = $parameter->getName();
$paramPosition = $parameter->getPosition(); $paramPosition = $parameter->getPosition();
if ($parameter->isVariadic()) {
// Each element is cast individually below; a non-array value is
// wrapped as a single element, since prompt arguments arrive as
// plain strings (Record<string,string> per the protocol) even
// though tool arguments follow the advertised "array" schema.
$values = $arguments[$paramName] ?? [];
if (!\is_array($values)) {
$values = [$values];
} elseif (!array_is_list($values)) {
throw RegistryException::invalidParams(\sprintf('Parameter `%s` must be a list of values, not an object.', $paramName));
}
foreach ($values as $index => $value) {
try {
$finalArgs[] = $this->castArgumentType($value, $parameter);
} catch (InvalidArgumentException $e) {
throw RegistryException::invalidParams(\sprintf('Parameter `%s[%d]`: %s', $paramName, $index, $e->getMessage()), $e);
} catch (\Throwable $e) {
throw RegistryException::internalError(\sprintf('Error processing parameter `%s[%d]`: %s', $paramName, $index, $e->getMessage()), $e);
}
}
continue;
}
// 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()) {

View file

@ -14,6 +14,7 @@ namespace Mcp\Tests\Unit\Capability\Registry;
use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ElementReference;
use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Capability\Registry\ReferenceHandler;
use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\InvalidArgumentException;
use Mcp\Exception\RegistryException;
use Mcp\Server\ClientGateway; use Mcp\Server\ClientGateway;
use Mcp\Server\Handler\ResourceHandlerInterface; use Mcp\Server\Handler\ResourceHandlerInterface;
use Mcp\Server\Handler\ToolHandlerInterface; use Mcp\Server\Handler\ToolHandlerInterface;
@ -167,4 +168,99 @@ final class ReferenceHandlerTest extends TestCase
(new ReferenceHandler())->handle($reference, ['_session' => $session]); (new ReferenceHandler())->handle($reference, ['_session' => $session]);
} }
public function testHandleCastsEachElementOfAnArrayArgumentForAVariadicParameter(): void
{
// SchemaGenerator advertises variadic parameters as a JSON "array" schema,
// so the array arrives here as a single named argument (not spread across
// multiple keys) and must be cast element-by-element to the variadic's type.
$closure = static fn (string $name, int ...$scores): string => \sprintf('%s:%d', $name, array_sum($scores));
$reference = new ElementReference($closure);
$result = (new ReferenceHandler())->handle($reference, [
'_session' => $this->createMock(SessionInterface::class),
'name' => 'total',
'scores' => ['1', '2', '3'],
]);
$this->assertSame('total:6', $result);
}
public function testHandleTreatsOmittedVariadicArgumentAsZeroElements(): void
{
$closure = static fn (string $name, int ...$scores): int => \count($scores);
$reference = new ElementReference($closure);
$result = (new ReferenceHandler())->handle($reference, [
'_session' => $this->createMock(SessionInterface::class),
'name' => 'empty',
]);
$this->assertSame(0, $result);
}
public function testHandleWrapsANonArrayVariadicArgumentAsASingleElement(): void
{
// Tool arguments follow the advertised "array" schema, but MCP prompt
// arguments are always Record<string,string> per the protocol - a
// client sends a plain string for a variadic prompt parameter, and
// that must still work rather than being rejected.
$closure = static fn (string $name, string ...$topics): array => $topics;
$reference = new ElementReference($closure);
$result = (new ReferenceHandler())->handle($reference, [
'_session' => $this->createMock(SessionInterface::class),
'name' => 'single',
'topics' => 'php',
]);
$this->assertSame(['php'], $result);
}
public function testHandleThrowsRegistryExceptionWhenVariadicArgumentIsAnObjectNotAList(): void
{
$closure = static fn (string $name, int ...$scores): int => \count($scores);
$reference = new ElementReference($closure);
$this->expectException(RegistryException::class);
$this->expectExceptionMessage('Parameter `scores` must be a list of values, not an object.');
(new ReferenceHandler())->handle($reference, [
'_session' => $this->createMock(SessionInterface::class),
'name' => 'bad',
'scores' => ['a' => 1, 'b' => 2],
]);
}
public function testHandleKeepsArgumentOrderWhenAnInjectableParameterPrecedesAVariadic(): void
{
// Injectable and regular parameters are assigned by position while variadic
// elements are appended, so the final order only holds because a variadic is
// always the last parameter.
$closure = static fn (ClientGateway $gateway, string $sep = ',', string ...$parts): string => implode($sep, $parts);
$reference = new ElementReference($closure);
$result = (new ReferenceHandler())->handle($reference, [
'_session' => $this->createMock(SessionInterface::class),
'_request' => new \stdClass(),
'parts' => ['a', 'b'],
]);
$this->assertSame('a,b', $result);
}
public function testHandleIncludesParameterNameAndIndexWhenAVariadicElementFailsToCast(): void
{
$closure = static fn (string $name, int ...$scores): int => \count($scores);
$reference = new ElementReference($closure);
$this->expectException(RegistryException::class);
$this->expectExceptionMessage('Parameter `scores[1]`: Cannot cast value to integer. Expected integer representation.');
(new ReferenceHandler())->handle($reference, [
'_session' => $this->createMock(SessionInterface::class),
'name' => 'bad',
'scores' => ['1', 'not-a-number', '3'],
]);
}
} }