Skip to content

Commit 45c1b00

Browse files
authored
feat(mcp): carry union keywords to the provider instead of skipping the tool (#853)
Implements the decision on #848: carry the union, do not skip the tool. **The rule that was there is sound, but it answered a different question.** Refusing `anyOf` protects against *dropping* it — a stored schema without the union permits more than the server does, so a model can produce arguments the server then rejects. Carrying the keyword unchanged does not do that: `ToolSpec::toArray()` puts the stored schema into the provider payload verbatim, and the providers this extension talks to accept a union in a function schema. The narrowing the server declared survives end to end. `anyOf`, `oneOf`, `allOf` and `not` are therefore carried now, and added to the retained top-level keys so a union declared at the root survives the filter too (inside a property it rides along with the property schema, which is copied whole). **What stays refused, and why the line runs exactly there:** - `$ref`, `$dynamicRef`, `$defs`, `definitions` — a reference points into a definition block this filter does not carry, so carrying the pointer alone would hand the provider a dangling reference. Refusing is the honest answer until the definition block is carried too. - `if`/`then`/`else`, `dependentSchemas`, `dependentRequired`, `unevaluatedProperties`, `patternProperties`, `propertyNames` — the draft-2019/2020 applicators have no dependable support across providers. Carrying them would trade an import-time refusal, which names the keyword since #849, for a call-time failure with a worse message. **The case that prompted this.** `https://mcp.deepwiki.com/mcp` advertises three tools; `ask_question` declares `repoName` as `anyOf[string, array<string>]` and was the one skipped, while its two siblings imported. That is now the test: the schema is carried verbatim from the real server's response, and the assertion checks the `anyOf` block arrives unchanged rather than merely that the tool imports. **Tests:** the real-world union imports and keeps its `anyOf`; a top-level union survives the key filter; a `$ref` schema is still refused and its reason names the keyword. Two existing data-provider cases asserted the old behaviour for `allOf` and nested `oneOf` — they are replaced by cases for the applicators that remain refused, so the provider still pins a real boundary rather than shrinking. Gates run locally: cgl, PHPStan level 10, the normaliser unit suite, and the functional `McpImport` suite on sqlite. CI covers the eight-cell matrix. Closes #848. _Assisted by claude-code:claude-fable-5 — [Session](https://claude.ai/code/session_0144iD1P22LotW8rxmxrNGro)_
2 parents 582bc85 + fd1a0bd commit 45c1b00

4 files changed

Lines changed: 89 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
### Changed
1010

11+
- **An MCP tool whose schema uses a union now imports.** `anyOf`, `oneOf`, `allOf` and `not` are carried to the provider verbatim instead of causing the whole tool to be skipped. The rule they were caught by protects against *dropping* a constraint — which would let a model produce arguments the server rejects — and carrying one unchanged does not do that. References (`$ref`, `$defs`) and the draft-2019/2020 applicators (`if`/`then`/`else`, `dependentRequired`, `unevaluatedProperties`, …) stay refused: the first would arrive dangling because the definition block is not carried, the second have no dependable provider support. Concretely, DeepWiki's `ask_question` imports now (#848).
1112
- **An MCP tool that is skipped on import now says why.** The report used to state that a tool had "no usable parameter schema", which is true and unactionable: an operator could not tell a malformed schema from an oversized one from a well-formed one this import deliberately does not carry. It now names the reason — for the common case, the keyword (`anyOf`, `$ref`, …) whose removal would widen what the tool accepts. The rejections themselves are unchanged.
1213

1314
## [0.31.1] - 2026-08-20

Classes/Service/Tool/Mcp/McpSchemaNormalizer.php

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,21 +49,36 @@
4949
* The only top-level keys carried over. Everything else is either an
5050
* annotation a provider ignores (`title`, `$id`, `examples`, vendor
5151
* extensions) or a constraint handled by self::UNSUPPORTED_KEYWORDS.
52+
*
53+
* The applicators are listed so a union declared at the TOP level survives
54+
* the filter; inside a property they ride along with the property schema,
55+
* which is copied whole.
5256
*/
5357
private const RETAINED_KEYS = [
5458
'type',
5559
'description',
5660
'properties',
5761
'required',
5862
'additionalProperties',
63+
'allOf',
64+
'anyOf',
65+
'oneOf',
66+
'not',
5967
];
6068

6169
/**
62-
* Keywords whose removal would widen what the server accepts: references
63-
* into a definition block we do not carry over, and applicators that
64-
* further constrain the arguments. Dropping any of them yields a schema
65-
* that permits calls the server rejects, so their presence at any level
66-
* rejects the whole schema.
70+
* Keywords this import refuses to carry, because carrying them would be a
71+
* lie and dropping them would widen what the tool accepts.
72+
*
73+
* The distinction that decides membership is whether the keyword is
74+
* SELF-CONTAINED. A union (`anyOf`) carries its alternatives inline, so
75+
* handing it to the provider verbatim preserves exactly what the server
76+
* said — those keywords are carried (see self::RETAINED_KEYS). A reference
77+
* points into a definition block this filter does not carry, so it would
78+
* arrive dangling; and the draft-2019/2020 applicators below have no
79+
* dependable support across the providers this extension talks to, so
80+
* carrying them trades an import-time refusal for a call-time failure with
81+
* a worse error message.
6782
*
6883
* A property literally named after one of these keywords is rejected along
6984
* with them: the walk does not distinguish a schema position from a
@@ -74,10 +89,6 @@
7489
'$dynamicRef',
7590
'$defs',
7691
'definitions',
77-
'allOf',
78-
'anyOf',
79-
'oneOf',
80-
'not',
8192
'if',
8293
'then',
8394
'else',

Tests/Unit/Service/Tool/Mcp/Conformance/AbstractMcpConformanceTestCase.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,12 @@ public static function unrepresentableSchemas(): array
238238
'a reference into a definition block we do not carry' => [
239239
['type' => 'object', 'properties' => ['a' => ['$ref' => '#/$defs/a']]],
240240
],
241-
'an applicator that narrows what the server accepts' => [
242-
['type' => 'object', 'properties' => ['a' => ['anyOf' => [['type' => 'string']]]]],
241+
// A union is representable and IS carried now — the guard this case
242+
// exists for is the keyword whose removal would widen the schema,
243+
// and a keyword carried verbatim never gets removed. What stays
244+
// unrepresentable is the applicator no provider dependably reads.
245+
'an applicator no provider dependably reads' => [
246+
['type' => 'object', 'properties' => ['a' => ['dependentRequired' => ['x' => ['y']]]]],
243247
],
244248
'a conditional' => [
245249
['type' => 'object', 'if' => ['required' => ['a']], 'then' => ['required' => ['b']]],

Tests/Unit/Service/Tool/Mcp/McpSchemaNormalizerTest.php

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,6 @@ public function aKeywordWeCannotCarryOverRejectsTheWholeSchema(array $inputSchem
102102
*/
103103
public static function unsupportedKeywordSchemas(): iterable
104104
{
105-
yield 'root allOf' => [[
106-
'type' => 'object',
107-
'properties' => ['q' => ['type' => 'string']],
108-
'allOf' => [['required' => ['q']]],
109-
]];
110-
111105
yield 'reference into a dropped definition block' => [[
112106
'type' => 'object',
113107
'$defs' => ['Id' => ['type' => 'string']],
@@ -119,9 +113,19 @@ public static function unsupportedKeywordSchemas(): iterable
119113
'properties' => ['id' => ['$ref' => 'https://example.invalid/Id']],
120114
]];
121115

122-
yield 'nested oneOf' => [[
116+
// The draft-2019/2020 applicators stay refused: unlike a union they
117+
// have no dependable support across the providers this extension talks
118+
// to, so carrying them would trade an import-time refusal for a
119+
// call-time failure with a worse message.
120+
yield 'nested if/then' => [[
123121
'type' => 'object',
124-
'properties' => ['id' => ['oneOf' => [['type' => 'string'], ['type' => 'integer']]]],
122+
'properties' => ['id' => ['if' => ['type' => 'string'], 'then' => ['minLength' => 1]]],
123+
]];
124+
125+
yield 'root dependentRequired' => [[
126+
'type' => 'object',
127+
'properties' => ['a' => ['type' => 'string'], 'b' => ['type' => 'string']],
128+
'dependentRequired' => ['a' => ['b']],
125129
]];
126130
}
127131

@@ -236,7 +240,7 @@ private function nestedObjectSchema(int $levels): array
236240
}
237241

238242
#[Test]
239-
public function rejectionReasonNamesTheKeywordForARealWorldSchema(): void
243+
public function aRealWorldUnionSchemaIsCarriedThrough(): void
240244
{
241245
// Verbatim from https://mcp.deepwiki.com/mcp (tools/list, 2026-08-20):
242246
// the `ask_question` tool, whose `repoName` accepts either one repo or
@@ -263,11 +267,58 @@ public function rejectionReasonNamesTheKeywordForARealWorldSchema(): void
263267

264268
$subject = new McpSchemaNormalizer();
265269

266-
self::assertNull($subject->normalise($schema), 'the schema is still refused');
270+
$normalised = $subject->normalise($schema);
271+
self::assertIsArray($normalised, 'the tool imports instead of being skipped');
272+
self::assertNull($subject->rejectionReason($schema));
273+
274+
// The union survives verbatim: carrying it is what keeps the stored
275+
// schema as narrow as the server's own, where dropping it would widen.
276+
self::assertIsArray($normalised['properties'] ?? null);
277+
$properties = $normalised['properties'];
278+
self::assertIsArray($properties['repoName'] ?? null);
279+
self::assertSame($schema['properties']['repoName']['anyOf'], $properties['repoName']['anyOf']);
280+
}
281+
282+
#[Test]
283+
public function aTopLevelUnionSurvivesTheKeyFilter(): void
284+
{
285+
// Inside a property the union rides along with the property schema,
286+
// which is copied whole. At the top level it only survives because the
287+
// applicators are retained keys.
288+
$subject = new McpSchemaNormalizer();
289+
290+
$schema = [
291+
'type' => 'object',
292+
'anyOf' => [
293+
['required' => ['a']],
294+
['required' => ['b']],
295+
],
296+
'properties' => ['a' => ['type' => 'string'], 'b' => ['type' => 'string']],
297+
];
298+
299+
$normalised = $subject->normalise($schema);
300+
self::assertIsArray($normalised);
301+
self::assertSame($schema['anyOf'], $normalised['anyOf'] ?? null);
302+
}
303+
304+
#[Test]
305+
public function aReferenceIsStillRefusedBecauseItsTargetIsNotCarried(): void
306+
{
307+
// The distinction that decides the keyword list: a union carries its
308+
// alternatives inline, a $ref points into a $defs block this filter
309+
// drops — carrying it would hand the provider a dangling pointer.
310+
$subject = new McpSchemaNormalizer();
311+
312+
$schema = [
313+
'type' => 'object',
314+
'properties' => ['repoName' => ['$ref' => '#/$defs/repo']],
315+
'$defs' => ['repo' => ['type' => 'string']],
316+
];
267317

318+
self::assertNull($subject->normalise($schema));
268319
$reason = $subject->rejectionReason($schema);
269320
self::assertIsString($reason);
270-
self::assertStringContainsString('anyOf', $reason);
321+
self::assertStringContainsString('$', $reason);
271322
}
272323

273324
#[Test]

0 commit comments

Comments
 (0)