OpenHands / OpenHands/software-agent-sdk
[Bug]: `anyOf: [false, X]` converts to `{"not": {}}`, clobbering the concrete branch
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 1.1k
- Forks
- 539
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 137
Description
Is there an existing issue for the same bug?
- I have searched existing issues and this is not a duplicate.
Bug Description
_process_schema_node in openhands-sdk/openhands/sdk/tool/schema.py picks the wrong anyOf member when a boolean false schema appears in the list. Its filter keeps every non-dict member:
non_null_types = [
t
for t in node["anyOf"]
if not isinstance(t, dict) or t.get("type") != "null"
]
not isinstance(False, dict) is True, so false survives the filter, and only the first survivor is used. anyOf: [false, {"type": "string"}] therefore converts to {"not": {}} — a schema no value can satisfy — while the reversed anyOf: [{"type": "string"}, false] converts to {"type": "string"}. The conversion is order-dependent.
The same filter is the only thing standing between a malformed member (5, "string", None, [], 1.5) and an uncaught TypeError/AttributeError out of to_openai_tool(), which takes down the whole tool list rather than one tool.
This is reachable from untrusted input: MCPToolDefinition._get_tool_schema deep-copies the server-provided inputSchema and feeds it straight into _process_schema_node, so whatever an MCP server advertises reaches this code verbatim.
Expected Behavior
anyOf is a disjunction, so a false branch contributes nothing and the concrete branch should win regardless of position:
anyOf: [false, {"type": "string"}]→{"type": "string"}anyOf: [{"type": "string"}, false]→{"type": "string"}
Degenerate unions with no viable branch must keep their reject-all meaning (anyOf: [false] → {"not": {}}), and a member that is not a schema at all should be ignored rather than raising.
Actual Behavior
Running this against the current main with uv run python -c ... shows the order dependence:
uv run python -c "
from openhands.sdk.tool.schema import _process_schema_node
print(_process_schema_node({'anyOf': [False, {'type': 'string'}]}, {}))
print(_process_schema_node({'anyOf': [{'type': 'string'}, False]}, {}))
print(_process_schema_node({'anyOf': ['string', {'type': 'string'}]}, {}))
"
prints
{'not': {}}
{'type': 'string'}
AttributeError: 'str' object has no attribute 'get'
The first line should be {'type': 'string'} like the second, and the third case should not raise.
Steps to Reproduce
uv syncin a checkout of this repository.- Run the
uv run python -c ...snippet above. - Observe
{'not': {}}for the[false, {"type": "string"}]union and anAttributeErrorfor the non-schema member.
Acceptance Criteria
-
anyOf: [false, {"type": "string"}]andanyOf: [{"type": "string"}, false]both convert to{"type": "string"}. -
anyOf: [false]andanyOf: [false, {"type": "null"}]still convert to{"not": {}}. -
anyOf: [true, X]keeps converting to{}(unchanged behavior). - An
anyOfmember that is not a schema (5,"string",None,[],1.5) is ignored instead of raisingTypeError/AttributeError. - Regression tests covering the above live in
tests/sdk/tool/test_mcp_schema.pyand pass withuv run pytest tests/sdk/tool/test_mcp_schema.py -q.
Installation Method
Source checkout (uv sync)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in openhands-sdk/openhands/sdk/tool/schema.py at _process_schema_node, then run the reproduction command from the issue. Add regression coverage in tests/sdk/tool/test_mcp_schema.py for false, true, null, and malformed anyOf members, and run uv run pytest tests/sdk/tool/test_mcp_schema.py -q to confirm the expected conversions and no exceptions.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 86/100