Skip to content

Commit bd99d20

Browse files
UPinarclaude
andcommitted
fix(mcp): emit correct field types in lean outputSchema (v1.33.18)
The lean outputSchema flattener defaulted every field to {"type":"object"} whenever its type came from Pydantic's anyOf encoding of an optional (T | None), because it only inspected a top-level "type" key. Strict MCP clients then rejected valid tool responses whose values (strings, arrays, numbers, booleans) did not match the advertised object type. Resolve the real primitive from the non-null anyOf/oneOf arm and from a one-hop $ref, while keeping the wire schema flat (no $defs/$ref/anyOf). Fields with no single representable type (mixed-type unions or Any) now emit a permissive empty schema that validates any value instead of a wrong object type. A visited-set guard prevents unbounded recursion on mutually-referential union definitions. Adds field-type, edge-case, and cycle-guard tests. Closes #38. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 53a744a commit bd99d20

3 files changed

Lines changed: 165 additions & 7 deletions

File tree

app/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pydantic import Field
1313
from pydantic_settings import BaseSettings, SettingsConfigDict
1414

15-
VERSION = "1.33.17"
15+
VERSION = "1.33.18"
1616
MCP_TOOL_COUNT = 53 # v1.33.0: +tech_stack_cve_audit (MCP-only composite)
1717
MCP_RESOURCE_COUNT = 7 # v1.23.0: atlas+d3fend+cwe (4 templates + 3 catalogs)
1818
MCP_PROMPT_COUNT = 3 # v1.23.0: security_audit, vulnerability_check, contrast_triage

app/core/mcp_proxy.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,48 @@ def _resolve_success(node: object) -> "dict | None":
125125
return defs.get(_ref.removeprefix("#/$defs/"))
126126
return node
127127

128+
def _ftype_of(_fs: object, _seen: "frozenset[str]" = frozenset()) -> dict:
129+
# Resolve one field schema to a flat single-level wire fragment, usually
130+
# {"type": <primitive>}. Handles a direct `type` (str or list form), a
131+
# `$ref` (incl. ref->union, cycle-guarded) and Pydantic's `anyOf`/`oneOf`
132+
# encoding of `T | None` / unions. Returns a permissive {} (validates any
133+
# value) for fields with no single representable type — `Any` ({}) or a
134+
# mixed-type union — so strict MCP clients never reject a valid response.
135+
# Genuine complex models stay {"type": "object"}. `_seen` tracks visited
136+
# $def names so a cyclic ref-of-unions can't recurse forever.
137+
if not isinstance(_fs, dict):
138+
return {"type": "object"}
139+
if not _fs:
140+
return {}
141+
_t = _fs.get("type")
142+
if isinstance(_t, str):
143+
return {"type": _t}
144+
if isinstance(_t, list):
145+
_nn = [x for x in _t if isinstance(x, str) and x != "null"]
146+
return {"type": _nn[0]} if len(_nn) == 1 else {}
147+
_ref = _fs.get("$ref")
148+
if isinstance(_ref, str) and _ref.startswith("#/$defs/"):
149+
_name = _ref.removeprefix("#/$defs/")
150+
_d = defs.get(_name) or {}
151+
if isinstance(_d.get("type"), str):
152+
return {"type": _d["type"]}
153+
if (isinstance(_d.get("anyOf"), list) or isinstance(_d.get("oneOf"), list)) and _name not in _seen:
154+
return _ftype_of(_d, _seen | {_name})
155+
return {"type": "object"}
156+
for _uk in ("anyOf", "oneOf"):
157+
_arms = _fs.get(_uk)
158+
if isinstance(_arms, list):
159+
_frags = [_ftype_of(_a, _seen) for _a in _arms if isinstance(_a, dict) and _a.get("type") != "null"]
160+
if not _frags:
161+
return {"type": "object"}
162+
_types = {f.get("type") for f in _frags}
163+
if len(_types) == 1 and None not in _types:
164+
return {"type": next(iter(_types))}
165+
if len(_frags) == 1:
166+
return _frags[0]
167+
return {}
168+
return {"type": "object"}
169+
128170
def _flatten(model: object) -> dict:
129171
# one-level: field name -> {"type": <primitive>}; nested obj/array kept as type only.
130172
if not isinstance(model, dict):
@@ -133,12 +175,7 @@ def _flatten(model: object) -> dict:
133175
return {"type": "array"}
134176
_props = {}
135177
for _fname, _fs in (model.get("properties") or {}).items():
136-
_ftype = _fs.get("type") if isinstance(_fs, dict) else None
137-
if isinstance(_fs, dict) and isinstance(_fs.get("$ref"), str) and _fs["$ref"].startswith("#/$defs/"):
138-
_ftype = (defs.get(_fs["$ref"].removeprefix("#/$defs/")) or {}).get("type", "object")
139-
if _ftype is None:
140-
_ftype = "object"
141-
_props[_fname] = {"type": _ftype}
178+
_props[_fname] = _ftype_of(_fs)
142179
return {
143180
"type": "object",
144181
"properties": _props,

app/tests/test_mcp.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1382,3 +1382,124 @@ def test_lean_outputschema_slowpath_cache_none(self, mcp_client, monkeypatch):
13821382
assert r.status_code == 200
13831383
assert r.json()["id"] == 99
13841384
self._assert_lean_outputschema(r.json()["result"]["tools"])
1385+
1386+
1387+
class TestLeanOutputSchemaFieldTypes:
1388+
"""Issue #38: anyOf-encoded optional fields (Pydantic `T | None`) must keep
1389+
their real primitive type in the lean outputSchema, not collapse to
1390+
{"type": "object"}. Exercises _leanify_output_schema directly with a crafted
1391+
FastMCP-style envelope so the per-field type mapping is deterministic."""
1392+
1393+
_FASTMCP_OSCH = {
1394+
"$defs": {
1395+
"Verdict": {"type": "object", "properties": {"label": {"type": "string"}}},
1396+
"ErrorResponse": {"type": "object", "properties": {"error": {"type": "string"}}},
1397+
"CveSuccess": {
1398+
"type": "object",
1399+
"properties": {
1400+
"summary": {"anyOf": [{"type": "string"}, {"type": "null"}]},
1401+
"next_calls": {"anyOf": [{"type": "array", "items": {"type": "object"}}, {"type": "null"}]},
1402+
"days_remaining": {"anyOf": [{"type": "integer"}, {"type": "null"}]},
1403+
"cvss_v3": {"anyOf": [{"type": "number"}, {"type": "null"}]},
1404+
"is_kev": {"anyOf": [{"type": "boolean"}, {"type": "null"}]},
1405+
"verdict": {"anyOf": [{"$ref": "#/$defs/Verdict"}, {"type": "null"}]},
1406+
"cve_id": {"type": "string"},
1407+
},
1408+
"required": ["cve_id"],
1409+
},
1410+
},
1411+
"properties": {"result": {"anyOf": [{"$ref": "#/$defs/CveSuccess"}, {"$ref": "#/$defs/ErrorResponse"}]}},
1412+
"required": ["result"],
1413+
}
1414+
1415+
def _leaned(self):
1416+
from core.mcp_proxy import _leanify_output_schema
1417+
1418+
out = _leanify_output_schema(self._FASTMCP_OSCH)
1419+
return out["properties"]["result"]["properties"]
1420+
1421+
def test_optional_string_keeps_string(self):
1422+
assert self._leaned()["summary"]["type"] == "string"
1423+
1424+
def test_optional_array_keeps_array(self):
1425+
assert self._leaned()["next_calls"]["type"] == "array"
1426+
1427+
def test_optional_integer_keeps_integer(self):
1428+
assert self._leaned()["days_remaining"]["type"] == "integer"
1429+
1430+
def test_optional_number_keeps_number(self):
1431+
assert self._leaned()["cvss_v3"]["type"] == "number"
1432+
1433+
def test_optional_boolean_keeps_boolean(self):
1434+
assert self._leaned()["is_kev"]["type"] == "boolean"
1435+
1436+
def test_optional_model_ref_stays_object(self):
1437+
assert self._leaned()["verdict"]["type"] == "object"
1438+
1439+
def test_plain_string_unaffected(self):
1440+
assert self._leaned()["cve_id"]["type"] == "string"
1441+
1442+
def test_schema_stays_flat(self):
1443+
import json as _json
1444+
1445+
from core.mcp_proxy import _leanify_output_schema
1446+
1447+
out = _leanify_output_schema(self._FASTMCP_OSCH)
1448+
blob = _json.dumps(out)
1449+
assert "$defs" not in out and "anyOf" not in blob and "$ref" not in blob
1450+
1451+
1452+
class TestLeanOutputSchemaEdgeCases:
1453+
"""Issue #38 hardening: shapes with no single representable flat type must
1454+
stay permissive ({} — validates any value) instead of a wrong
1455+
{"type": "object"}, and alternate encodings (type-as-list, $ref->union)
1456+
must still resolve to the real primitive."""
1457+
1458+
_OSCH = {
1459+
"$defs": {
1460+
"MaybeStr": {"anyOf": [{"type": "string"}, {"type": "null"}]},
1461+
},
1462+
"type": "object",
1463+
"properties": {
1464+
"type_list": {"type": ["string", "null"]},
1465+
"mixed_union": {"anyOf": [{"type": "integer"}, {"type": "string"}]},
1466+
"ref_to_union": {"$ref": "#/$defs/MaybeStr"},
1467+
"any_field": {},
1468+
},
1469+
"required": [],
1470+
}
1471+
1472+
def _leaned(self):
1473+
from core.mcp_proxy import _leanify_output_schema
1474+
1475+
return _leanify_output_schema(self._OSCH)["properties"]
1476+
1477+
def test_type_list_picks_non_null(self):
1478+
assert self._leaned()["type_list"] == {"type": "string"}
1479+
1480+
def test_mixed_union_is_permissive(self):
1481+
# cannot be a single flat type -> {} so strict clients never reject
1482+
assert self._leaned()["mixed_union"] == {}
1483+
1484+
def test_ref_to_union_resolves(self):
1485+
assert self._leaned()["ref_to_union"] == {"type": "string"}
1486+
1487+
def test_any_field_is_permissive(self):
1488+
assert self._leaned()["any_field"] == {}
1489+
1490+
def test_cyclic_ref_does_not_recurse_forever(self):
1491+
# A mutual $ref cycle between two union-defs must terminate (cycle guard),
1492+
# not blow the stack, and fall back to a safe wire fragment.
1493+
from core.mcp_proxy import _leanify_output_schema
1494+
1495+
osch = {
1496+
"$defs": {
1497+
"A": {"anyOf": [{"$ref": "#/$defs/B"}, {"type": "null"}]},
1498+
"B": {"anyOf": [{"$ref": "#/$defs/A"}, {"type": "null"}]},
1499+
},
1500+
"type": "object",
1501+
"properties": {"f": {"$ref": "#/$defs/A"}},
1502+
"required": [],
1503+
}
1504+
out = _leanify_output_schema(osch)
1505+
assert out["properties"]["f"] in ({"type": "object"}, {})

0 commit comments

Comments
 (0)