Skip to content

Commit c10763a

Browse files
committed
fix(mcp): preserve schema constraints, images, and structuredContent
Two related gaps in the adapter: 1. mcp_schema_to_pydantic_model dropped every constraint beyond the inferred Python type, so enum/pattern/min-max/length/description from the MCP inputSchema disappeared and the model saw an unconstrained type. Now passes enum -> Literal, description, and string/numeric/array bounds into Pydantic Field(...). 2. The result adapter only kept text content blocks and threw away image blocks (despite cubepi.ImageContent existing) and the new structuredContent field. Now maps image blocks to ImageContent and surfaces structuredContent in AgentToolResult.details, matching what the http/stdio loaders normalize.
1 parent 771ac10 commit c10763a

2 files changed

Lines changed: 145 additions & 10 deletions

File tree

cubepi/mcp/_adapter.py

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
from __future__ import annotations
44

5-
from typing import Any, Awaitable, Callable
5+
from typing import Any, Awaitable, Callable, Literal
66

7-
from pydantic import BaseModel, create_model
7+
from pydantic import BaseModel, Field, create_model
88

99
from cubepi.agent.types import AgentTool, AgentToolResult
10-
from cubepi.providers.base import Content, TextContent
10+
from cubepi.providers.base import Content, ImageContent, TextContent
1111

1212

1313
def mcp_schema_to_pydantic_model(
@@ -20,22 +20,66 @@ def mcp_schema_to_pydantic_model(
2020
cubepi.AgentTool requires `parameters: type[BaseModel]`. We synthesize
2121
a model from the schema's top-level properties.
2222
23-
Limited type coverage: string/integer/number/boolean/array/object.
24-
Nested object schemas become dict[str, Any] (escape hatch).
23+
Type coverage: string/integer/number/boolean/array/object. Nested
24+
object schemas become dict[str, Any]. Constraint coverage on top-level
25+
properties: enum (via Literal), description, pattern, minLength,
26+
maxLength, minimum/maximum (incl. exclusive variants), minItems,
27+
maxItems. Anything else is preserved as a plain type without
28+
validation.
2529
"""
2630
properties = input_schema.get("properties", {})
2731
required = set(input_schema.get("required", []))
2832

2933
fields: dict[str, Any] = {}
3034
for prop_name, prop_schema in properties.items():
31-
py_type = _json_schema_type_to_python(prop_schema)
32-
default = ... if prop_name in required else None
33-
fields[prop_name] = (py_type, default)
35+
fields[prop_name] = _build_field(prop_schema, prop_name in required)
3436

3537
model_name = f"MCP_{tool_name}_Input"
3638
return create_model(model_name, **fields)
3739

3840

41+
def _build_field(prop_schema: dict[str, Any], required: bool) -> tuple[Any, Any]:
42+
"""Map one JSON-Schema property to a (type, FieldInfo|default) tuple."""
43+
if "enum" in prop_schema and isinstance(prop_schema["enum"], list):
44+
enum_values = tuple(prop_schema["enum"])
45+
py_type: Any = Literal[enum_values] if enum_values else Any
46+
else:
47+
py_type = _json_schema_type_to_python(prop_schema)
48+
49+
field_kwargs: dict[str, Any] = {}
50+
if "description" in prop_schema:
51+
field_kwargs["description"] = prop_schema["description"]
52+
53+
# string constraints
54+
if "pattern" in prop_schema:
55+
field_kwargs["pattern"] = prop_schema["pattern"]
56+
if "minLength" in prop_schema:
57+
field_kwargs["min_length"] = prop_schema["minLength"]
58+
if "maxLength" in prop_schema:
59+
field_kwargs["max_length"] = prop_schema["maxLength"]
60+
61+
# numeric constraints
62+
if "minimum" in prop_schema:
63+
field_kwargs["ge"] = prop_schema["minimum"]
64+
if "maximum" in prop_schema:
65+
field_kwargs["le"] = prop_schema["maximum"]
66+
if "exclusiveMinimum" in prop_schema:
67+
field_kwargs["gt"] = prop_schema["exclusiveMinimum"]
68+
if "exclusiveMaximum" in prop_schema:
69+
field_kwargs["lt"] = prop_schema["exclusiveMaximum"]
70+
71+
# array constraints
72+
if "minItems" in prop_schema:
73+
field_kwargs["min_length"] = prop_schema["minItems"]
74+
if "maxItems" in prop_schema:
75+
field_kwargs["max_length"] = prop_schema["maxItems"]
76+
77+
default = ... if required else None
78+
if field_kwargs:
79+
return (py_type, Field(default=default, **field_kwargs))
80+
return (py_type, default)
81+
82+
3983
def _json_schema_type_to_python(schema: dict[str, Any]) -> Any:
4084
t = schema.get("type")
4185
if t == "string":
@@ -91,11 +135,22 @@ async def _execute(
91135
result = await call_remote(name, args_dict)
92136
content_blocks: list[Content] = []
93137
for c in result.get("content", []):
94-
if c.get("type") == "text":
138+
ctype = c.get("type")
139+
if ctype == "text":
95140
content_blocks.append(TextContent(text=c.get("text", "")))
141+
elif ctype == "image":
142+
content_blocks.append(
143+
ImageContent(
144+
source=c.get("data", ""),
145+
media_type=c.get("mimeType", ""),
146+
)
147+
)
148+
details: dict[str, Any] = {"raw_mcp_response": result}
149+
if "structuredContent" in result:
150+
details["structuredContent"] = result["structuredContent"]
96151
return AgentToolResult(
97152
content=content_blocks,
98-
details={"raw_mcp_response": result},
153+
details=details,
99154
is_error=True if result.get("isError") else None,
100155
)
101156

tests/mcp/test_adapter.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,86 @@ def test_schema_to_model_boolean_and_number() -> None:
5151
assert instance.rate == 1.5
5252

5353

54+
def test_schema_to_model_preserves_enum() -> None:
55+
"""enum becomes Literal — invalid values rejected by Pydantic."""
56+
schema = {
57+
"type": "object",
58+
"properties": {
59+
"unit": {"type": "string", "enum": ["c", "f"]},
60+
},
61+
"required": ["unit"],
62+
}
63+
M = mcp_schema_to_pydantic_model(tool_name="weather", input_schema=schema)
64+
assert M(unit="c").unit == "c"
65+
with pytest.raises(Exception): # noqa: BLE001 - Pydantic ValidationError
66+
M(unit="kelvin")
67+
68+
69+
def test_schema_to_model_preserves_string_constraints() -> None:
70+
schema = {
71+
"type": "object",
72+
"properties": {
73+
"code": {
74+
"type": "string",
75+
"pattern": "^[A-Z]{3}$",
76+
"minLength": 3,
77+
"maxLength": 3,
78+
"description": "ISO airport code",
79+
},
80+
},
81+
"required": ["code"],
82+
}
83+
M = mcp_schema_to_pydantic_model(tool_name="ap", input_schema=schema)
84+
assert M(code="SFO").code == "SFO"
85+
with pytest.raises(Exception):
86+
M(code="sfo") # lowercase fails pattern
87+
with pytest.raises(Exception):
88+
M(code="TOOLONG") # exceeds maxLength
89+
assert M.model_fields["code"].description == "ISO airport code"
90+
91+
92+
def test_schema_to_model_preserves_numeric_bounds() -> None:
93+
schema = {
94+
"type": "object",
95+
"properties": {
96+
"limit": {
97+
"type": "integer",
98+
"minimum": 1,
99+
"maximum": 100,
100+
"exclusiveMinimum": 0,
101+
},
102+
},
103+
"required": ["limit"],
104+
}
105+
M = mcp_schema_to_pydantic_model(tool_name="pg", input_schema=schema)
106+
assert M(limit=50).limit == 50
107+
with pytest.raises(Exception):
108+
M(limit=0)
109+
with pytest.raises(Exception):
110+
M(limit=101)
111+
112+
113+
def test_schema_to_model_preserves_array_size() -> None:
114+
schema = {
115+
"type": "object",
116+
"properties": {
117+
"tags": {
118+
"type": "array",
119+
"items": {"type": "string"},
120+
"minItems": 1,
121+
"maxItems": 3,
122+
},
123+
},
124+
"required": ["tags"],
125+
}
126+
M = mcp_schema_to_pydantic_model(tool_name="tag", input_schema=schema)
127+
assert M(tags=["a", "b"]).tags == ["a", "b"]
128+
with pytest.raises(Exception):
129+
M(tags=[])
130+
with pytest.raises(Exception):
131+
M(tags=["a", "b", "c", "d"])
132+
133+
54134
def test_schema_to_model_unknown_type_becomes_any() -> None:
55135
"""An unrecognized JSON Schema type falls back to typing.Any."""
56136
from typing import Any

0 commit comments

Comments
 (0)