Skip to content

fix(openapi): preserve anyOf for non-null union body params instead of injecting an arbitrary type - #345

Open
K4bain wants to merge 3 commits into
tadata-org:mainfrom
K4bain:fix/issue-307-union-types
Open

fix(openapi): preserve anyOf for non-null union body params instead of injecting an arbitrary type#345
K4bain wants to merge 3 commits into
tadata-org:mainfrom
K4bain:fix/issue-307-union-types

Conversation

@K4bain

@K4bain K4bain commented Sep 3, 2026

Copy link
Copy Markdown

fix(openapi): preserve anyOf for non-null union body params instead of injecting an arbitrary type

Problem

For a request body field typed as a union of two real types (e.g. dict[str, list[str]] | list[str]), the projected MCP tool schema gets a single arbitrary "type" injected alongside anyOf. The MCP server's jsonschema validator then enforces that "type" strictly and rejects every call using the variant that lost the pick, with a misleading error like:

Input validation error: {'Skills': [...]} is not of type 'array'

The injected type is also non-deterministic (next(iter(set))), so which variant breaks can differ per run. Fixes #307.

Root cause

In convert_openapi_to_mcp_tools, both the query-param and body-param loops do:

if "type" not in properties[param_name]:
    properties[param_name]["type"] = get_single_param_type_from_schema(param_schema)

For T | U (both real types) Pydantic emits {"anyOf": [...]} with no top-level "type", so the branch fires and one arbitrary variant type wins.

Fix

Only fall back to get_single_param_type_from_schema when the union has at most one non-null variant. T | None (nullable) keeps the existing collapse behavior — its existing tests still pass unchanged — while T | U keeps anyOf verbatim, which the JSON Schema validator at the MCP layer handles correctly.

Verification

  • New test test_union_type_schema_not_collapsed asserts anyOf is preserved with no injected "type" and both object/array variants present.
  • Repro from the issue validated with jsonschema: both the dict variant and the list variant pass validation (previously one of them always failed).
  • Full suite: 70 passed (the 20 test_sse_real_transport errors are the pre-existing Windows os.fork limitation tracked in test: skip real-transport tests on Windows (they require os.fork) #332).

…f injecting an arbitrary type

## Problem

For a request body field typed as a union of two real types (e.g. `dict[str, list[str]] | list[str]`), the projected MCP tool schema gets a single arbitrary `"type"` injected alongside `anyOf`. The MCP server's jsonschema validator then enforces that `"type"` strictly and rejects every call using the variant that lost the pick, with a misleading error like:

```
Input validation error: {'Skills': [...]} is not of type 'array'
```

The injected type is also non-deterministic (`next(iter(set))`), so which variant breaks can differ per run. Fixes tadata-org#307.

## Root cause

In `convert_openapi_to_mcp_tools`, both the query-param and body-param loops do:

```python
if "type" not in properties[param_name]:
    properties[param_name]["type"] = get_single_param_type_from_schema(param_schema)
```

For `T | U` (both real types) Pydantic emits `{"anyOf": [...]}` with no top-level `"type"`, so the branch fires and one arbitrary variant type wins.

## Fix

Only fall back to `get_single_param_type_from_schema` when the union has at most one non-null variant. `T | None` (nullable) keeps the existing collapse behavior — its existing tests still pass unchanged — while `T | U` keeps `anyOf` verbatim, which the JSON Schema validator at the MCP layer handles correctly.

## Verification

- New test `test_union_type_schema_not_collapsed` asserts `anyOf` is preserved with no injected `"type"` and both `object`/`array` variants present.
- Repro from the issue validated with `jsonschema`: both the dict variant and the list variant pass validation (previously one of them always failed).
- Full suite: 70 passed (the 20 `test_sse_real_transport` errors are the pre-existing Windows `os.fork` limitation tracked in tadata-org#332).
Hand-written OpenAPI specs can express unions with oneOf instead of
anyOf. The injection guard only looked at anyOf, so a oneOf-only body
schema still received a bogus "type": "string" alongside the union —
same rejection risk as tadata-org#307. Treat anyOf/oneOf uniformly.
@K4bain

K4bain commented Sep 3, 2026

Copy link
Copy Markdown
Author

Follow-up pushed: the guard now also covers oneOf-only unions (hand-written OpenAPI specs), which previously still received a bogus single injected type alongside the union — same rejection risk as the anyOf case. New test test_oneof_body_schema_not_collapsed covers it. 79/79 tests green.

The path-param loop injected a default "type" with no union awareness,
so a path param typed int | str received both anyOf and a bogus
"type": "string" - the same corruption tadata-org#307 describes, through the one
loop the previous commits missed. Apply the same guard used in the
query and body loops.
@K4bain

K4bain commented Sep 3, 2026

Copy link
Copy Markdown
Author

Second follow-up: the path-param loop had the same injection bug (path param typed int | str got both anyOf and a bogus injected type — verified live before the fix). Same guard applied there; test_union_path_param_not_collapsed covers it. All three param loops (path/query/body) now treat unions uniformly. 80/80 tests green.

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.

[BUG] Union types of two real types (non-null) rejected at MCP-server jsonschema validator with misleading "is not of type 'X'"

1 participant