diff --git a/pyproject.toml b/pyproject.toml index aa778f3e..a593aaa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.32.0" +version = "0.32.1" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agentgateway/converters.py b/src/sap_cloud_sdk/agentgateway/converters.py index 180194ff..18788c60 100644 --- a/src/sap_cloud_sdk/agentgateway/converters.py +++ b/src/sap_cloud_sdk/agentgateway/converters.py @@ -16,6 +16,28 @@ if TYPE_CHECKING: from langchain_core.tools import StructuredTool +_JSON_TYPE_MAP: dict[str, type] = { + "string": str, + "integer": int, + "number": float, + "boolean": bool, + "array": list, + "object": dict, +} + + +def _resolve_type(json_type: Any) -> tuple[type, bool]: + """Return (python_type, is_nullable) from a JSON Schema ``type`` value. + + Handles both the plain-string form (``"integer"``) and the array form + (``["integer", "null"]``). Unknown or missing types map to ``Any``. + """ + if isinstance(json_type, list): + nullable = "null" in json_type + scalar = next((t for t in json_type if t != "null"), None) + return _JSON_TYPE_MAP.get(scalar, Any), nullable + return _JSON_TYPE_MAP.get(json_type, Any), False + def mcp_tool_to_langchain( mcp_tool: MCPTool, @@ -82,10 +104,14 @@ async def run(**kwargs) -> str: # Build args schema from input_schema properties = mcp_tool.input_schema.get("properties", {}) required = set(mcp_tool.input_schema.get("required", [])) - fields: dict[str, Any] = { - k: (str, ...) if k in required else (str | None, Field(default=None)) - for k in properties - } + fields: dict[str, Any] = {} + for k, v in properties.items(): + py_type, type_nullable = _resolve_type(v.get("type")) + optional = k not in required + if optional or type_nullable: + fields[k] = (py_type | None, Field(default=None)) + else: + fields[k] = (py_type, ...) args_schema = create_model(f"{mcp_tool.name}_args", **fields) if fields else None return StructuredTool.from_function( diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index 813ed2e0..d31ace2c 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -122,6 +122,20 @@ mcp_tool_to_langchain( ) ``` +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: + +| JSON Schema type | Python type | +|------------------|-------------| +| `"string"` | `str` | +| `"integer"` | `int` | +| `"number"` | `float` | +| `"boolean"` | `bool` | +| `"array"` | `list` | +| `"object"` | `dict` | +| missing / other | `Any` | + +Optional fields (not listed in `"required"`) are typed as `T | None` with a `None` default. + ## Concepts ### Agent Types diff --git a/tests/agentgateway/unit/test_converters.py b/tests/agentgateway/unit/test_converters.py index 61b65eec..d6dce001 100644 --- a/tests/agentgateway/unit/test_converters.py +++ b/tests/agentgateway/unit/test_converters.py @@ -89,6 +89,149 @@ def test_input_schema_without_properties_key(self): assert lc_tool.args_schema is not None +class TestMcpToolToLangchainTypeMapping: + """Tests that JSON Schema types are mapped to the correct Python types.""" + + def _tool_with_types(self, properties: dict, required: list[str] | None = None) -> MCPTool: + return MCPTool( + name="typed_tool", + server_name="server", + description="desc", + input_schema={ + "type": "object", + "required": required or [], + "properties": properties, + }, + url="https://example.com/mcp", + ) + + def test_string_type_maps_to_str(self): + lc_tool = mcp_tool_to_langchain( + self._tool_with_types({"name": {"type": "string"}}, required=["name"]), + AsyncMock(), + lambda: "token", + ) + assert _schema_fields(lc_tool)["name"].annotation is str + + def test_integer_type_maps_to_int(self): + lc_tool = mcp_tool_to_langchain( + self._tool_with_types({"limit": {"type": "integer"}}, required=["limit"]), + AsyncMock(), + lambda: "token", + ) + assert _schema_fields(lc_tool)["limit"].annotation is int + + def test_number_type_maps_to_float(self): + lc_tool = mcp_tool_to_langchain( + self._tool_with_types({"ratio": {"type": "number"}}, required=["ratio"]), + AsyncMock(), + lambda: "token", + ) + assert _schema_fields(lc_tool)["ratio"].annotation is float + + def test_boolean_type_maps_to_bool(self): + lc_tool = mcp_tool_to_langchain( + self._tool_with_types({"active": {"type": "boolean"}}, required=["active"]), + AsyncMock(), + lambda: "token", + ) + assert _schema_fields(lc_tool)["active"].annotation is bool + + def test_array_type_maps_to_list(self): + lc_tool = mcp_tool_to_langchain( + self._tool_with_types({"tags": {"type": "array"}}, required=["tags"]), + AsyncMock(), + lambda: "token", + ) + assert _schema_fields(lc_tool)["tags"].annotation is list + + def test_object_type_maps_to_dict(self): + lc_tool = mcp_tool_to_langchain( + self._tool_with_types({"meta": {"type": "object"}}, required=["meta"]), + AsyncMock(), + lambda: "token", + ) + assert _schema_fields(lc_tool)["meta"].annotation is dict + + def test_unknown_type_maps_to_any(self): + from typing import Any + lc_tool = mcp_tool_to_langchain( + self._tool_with_types({"data": {"type": "unknown"}}, required=["data"]), + AsyncMock(), + lambda: "token", + ) + assert _schema_fields(lc_tool)["data"].annotation is Any + + def test_missing_type_maps_to_any(self): + from typing import Any + lc_tool = mcp_tool_to_langchain( + self._tool_with_types({"data": {}}, required=["data"]), + AsyncMock(), + lambda: "token", + ) + assert _schema_fields(lc_tool)["data"].annotation is Any + + def test_optional_non_string_field_is_nullable(self): + lc_tool = mcp_tool_to_langchain( + self._tool_with_types({"limit": {"type": "integer"}}), + AsyncMock(), + lambda: "token", + ) + field = _schema_fields(lc_tool)["limit"] + assert not field.is_required() + # annotation should be int | None + import types as _types + assert isinstance(field.annotation, _types.UnionType) + assert int in field.annotation.__args__ + assert type(None) in field.annotation.__args__ + + def test_array_type_integer_null_maps_to_int(self): + lc_tool = mcp_tool_to_langchain( + self._tool_with_types({"limit": {"type": ["integer", "null"]}}, required=["limit"]), + AsyncMock(), + lambda: "token", + ) + field = _schema_fields(lc_tool)["limit"] + import types as _types + assert isinstance(field.annotation, _types.UnionType) + assert int in field.annotation.__args__ + assert type(None) in field.annotation.__args__ + + def test_array_type_number_null_maps_to_float(self): + lc_tool = mcp_tool_to_langchain( + self._tool_with_types({"ratio": {"type": ["number", "null"]}}, required=["ratio"]), + AsyncMock(), + lambda: "token", + ) + field = _schema_fields(lc_tool)["ratio"] + import types as _types + assert isinstance(field.annotation, _types.UnionType) + assert float in field.annotation.__args__ + assert type(None) in field.annotation.__args__ + + def test_array_type_multiple_scalars_uses_first_non_null(self): + # e.g. {"type": ["number", "string", "null"]} — pick "number" + lc_tool = mcp_tool_to_langchain( + self._tool_with_types({"val": {"type": ["number", "string", "null"]}}, required=["val"]), + AsyncMock(), + lambda: "token", + ) + field = _schema_fields(lc_tool)["val"] + import types as _types + assert isinstance(field.annotation, _types.UnionType) + assert float in field.annotation.__args__ + assert type(None) in field.annotation.__args__ + + def test_array_type_without_null_is_not_nullable(self): + lc_tool = mcp_tool_to_langchain( + self._tool_with_types({"count": {"type": ["integer"]}}, required=["count"]), + AsyncMock(), + lambda: "token", + ) + field = _schema_fields(lc_tool)["count"] + assert field.annotation is int + + class TestMcpToolToLangchainInvocation: """End-to-end invocation tests: verify what actually reaches call_tool."""