Skip to content

Accept boolean sub-schemas in 2025-11-25 tool schema properties - #3354

Open
pja-ant wants to merge 1 commit into
mainfrom
bool-subschema-2025-11-25
Open

Accept boolean sub-schemas in 2025-11-25 tool schema properties#3354
pja-ant wants to merge 1 commit into
mainfrom
bool-subschema-2025-11-25

Conversation

@pja-ant

@pja-ant pja-ant commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes #3353

The generated 2025-11-25 wire models for Tool.inputSchema / Tool.outputSchema typed every value under properties as an object, so a tool advertising "properties": {"result": true} (a valid JSON Schema 2020-12 boolean sub-schema) failed ListToolsResult validation and the whole listing was discarded on any pre-2026 session. This widens those values to object | boolean.

Motivation and Context

JSON Schema 2020-12 allows true/false anywhere a sub-schema is expected. The 2026-07-28 surface already accepts this (SEP-2106 made inputSchema/outputSchema free-form), so the 2025-11-25 strictness is an artefact of the schema.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?

  • New test_2025_11_25_tool_schema_surfaces_accept_boolean_sub_schemas in tests/types/test_methods.py (fails before, passes after).
  • Drove a real mcp.Client against an in-process low-level Server advertising {"properties": {"result": true}}: mode="legacy" (negotiated 2025-11-25) fails on main with tools.0.outputSchema.properties.result dict_type and succeeds with this change, returning the schema intact; mode="auto" (2026-07-28) passes on both. false, nested items: true, and inputSchema variants also pass; null/string property values are still rejected.
  • scripts/gen_surface_types.py --check, ruff, pyright clean; full suite has no new failures.

Breaking Changes

None — strictly more lenient inbound validation.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I am assigned to the linked issue (or it is labeled help wanted, or I'm a maintainer)
  • I have disclosed any AI assistance and can explain the change in my own words
  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Only the direct values of properties were affected: every other JSON Schema keyword at the schema root is an untyped extra (extra="allow"), and anything nested inside a property object is Any. The same ListToolsResult class 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.

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
@pja-ant
pja-ant marked this pull request as ready for review August 21, 2026 14:03
@pja-ant
pja-ant requested a review from maxisbey August 21, 2026 14:03

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2025-11-25 Tool.inputSchema/outputSchema reject boolean sub-schemas in properties, failing the whole tools/list

1 participant