From c528067c79ac0113f4a8aa272ccdeed43df50a9a Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 27 May 2026 10:35:50 +0200 Subject: [PATCH] =?UTF-8?q?fix(mcp)=20:=20accepter=20les=20arguments=20sca?= =?UTF-8?q?laires=20stringifi=C3=A9s=20(coercition=20string->int/bool)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Certains clients MCP sérialisent tous les arguments JSON-RPC en string (ex: "22" au lieu de 22). Le SDK valide les arguments contre le schéma JSON AVANT de les caster (CallToolHandler), donc un schéma integer strict rejetait "22" en 422 alors que ReferenceHandler::castArgumentType sait le coercer ensuite. CoercingSchemaGenerator enveloppe le SchemaGenerator du SDK et ajoute "string" aux types scalaires integer/number/boolean (et aux items de tableaux), de sorte que opis accepte la valeur stringifiée ; le type PHP réel du paramètre pilote toujours la coercition. Branché sur le builder MCP via McpSchemaGeneratorPass (enregistrée dans Kernel::build). Corrige le rejet 422 sur groupId/effortId/priorityId/statusId/etc. lors de l'appel des tools depuis Claude. --- .../Compiler/McpSchemaGeneratorPass.php | 28 ++++++ src/Kernel.php | 7 ++ src/Mcp/Schema/CoercingSchemaGenerator.php | 97 +++++++++++++++++++ .../Unit/Mcp/CoercingSchemaGeneratorTest.php | 75 ++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 src/DependencyInjection/Compiler/McpSchemaGeneratorPass.php create mode 100644 src/Mcp/Schema/CoercingSchemaGenerator.php create mode 100644 tests/Unit/Mcp/CoercingSchemaGeneratorTest.php diff --git a/src/DependencyInjection/Compiler/McpSchemaGeneratorPass.php b/src/DependencyInjection/Compiler/McpSchemaGeneratorPass.php new file mode 100644 index 0000000..85f3532 --- /dev/null +++ b/src/DependencyInjection/Compiler/McpSchemaGeneratorPass.php @@ -0,0 +1,28 @@ +hasDefinition('mcp.server.builder')) { + return; + } + + $container->getDefinition('mcp.server.builder') + ->addMethodCall('setSchemaGenerator', [new Reference(CoercingSchemaGenerator::class)]) + ; + } +} diff --git a/src/Kernel.php b/src/Kernel.php index ad0fb48..9c8f34f 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -4,10 +4,17 @@ declare(strict_types=1); namespace App; +use App\DependencyInjection\Compiler\McpSchemaGeneratorPass; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel as BaseKernel; class Kernel extends BaseKernel { use MicroKernelTrait; + + protected function build(ContainerBuilder $container): void + { + $container->addCompilerPass(new McpSchemaGeneratorPass()); + } } diff --git a/src/Mcp/Schema/CoercingSchemaGenerator.php b/src/Mcp/Schema/CoercingSchemaGenerator.php new file mode 100644 index 0000000..dee913b --- /dev/null +++ b/src/Mcp/Schema/CoercingSchemaGenerator.php @@ -0,0 +1,97 @@ +inner->generate($reflection); + + if (isset($schema['properties']) && is_array($schema['properties'])) { + foreach ($schema['properties'] as $name => $property) { + if (is_array($property)) { + $schema['properties'][$name] = $this->relaxNode($property); + } + } + } + + return $schema; + } + + public function generateOutputSchema(Reflector $reflection): ?array + { + return $this->inner->generateOutputSchema($reflection); + } + + /** + * @param array $node + * + * @return array + */ + private function relaxNode(array $node): array + { + if (isset($node['type'])) { + $node['type'] = $this->relaxType($node['type']); + } + + // Relax array element types too (stringified IDs inside tagIds, etc.). + if (isset($node['items']) && is_array($node['items'])) { + $node['items'] = $this->relaxNode($node['items']); + } + + return $node; + } + + /** + * Adds "string" to a type definition that allows integer/number/boolean. + * + * @param string|string[] $type + * + * @return string|string[] + */ + private function relaxType(array|string $type): array|string + { + $types = (array) $type; + + $isNumericOrBool = in_array('integer', $types, true) + || in_array('number', $types, true) + || in_array('boolean', $types, true); + + if ($isNumericOrBool && !in_array('string', $types, true)) { + $types[] = 'string'; + } + + return 1 === count($types) ? $types[0] : array_values($types); + } +} diff --git a/tests/Unit/Mcp/CoercingSchemaGeneratorTest.php b/tests/Unit/Mcp/CoercingSchemaGeneratorTest.php new file mode 100644 index 0000000..d878905 --- /dev/null +++ b/tests/Unit/Mcp/CoercingSchemaGeneratorTest.php @@ -0,0 +1,75 @@ +generator = new CoercingSchemaGenerator(); + } + + public function testNullableIntegerScalarAlsoAcceptsString(): void + { + $schema = $this->generator->generate(new ReflectionMethod(ListTasksTool::class, '__invoke')); + + // ?int $projectId -> ["null","integer"] relaxed with "string". + self::assertSame(['null', 'integer', 'string'], $schema['properties']['projectId']['type']); + } + + public function testRequiredIntegerScalarAlsoAcceptsString(): void + { + $schema = $this->generator->generate(new ReflectionMethod(ListTasksTool::class, '__invoke')); + + // int $limit = 100 -> "integer" relaxed to ["integer","string"]. + self::assertSame(['integer', 'string'], $schema['properties']['limit']['type']); + } + + public function testBooleanScalarAlsoAcceptsString(): void + { + $schema = $this->generator->generate(new ReflectionMethod(CreateTaskTool::class, '__invoke')); + + // ?bool $syncToCalendar -> ["boolean","null"] relaxed with "string". + $type = $schema['properties']['syncToCalendar']['type']; + self::assertContains('boolean', $type); + self::assertContains('string', $type); + self::assertContains('null', $type); + } + + public function testArrayItemTypeAlsoAcceptsString(): void + { + $schema = $this->generator->generate(new ReflectionMethod(CreateTaskTool::class, '__invoke')); + + // int[] $tagIds -> items {type: integer} relaxed to {type: [integer, string]}. + self::assertSame(['integer', 'string'], $schema['properties']['tagIds']['items']['type']); + } + + public function testStringScalarIsLeftUntouched(): void + { + $schema = $this->generator->generate(new ReflectionMethod(CreateTaskTool::class, '__invoke')); + + // string $title stays a plain string (no spurious relaxation). + self::assertSame('string', $schema['properties']['title']['type']); + } + + public function testArrayContainerTypeIsNotRelaxed(): void + { + $schema = $this->generator->generate(new ReflectionMethod(CreateTaskTool::class, '__invoke')); + + // The array container itself must not gain "string". + self::assertSame(['array', 'null'], $schema['properties']['tagIds']['type']); + } +}