Skip to content

Commit c2909a1

Browse files
soyukaclaude
andauthored
fix(mcp): fallback to sdk handler when not found (#7818)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 94f3c7f commit c2909a1

File tree

31 files changed

+827
-94
lines changed

31 files changed

+827
-94
lines changed

src/Laravel/ApiPlatformProvider.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
use ApiPlatform\Laravel\State\SwaggerUiProvider;
109109
use ApiPlatform\Laravel\State\ValidateProvider;
110110
use ApiPlatform\Mcp\Capability\Registry\Loader as McpLoader;
111+
use ApiPlatform\Mcp\JsonSchema\SchemaFactory as McpSchemaFactory;
111112
use ApiPlatform\Mcp\Metadata\Operation\Factory\OperationMetadataFactory as McpOperationMetadataFactory;
112113
use ApiPlatform\Mcp\Routing\IriConverter as McpIriConverter;
113114
use ApiPlatform\Mcp\Server\Handler;
@@ -1085,11 +1086,17 @@ private function registerMcp(): void
10851086
);
10861087
});
10871088

1089+
$this->app->singleton(McpSchemaFactory::class, static function (Application $app) {
1090+
return new McpSchemaFactory(
1091+
$app->make(SchemaFactory::class)
1092+
);
1093+
});
1094+
10881095
$this->app->singleton(McpLoader::class, static function (Application $app) {
10891096
return new McpLoader(
10901097
$app->make(ResourceNameCollectionFactoryInterface::class),
10911098
$app->make(ResourceMetadataCollectionFactoryInterface::class),
1092-
$app->make(SchemaFactoryInterface::class)
1099+
$app->make(McpSchemaFactory::class)
10931100
);
10941101
});
10951102
$this->app->tag(McpLoader::class, 'mcp.loader');

src/Mcp/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/composer.lock
2+
/vendor
3+
/.phpunit.cache

src/Mcp/Capability/Registry/Loader.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,22 +50,25 @@ public function load(RegistryInterface $registry): void
5050
foreach ($resource->getMcp() ?? [] as $mcp) {
5151
if ($mcp instanceof McpTool) {
5252
$inputClass = $mcp->getInput()['class'] ?? $mcp->getClass();
53-
$inputFormat = array_first($mcp->getInputFormats() ?? ['json']);
53+
$inputFormat = array_key_first($mcp->getInputFormats() ?? ['json' => ['application/json']]);
5454
$inputSchema = $this->schemaFactory->buildSchema($inputClass, $inputFormat, Schema::TYPE_INPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]);
5555

56-
$outputClass = $mcp->getOutput()['class'] ?? $mcp->getClass();
57-
$outputFormat = array_first($mcp->getOutputFormats() ?? ['jsonld']);
58-
$outputSchema = $this->schemaFactory->buildSchema($outputClass, $outputFormat, Schema::TYPE_OUTPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]);
56+
$outputSchema = null;
57+
if (false !== $mcp->getStructuredContent()) {
58+
$outputClass = $mcp->getOutput()['class'] ?? $mcp->getClass();
59+
$outputFormat = array_key_first($mcp->getOutputFormats() ?? ['json' => ['application/json']]);
60+
$outputSchema = $this->schemaFactory->buildSchema($outputClass, $outputFormat, Schema::TYPE_OUTPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true])->getArrayCopy();
61+
}
5962

6063
$registry->registerTool(
6164
new Tool(
6265
name: $mcp->getName(),
63-
inputSchema: $inputSchema->getDefinitions()[$inputSchema->getRootDefinitionKey()]->getArrayCopy(),
66+
inputSchema: $inputSchema->getArrayCopy(),
6467
description: $mcp->getDescription(),
6568
annotations: $mcp->getAnnotations() ? ToolAnnotations::fromArray($mcp->getAnnotations()) : null,
6669
icons: $mcp->getIcons(),
6770
meta: $mcp->getMeta(),
68-
outputSchema: $outputSchema->getArrayCopy(),
71+
outputSchema: $outputSchema,
6972
),
7073
self::HANDLER,
7174
true,
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Mcp\JsonSchema;
15+
16+
use ApiPlatform\JsonSchema\Schema;
17+
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
18+
use ApiPlatform\Metadata\Operation;
19+
20+
/**
21+
* Wraps a SchemaFactoryInterface and flattens the resulting schema
22+
* into a MCP-compliant structure: no $ref, no allOf, no definitions.
23+
*
24+
* @experimental
25+
*/
26+
final class SchemaFactory implements SchemaFactoryInterface
27+
{
28+
public function __construct(
29+
private readonly SchemaFactoryInterface $decorated,
30+
) {
31+
}
32+
33+
public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
34+
{
35+
$schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
36+
37+
$definitions = [];
38+
foreach ($schema->getDefinitions() as $key => $definition) {
39+
$definitions[$key] = $definition instanceof \ArrayObject ? $definition->getArrayCopy() : (array) $definition;
40+
}
41+
42+
$rootKey = $schema->getRootDefinitionKey();
43+
if (null !== $rootKey) {
44+
$root = $definitions[$rootKey] ?? [];
45+
} else {
46+
// Collection schemas (and others) put allOf/type directly on the root
47+
$root = $schema->getArrayCopy(false);
48+
}
49+
50+
$flat = self::resolveNode($root, $definitions);
51+
52+
$flatSchema = new Schema(Schema::VERSION_JSON_SCHEMA);
53+
unset($flatSchema['$schema']);
54+
foreach ($flat as $key => $value) {
55+
$flatSchema[$key] = $value;
56+
}
57+
58+
return $flatSchema;
59+
}
60+
61+
/**
62+
* Recursively resolve $ref, allOf, and nested structures into a flat schema node.
63+
*
64+
* @param array $resolving Tracks the current $ref resolution chain to detect circular references
65+
*/
66+
public static function resolveNode(array|\ArrayObject $node, array $definitions, array &$resolving = []): array
67+
{
68+
if ($node instanceof \ArrayObject) {
69+
$node = $node->getArrayCopy();
70+
}
71+
72+
if (isset($node['$ref'])) {
73+
$refKey = str_replace('#/definitions/', '', $node['$ref']);
74+
if (!isset($definitions[$refKey]) || isset($resolving[$refKey])) {
75+
return ['type' => 'object'];
76+
}
77+
$resolving[$refKey] = true;
78+
$resolved = self::resolveNode($definitions[$refKey], $definitions, $resolving);
79+
unset($resolving[$refKey]);
80+
81+
return $resolved;
82+
}
83+
84+
if (isset($node['allOf'])) {
85+
$merged = ['type' => 'object', 'properties' => []];
86+
$requiredSets = [];
87+
foreach ($node['allOf'] as $entry) {
88+
$resolved = self::resolveNode($entry, $definitions, $resolving);
89+
if (isset($resolved['properties'])) {
90+
foreach ($resolved['properties'] as $k => $v) {
91+
$merged['properties'][$k] = $v;
92+
}
93+
}
94+
if (isset($resolved['required'])) {
95+
$requiredSets[] = $resolved['required'];
96+
}
97+
}
98+
99+
if ($requiredSets) {
100+
$merged['required'] = array_merge(...$requiredSets);
101+
}
102+
if ([] === $merged['properties']) {
103+
unset($merged['properties']);
104+
}
105+
if (isset($node['description'])) {
106+
$merged['description'] = $node['description'];
107+
}
108+
109+
return self::resolveDeep($merged, $definitions, $resolving);
110+
}
111+
112+
if (!isset($node['type'])) {
113+
$node['type'] = 'object';
114+
}
115+
116+
return self::resolveDeep($node, $definitions, $resolving);
117+
}
118+
119+
/**
120+
* Recursively resolve nested properties and array items.
121+
*/
122+
private static function resolveDeep(array $node, array $definitions, array &$resolving): array
123+
{
124+
if (isset($node['items'])) {
125+
$node['items'] = self::resolveNode(
126+
$node['items'] instanceof \ArrayObject ? $node['items']->getArrayCopy() : $node['items'],
127+
$definitions,
128+
$resolving,
129+
);
130+
}
131+
132+
if (isset($node['properties']) && \is_array($node['properties'])) {
133+
foreach ($node['properties'] as $propName => $propSchema) {
134+
$node['properties'][$propName] = self::resolveNode(
135+
$propSchema instanceof \ArrayObject ? $propSchema->getArrayCopy() : $propSchema,
136+
$definitions,
137+
$resolving,
138+
);
139+
}
140+
}
141+
142+
return $node;
143+
}
144+
}

src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php

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

1414
namespace ApiPlatform\Mcp\Metadata\Operation\Factory;
1515

16-
use ApiPlatform\Metadata\Exception\RuntimeException;
1716
use ApiPlatform\Metadata\HttpOperation;
1817
use ApiPlatform\Metadata\McpResource;
1918
use ApiPlatform\Metadata\McpTool;
@@ -32,10 +31,7 @@ public function __construct(
3231
) {
3332
}
3433

35-
/**
36-
* @throws RuntimeException
37-
*/
38-
public function create(string $operationName, array $context = []): HttpOperation
34+
public function create(string $operationName, array $context = []): ?HttpOperation
3935
{
4036
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
4137
foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resource) {
@@ -55,6 +51,6 @@ public function create(string $operationName, array $context = []): HttpOperatio
5551
}
5652
}
5753

58-
throw new RuntimeException(\sprintf('MCP operation "%s" not found.', $operationName));
54+
return null;
5955
}
6056
}

src/Mcp/Server/Handler.php

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,15 @@ public function __construct(
4949

5050
public function supports(Request $request): bool
5151
{
52-
return $request instanceof CallToolRequest || $request instanceof ReadResourceRequest;
52+
if ($request instanceof CallToolRequest) {
53+
return null !== $this->operationMetadataFactory->create($request->name);
54+
}
55+
56+
if ($request instanceof ReadResourceRequest) {
57+
return null !== $this->operationMetadataFactory->create($request->uri);
58+
}
59+
60+
return false;
5361
}
5462

5563
/**
@@ -70,9 +78,13 @@ public function handle(Request $request, SessionInterface $session): Response|Er
7078
$this->logger->debug('Executing tool', ['name' => $operationNameOrUri, 'arguments' => $arguments]);
7179
}
7280

73-
/** @var HttpOperation $operation */
81+
/** @var HttpOperation|null $operation */
7482
$operation = $this->operationMetadataFactory->create($operationNameOrUri);
7583

84+
if (null === $operation) {
85+
return Error::forMethodNotFound(\sprintf('MCP operation "%s" not found.', $operationNameOrUri), $request->getId());
86+
}
87+
7688
$uriVariables = [];
7789
if (!$isResource) {
7890
foreach ($operation->getUriVariables() ?? [] as $key => $link) {
@@ -83,7 +95,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er
8395
}
8496

8597
$context = [
86-
'request' => ($httpRequest = $this->requestStack->getCurrentRequest()),
98+
'request' => $this->requestStack->getCurrentRequest(),
8799
'mcp_request' => $request,
88100
'uri_variables' => $uriVariables,
89101
'resource_class' => $operation->getClass(),
@@ -93,6 +105,15 @@ public function handle(Request $request, SessionInterface $session): Response|Er
93105
$context['mcp_data'] = $arguments;
94106
}
95107

108+
$operation = $operation->withExtraProperties(
109+
array_merge($operation->getExtraProperties(), ['_api_disable_swagger_provider' => true])
110+
);
111+
112+
// MCP has its own transport (JSON-RPC) — HTTP content negotiation is irrelevant.
113+
if (null === $operation->canNegotiateContent()) {
114+
$operation = $operation->withContentNegotiation(false);
115+
}
116+
96117
if (null === $operation->canValidate()) {
97118
$operation = $operation->withValidate(false);
98119
}
@@ -111,7 +132,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er
111132

112133
$body = $this->provider->provide($operation, $uriVariables, $context);
113134

114-
if (!$isResource) {
135+
if (!$isResource && null !== ($httpRequest = $context['request'] ?? null)) {
115136
$context['previous_data'] = $httpRequest->attributes->get('previous_data');
116137
$context['data'] = $httpRequest->attributes->get('data');
117138
$context['read_data'] = $httpRequest->attributes->get('read_data');

src/Mcp/State/StructuredContentProcessor.php

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,7 @@ public function __construct(
4040

4141
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
4242
{
43-
if (
44-
!$this->serializer instanceof NormalizerInterface
45-
|| !$this->serializer instanceof EncoderInterface
46-
|| !isset($context['mcp_request'])
47-
|| !($request = $context['request'])
48-
) {
43+
if (!isset($context['mcp_request'])) {
4944
return $this->decorated->process($data, $operation, $uriVariables, $context);
5045
}
5146

@@ -55,12 +50,13 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
5550
return new Response($context['mcp_request']->getId(), $result);
5651
}
5752

53+
$request = $context['request'] ?? null;
5854
$context['original_data'] = $result;
5955
$class = $operation->getClass();
6056
$includeStructuredContent = $operation instanceof McpTool || $operation instanceof McpResource ? $operation->getStructuredContent() ?? true : false;
6157
$structuredContent = null;
6258

63-
if ($includeStructuredContent) {
59+
if ($includeStructuredContent && $request && $this->serializer instanceof NormalizerInterface && $this->serializer instanceof EncoderInterface) {
6460
$serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [
6561
'resource_class' => $class,
6662
'operation' => $operation,

0 commit comments

Comments
 (0)