Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions 8f75e2e310 chore: bump version to v0.4.17
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 22s
2026-05-27 08:53:52 +00:00
Matthieu 75fd737a4c fix(mcp) : décoder les arguments tableaux/objets sérialisés en string JSON
Auto Tag Develop / tag (push) Successful in 9s
Complément du fix scalaire : certains proxies MCP sérialisent aussi les
arguments tableaux/objets en string JSON (ex: tagIds arrive en "[3]" au
lieu de [3]). Le schéma array les rejetait en 422, et castToArray du SDK
ne décode pas les strings JSON.

CoerceJsonEncodedArgumentsListener écoute le RequestEvent du SDK (dispatché
avant tout handler) et, piloté par le schéma du tool, décode les arguments
string dont le type cible est array/object. Les params string ne sont
jamais touchés (sûr pour les titres/descriptions ressemblant à du JSON).

Corrige le 422 'Expected array|null, but received string' sur tagIds /
collaboratorIds lors des appels depuis Claude.
2026-05-27 10:53:42 +02:00
gitea-actions 77e1017d09 chore: bump version to v0.4.16
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-27 08:36:22 +00:00
Matthieu c528067c79 fix(mcp) : accepter les arguments scalaires stringifiés (coercition string->int/bool)
Auto Tag Develop / tag (push) Successful in 11s
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.
2026-05-27 10:36:06 +02:00
7 changed files with 398 additions and 1 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.15' app.version: '0.4.17'
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\DependencyInjection\Compiler;
use App\Mcp\Schema\CoercingSchemaGenerator;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
/**
* Wires the CoercingSchemaGenerator into the MCP server builder so that
* generated tool input schemas accept stringified scalar arguments.
*/
final class McpSchemaGeneratorPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('mcp.server.builder')) {
return;
}
$container->getDefinition('mcp.server.builder')
->addMethodCall('setSchemaGenerator', [new Reference(CoercingSchemaGenerator::class)])
;
}
}
+7
View File
@@ -4,10 +4,17 @@ declare(strict_types=1);
namespace App; namespace App;
use App\DependencyInjection\Compiler\McpSchemaGeneratorPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel; use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel class Kernel extends BaseKernel
{ {
use MicroKernelTrait; use MicroKernelTrait;
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(new McpSchemaGeneratorPass());
}
} }
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Mcp\EventListener;
use App\Mcp\Schema\CoercingSchemaGenerator;
use Mcp\Capability\RegistryInterface;
use Mcp\Event\RequestEvent;
use Mcp\Schema\Request\CallToolRequest;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Throwable;
use function is_array;
use function is_string;
/**
* Decodes JSON-encoded structured arguments before tool calls are validated.
*
* Some MCP clients/proxies serialize array and object arguments as JSON strings
* (e.g. `tagIds` arrives as the string `"[3]"` instead of the array `[3]`). The
* SDK validates arguments against the JSON Schema BEFORE casting, so an `array`
* schema rejects the string with a 422, and ReferenceHandler::castToArray does
* not decode JSON strings either.
*
* This listener runs on the SDK RequestEvent (dispatched before any handler) and,
* driven by the tool's input schema, decodes string arguments whose target type
* is `array` or `object`. Scalar stringification is handled separately by
* {@see CoercingSchemaGenerator}.
*/
#[AsEventListener(event: RequestEvent::class)]
final class CoerceJsonEncodedArgumentsListener
{
public function __construct(
#[Autowire(service: 'mcp.registry')]
private readonly RegistryInterface $registry,
) {}
public function __invoke(RequestEvent $event): void
{
$request = $event->getRequest();
if (!$request instanceof CallToolRequest) {
return;
}
$arguments = $request->arguments;
if ([] === $arguments) {
return;
}
$properties = $this->toolProperties($request->name);
if (null === $properties) {
return;
}
$changed = false;
foreach ($arguments as $name => $value) {
if (!is_string($value) || !is_array($properties[$name] ?? null)) {
continue;
}
$types = (array) ($properties[$name]['type'] ?? []);
if ([] === array_intersect(['array', 'object'], $types)) {
continue;
}
$decoded = json_decode($value, true);
if (is_array($decoded)) {
$arguments[$name] = $decoded;
$changed = true;
}
}
if ($changed) {
$event->setRequest(
new CallToolRequest($request->name, $arguments)
->withId($request->getId())
->withMeta($request->getMeta()),
);
}
}
/**
* @return null|array<string, mixed>
*/
private function toolProperties(string $toolName): ?array
{
try {
$schema = $this->registry->getTool($toolName)->tool->inputSchema;
} catch (Throwable) {
return null;
}
$properties = $schema['properties'] ?? null;
return is_array($properties) ? $properties : null;
}
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Schema;
use Mcp\Capability\Discovery\DocBlockParser;
use Mcp\Capability\Discovery\SchemaGenerator;
use Mcp\Capability\Discovery\SchemaGeneratorInterface;
use Reflector;
use function count;
use function in_array;
use function is_array;
/**
* Wraps the SDK SchemaGenerator and relaxes scalar parameter schemas so that
* numeric/boolean parameters also accept their string representation.
*
* Rationale: some MCP clients serialize every JSON-RPC argument as a string
* (e.g. `"22"` instead of `22`). The SDK validates arguments against the
* generated JSON Schema BEFORE casting them (see CallToolHandler), so a strict
* `integer` schema rejects `"22"` with a 422 even though the SDK's
* ReferenceHandler::castArgumentType would happily coerce it afterwards.
*
* By advertising `["integer", "string"]` (resp. number/boolean) we let opis
* accept the stringified value; the reflected PHP type hint (`int`, `bool`, ...)
* still drives the actual coercion in ReferenceHandler. Non-numeric strings are
* rejected later with a clear "cannot cast" error.
*/
final class CoercingSchemaGenerator implements SchemaGeneratorInterface
{
public function __construct(
private readonly SchemaGeneratorInterface $inner = new SchemaGenerator(new DocBlockParser()),
) {}
public function generate(Reflector $reflection): array
{
$schema = $this->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<string, mixed> $node
*
* @return array<string, mixed>
*/
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);
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Mcp;
use App\Mcp\EventListener\CoerceJsonEncodedArgumentsListener;
use Mcp\Capability\Registry\ToolReference;
use Mcp\Capability\RegistryInterface;
use Mcp\Event\RequestEvent;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Tool;
use Mcp\Server\Session\SessionInterface;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
class CoerceJsonEncodedArgumentsListenerTest extends TestCase
{
private const SCHEMA = [
'type' => 'object',
'properties' => [
'id' => ['type' => ['integer', 'string']],
'title' => ['type' => 'string'],
'tagIds' => ['type' => ['array', 'null'], 'items' => ['type' => ['integer', 'string']]],
'collaboratorIds' => ['type' => ['array', 'null'], 'items' => ['type' => ['integer', 'string']]],
],
];
public function testDecodesJsonStringArrayForArrayTypedParam(): void
{
$result = $this->handle(['tagIds' => '[3]', 'collaboratorIds' => '[5,6]']);
self::assertSame([3], $result->arguments['tagIds']);
self::assertSame([5, 6], $result->arguments['collaboratorIds']);
}
public function testLeavesRealArrayUntouched(): void
{
$result = $this->handle(['tagIds' => [3]]);
self::assertSame([3], $result->arguments['tagIds']);
}
public function testDoesNotTouchStringTypedParamEvenIfItLooksLikeJson(): void
{
$result = $this->handle(['title' => '[1,2]']);
// title is schema-typed string -> must stay the literal string.
self::assertSame('[1,2]', $result->arguments['title']);
}
public function testLeavesScalarTypedParamUntouched(): void
{
// id is integer/string typed -> not an array/object, handled by the schema
// relaxation + SDK cast, not by this listener.
$result = $this->handle(['id' => '463']);
self::assertSame('463', $result->arguments['id']);
}
public function testPreservesRequestId(): void
{
$result = $this->handle(['tagIds' => '[3]']);
self::assertSame(1, $result->getId());
}
/**
* @param array<string, mixed> $arguments
*/
private function handle(array $arguments): CallToolRequest
{
$tool = new Tool('update-task', self::SCHEMA, null, null);
$reference = new ToolReference($tool, static fn () => null);
$registry = $this->createMock(RegistryInterface::class);
$registry->method('getTool')->willReturn($reference);
$request = new CallToolRequest('update-task', $arguments)->withId(1);
$event = new RequestEvent($request, $this->createMock(SessionInterface::class));
(new CoerceJsonEncodedArgumentsListener($registry))($event);
$result = $event->getRequest();
self::assertInstanceOf(CallToolRequest::class, $result);
return $result;
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Mcp;
use App\Mcp\Schema\CoercingSchemaGenerator;
use App\Mcp\Tool\Task\CreateTaskTool;
use App\Mcp\Tool\Task\ListTasksTool;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
/**
* @internal
*/
class CoercingSchemaGeneratorTest extends TestCase
{
private CoercingSchemaGenerator $generator;
protected function setUp(): void
{
$this->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']);
}
}