Skip to content

Commit 2b64465

Browse files
authored
fix: mcp_tool_to_langchain parameters conversion error (#191)
1 parent 6c042e1 commit 2b64465

4 files changed

Lines changed: 188 additions & 5 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sap-cloud-sdk"
3-
version = "0.32.0"
3+
version = "0.32.1"
44
description = "SAP Cloud SDK for Python"
55
readme = "README.md"
66
license = "Apache-2.0"

src/sap_cloud_sdk/agentgateway/converters.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,28 @@
1616
if TYPE_CHECKING:
1717
from langchain_core.tools import StructuredTool
1818

19+
_JSON_TYPE_MAP: dict[str, type] = {
20+
"string": str,
21+
"integer": int,
22+
"number": float,
23+
"boolean": bool,
24+
"array": list,
25+
"object": dict,
26+
}
27+
28+
29+
def _resolve_type(json_type: Any) -> tuple[type, bool]:
30+
"""Return (python_type, is_nullable) from a JSON Schema ``type`` value.
31+
32+
Handles both the plain-string form (``"integer"``) and the array form
33+
(``["integer", "null"]``). Unknown or missing types map to ``Any``.
34+
"""
35+
if isinstance(json_type, list):
36+
nullable = "null" in json_type
37+
scalar = next((t for t in json_type if t != "null"), None)
38+
return _JSON_TYPE_MAP.get(scalar, Any), nullable
39+
return _JSON_TYPE_MAP.get(json_type, Any), False
40+
1941

2042
def mcp_tool_to_langchain(
2143
mcp_tool: MCPTool,
@@ -82,10 +104,14 @@ async def run(**kwargs) -> str:
82104
# Build args schema from input_schema
83105
properties = mcp_tool.input_schema.get("properties", {})
84106
required = set(mcp_tool.input_schema.get("required", []))
85-
fields: dict[str, Any] = {
86-
k: (str, ...) if k in required else (str | None, Field(default=None))
87-
for k in properties
88-
}
107+
fields: dict[str, Any] = {}
108+
for k, v in properties.items():
109+
py_type, type_nullable = _resolve_type(v.get("type"))
110+
optional = k not in required
111+
if optional or type_nullable:
112+
fields[k] = (py_type | None, Field(default=None))
113+
else:
114+
fields[k] = (py_type, ...)
89115
args_schema = create_model(f"{mcp_tool.name}_args", **fields) if fields else None
90116

91117
return StructuredTool.from_function(

src/sap_cloud_sdk/agentgateway/user-guide.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,20 @@ mcp_tool_to_langchain(
122122
)
123123
```
124124

125+
The converter maps each property's JSON Schema `"type"` to the corresponding Python type so Pydantic validates and forwards the correct native type to the MCP server:
126+
127+
| JSON Schema type | Python type |
128+
|------------------|-------------|
129+
| `"string"` | `str` |
130+
| `"integer"` | `int` |
131+
| `"number"` | `float` |
132+
| `"boolean"` | `bool` |
133+
| `"array"` | `list` |
134+
| `"object"` | `dict` |
135+
| missing / other | `Any` |
136+
137+
Optional fields (not listed in `"required"`) are typed as `T | None` with a `None` default.
138+
125139
## Concepts
126140

127141
### Agent Types

tests/agentgateway/unit/test_converters.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,149 @@ def test_input_schema_without_properties_key(self):
8989
assert lc_tool.args_schema is not None
9090

9191

92+
class TestMcpToolToLangchainTypeMapping:
93+
"""Tests that JSON Schema types are mapped to the correct Python types."""
94+
95+
def _tool_with_types(self, properties: dict, required: list[str] | None = None) -> MCPTool:
96+
return MCPTool(
97+
name="typed_tool",
98+
server_name="server",
99+
description="desc",
100+
input_schema={
101+
"type": "object",
102+
"required": required or [],
103+
"properties": properties,
104+
},
105+
url="https://example.com/mcp",
106+
)
107+
108+
def test_string_type_maps_to_str(self):
109+
lc_tool = mcp_tool_to_langchain(
110+
self._tool_with_types({"name": {"type": "string"}}, required=["name"]),
111+
AsyncMock(),
112+
lambda: "token",
113+
)
114+
assert _schema_fields(lc_tool)["name"].annotation is str
115+
116+
def test_integer_type_maps_to_int(self):
117+
lc_tool = mcp_tool_to_langchain(
118+
self._tool_with_types({"limit": {"type": "integer"}}, required=["limit"]),
119+
AsyncMock(),
120+
lambda: "token",
121+
)
122+
assert _schema_fields(lc_tool)["limit"].annotation is int
123+
124+
def test_number_type_maps_to_float(self):
125+
lc_tool = mcp_tool_to_langchain(
126+
self._tool_with_types({"ratio": {"type": "number"}}, required=["ratio"]),
127+
AsyncMock(),
128+
lambda: "token",
129+
)
130+
assert _schema_fields(lc_tool)["ratio"].annotation is float
131+
132+
def test_boolean_type_maps_to_bool(self):
133+
lc_tool = mcp_tool_to_langchain(
134+
self._tool_with_types({"active": {"type": "boolean"}}, required=["active"]),
135+
AsyncMock(),
136+
lambda: "token",
137+
)
138+
assert _schema_fields(lc_tool)["active"].annotation is bool
139+
140+
def test_array_type_maps_to_list(self):
141+
lc_tool = mcp_tool_to_langchain(
142+
self._tool_with_types({"tags": {"type": "array"}}, required=["tags"]),
143+
AsyncMock(),
144+
lambda: "token",
145+
)
146+
assert _schema_fields(lc_tool)["tags"].annotation is list
147+
148+
def test_object_type_maps_to_dict(self):
149+
lc_tool = mcp_tool_to_langchain(
150+
self._tool_with_types({"meta": {"type": "object"}}, required=["meta"]),
151+
AsyncMock(),
152+
lambda: "token",
153+
)
154+
assert _schema_fields(lc_tool)["meta"].annotation is dict
155+
156+
def test_unknown_type_maps_to_any(self):
157+
from typing import Any
158+
lc_tool = mcp_tool_to_langchain(
159+
self._tool_with_types({"data": {"type": "unknown"}}, required=["data"]),
160+
AsyncMock(),
161+
lambda: "token",
162+
)
163+
assert _schema_fields(lc_tool)["data"].annotation is Any
164+
165+
def test_missing_type_maps_to_any(self):
166+
from typing import Any
167+
lc_tool = mcp_tool_to_langchain(
168+
self._tool_with_types({"data": {}}, required=["data"]),
169+
AsyncMock(),
170+
lambda: "token",
171+
)
172+
assert _schema_fields(lc_tool)["data"].annotation is Any
173+
174+
def test_optional_non_string_field_is_nullable(self):
175+
lc_tool = mcp_tool_to_langchain(
176+
self._tool_with_types({"limit": {"type": "integer"}}),
177+
AsyncMock(),
178+
lambda: "token",
179+
)
180+
field = _schema_fields(lc_tool)["limit"]
181+
assert not field.is_required()
182+
# annotation should be int | None
183+
import types as _types
184+
assert isinstance(field.annotation, _types.UnionType)
185+
assert int in field.annotation.__args__
186+
assert type(None) in field.annotation.__args__
187+
188+
def test_array_type_integer_null_maps_to_int(self):
189+
lc_tool = mcp_tool_to_langchain(
190+
self._tool_with_types({"limit": {"type": ["integer", "null"]}}, required=["limit"]),
191+
AsyncMock(),
192+
lambda: "token",
193+
)
194+
field = _schema_fields(lc_tool)["limit"]
195+
import types as _types
196+
assert isinstance(field.annotation, _types.UnionType)
197+
assert int in field.annotation.__args__
198+
assert type(None) in field.annotation.__args__
199+
200+
def test_array_type_number_null_maps_to_float(self):
201+
lc_tool = mcp_tool_to_langchain(
202+
self._tool_with_types({"ratio": {"type": ["number", "null"]}}, required=["ratio"]),
203+
AsyncMock(),
204+
lambda: "token",
205+
)
206+
field = _schema_fields(lc_tool)["ratio"]
207+
import types as _types
208+
assert isinstance(field.annotation, _types.UnionType)
209+
assert float in field.annotation.__args__
210+
assert type(None) in field.annotation.__args__
211+
212+
def test_array_type_multiple_scalars_uses_first_non_null(self):
213+
# e.g. {"type": ["number", "string", "null"]} — pick "number"
214+
lc_tool = mcp_tool_to_langchain(
215+
self._tool_with_types({"val": {"type": ["number", "string", "null"]}}, required=["val"]),
216+
AsyncMock(),
217+
lambda: "token",
218+
)
219+
field = _schema_fields(lc_tool)["val"]
220+
import types as _types
221+
assert isinstance(field.annotation, _types.UnionType)
222+
assert float in field.annotation.__args__
223+
assert type(None) in field.annotation.__args__
224+
225+
def test_array_type_without_null_is_not_nullable(self):
226+
lc_tool = mcp_tool_to_langchain(
227+
self._tool_with_types({"count": {"type": ["integer"]}}, required=["count"]),
228+
AsyncMock(),
229+
lambda: "token",
230+
)
231+
field = _schema_fields(lc_tool)["count"]
232+
assert field.annotation is int
233+
234+
92235
class TestMcpToolToLangchainInvocation:
93236
"""End-to-end invocation tests: verify what actually reaches call_tool."""
94237

0 commit comments

Comments
 (0)