Accept boolean sub-schemas in 2025-11-25 tool schema properties - #3354
Accept boolean sub-schemas in 2025-11-25 tool schema properties#3354pja-ant wants to merge 1 commit into
Conversation
JSON Schema 2020-12 allows `true`/`false` anywhere a sub-schema is
expected, but the generated 2025-11-25 `InputSchema`/`OutputSchema`
typed `properties` values as `dict[str, Any]`, so a tool advertising
`"properties": {"result": true}` failed `ListToolsResult` validation
and the whole listing was lost on any pre-2026 session. The
2026-07-28 surface already leaves these schemas free-form.
Widen the `additionalProperties` of both `properties` maps to
`anyOf: [object, boolean]` via the generator's existing schema-patch
list and regenerate; the 2026-07-28 module is unchanged.
Fixes #3353
There was a problem hiding this comment.
1 issue found across 3 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp-types/mcp_types/_v2025_11_25/__init__.py">
<violation number="1" location="src/mcp-types/mcp_types/_v2025_11_25/__init__.py:1361">
P2: When a `properties` value is `"false"` or `0`, Pydantic's non-strict `bool` arm coerces it and accepts an invalid JSON Schema sub-schema. Generate this union with a strict boolean type, such as `StrictBool` or a strict boolean literal, in both schemas so only `true` and `false` are admitted.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| ) | ||
| schema_: Annotated[str | None, Field(alias="$schema")] = None | ||
| properties: dict[str, dict[str, Any]] | None = None | ||
| properties: dict[str, dict[str, Any] | bool] | None = None |
There was a problem hiding this comment.
P2: When a properties value is "false" or 0, Pydantic's non-strict bool arm coerces it and accepts an invalid JSON Schema sub-schema. Generate this union with a strict boolean type, such as StrictBool or a strict boolean literal, in both schemas so only true and false are admitted.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp-types/mcp_types/_v2025_11_25/__init__.py, line 1361:
<comment>When a `properties` value is `"false"` or `0`, Pydantic's non-strict `bool` arm coerces it and accepts an invalid JSON Schema sub-schema. Generate this union with a strict boolean type, such as `StrictBool` or a strict boolean literal, in both schemas so only `true` and `false` are admitted.</comment>
<file context>
@@ -1358,7 +1358,7 @@ class InputSchema(WireModel):
)
schema_: Annotated[str | None, Field(alias="$schema")] = None
- properties: dict[str, dict[str, Any]] | None = None
+ properties: dict[str, dict[str, Any] | bool] | None = None
required: list[str] | None = None
type: Literal["object"]
</file context>
There was a problem hiding this comment.
Beyond the inline finding, I also checked the two review points this change hinges on: the SCHEMA_PATCHES old-value tuples match the vendored schema/2025-11-25.json exactly, and patch_schema raises SystemExit on any mismatch, so the patch cannot silently no-op; hand-edit drift in the generated _v2025_11_25/__init__.py is ruled out by CI running gen_surface_types.py --check in .github/workflows/shared.yml.
Extended reasoning...
The diff is small and well-scoped: two generator patches, the matching two-line change in the generated 2025-11-25 wire module, and a round-trip test. I verified statically that the patch paths and old values match the vendored schema at /home/claude/python-sdk/schema/2025-11-25.json (lines 3643-3647 and 3677-3681) and that the patcher asserts old values rather than no-opping, and that CI enforces generated-file sync. The remaining open question is the confirmed inline nit about pydantic smart-union lax coercion of bool-ish scalars ("true", 1) into boolean sub-schemas, which trades a clear validation error for silent rewriting of invalid schemas — a behaviour choice on the 2.x compatibility surface that a maintainer should weigh, so the inline comment stands on its own.
| ) | ||
| schema_: Annotated[str | None, Field(alias="$schema")] = None | ||
| properties: dict[str, dict[str, Any]] | None = None | ||
| properties: dict[str, dict[str, Any] | bool] | None = None |
There was a problem hiding this comment.
🟡 nit: dict[str, Any] | bool under pydantic smart union lax-coerces bool-ish scalars (1, 0, "true", "yes", "on", 1.0) into booleans, silently rewriting invalid property sub-schemas instead of rejecting them (same union at line 1379 in OutputSchema; WireModel is not strict-mode)
Extended reasoning...
On a 2025-11-25 (or earlier legacy) session, a server advertises an invalid tool schema like "properties": {"flag": "true"} or {"flag": 1}. Before this change the ListToolsResult failed validation with a clear dict_type error; after it, the sieve silently coerces the value to boolean true, so the client (or a server forwarding a third-party schema) sees a mutated schema the peer never sent — contradicting the PR's claim that string/scalar property values are still rejected. Fix would be a strict-bool annotation on the bool arm of both unions (via the generator patch).
Verification: nit. The coercion path is real and unguarded. The new field is a plain smart union — properties: dict[str, dict[str, Any] | bool] | None = None at src/mcp-types/mcp_types/_v2025_11_25/init.py:1361 (InputSchema) and :1379 (OutputSchema) — with no strict annotation on the bool member. Nothing in the validation path enables strict mode: `WireModel.model_config = ConfigDict(populate_by_name=True
Fixes #3353
The generated 2025-11-25 wire models for
Tool.inputSchema/Tool.outputSchematyped every value underpropertiesas an object, so a tool advertising"properties": {"result": true}(a valid JSON Schema 2020-12 boolean sub-schema) failedListToolsResultvalidation and the whole listing was discarded on any pre-2026 session. This widens those values toobject | boolean.Motivation and Context
JSON Schema 2020-12 allows
true/falseanywhere a sub-schema is expected. The 2026-07-28 surface already accepts this (SEP-2106 madeinputSchema/outputSchemafree-form), so the 2025-11-25 strictness is an artefact of theschema.ts→ JSON rendering rather than spec text, and it breaks real public servers (see the issue). It bites both directions: an SDK client listing a foreign server's tools, and an SDK server forwarding a third-party schema to a legacy-negotiated client.The fix is two entries in the generator's existing
SCHEMA_PATCHES["2025-11-25"]list plus regeneration. The generated diff is two lines; the 2026-07-28 module is byte-for-byte unchanged.How Has This Been Tested?
test_2025_11_25_tool_schema_surfaces_accept_boolean_sub_schemasintests/types/test_methods.py(fails before, passes after).mcp.Clientagainst an in-process low-levelServeradvertising{"properties": {"result": true}}:mode="legacy"(negotiated 2025-11-25) fails onmainwithtools.0.outputSchema.properties.result dict_typeand succeeds with this change, returning the schema intact;mode="auto"(2026-07-28) passes on both.false, nesteditems: true, andinputSchemavariants also pass;null/string property values are still rejected.scripts/gen_surface_types.py --check,ruff,pyrightclean; full suite has no new failures.Breaking Changes
None — strictly more lenient inbound validation.
Types of changes
Checklist
help wanted, or I'm a maintainer)Additional context
Only the direct values of
propertieswere affected: every other JSON Schema keyword at the schema root is an untyped extra (extra="allow"), and anything nested inside a property object isAny. The sameListToolsResultclass serves 2024-11-05, 2025-03-26, and 2025-06-18 sessions, so those are fixed too. Related: #3337 (same failure shape, different trigger).AI Disclosure
I have used Claude Code for authoring this PR.