Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sap-cloud-sdk"
version = "0.57.0"
version = "0.57.1"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
27 changes: 19 additions & 8 deletions src/sap_cloud_sdk/agentgateway/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,27 +91,38 @@ def mcp_tool_to_langchain(
"Install it with: pip install sap-cloud-sdk[langchain]"
) from None

# Build args schema from input_schema.
# Pydantic v2 rejects field names starting with '_' (OData CSDL §15.2 allows them).
# Strip leading underscores for the Pydantic model and restore originals before
# forwarding to call_tool via name_map.
properties = mcp_tool.input_schema.get("properties", {})
required = set(mcp_tool.input_schema.get("required", []))
# safe_name -> original_name; identity for names that need no renaming
name_map: dict[str, str] = {k.lstrip("_") or k: k for k in properties}

async def run(**kwargs) -> str:
# Translate safe names back to original OData names before forwarding.
restored = {name_map.get(k, k): v for k, v in kwargs.items()}
resolved = (
{k: v for k, v in kwargs.items() if v is not None} if omit_none else kwargs
{k: v for k, v in restored.items() if v is not None}
if omit_none
else restored
)
return await call_tool(
mcp_tool,
user_token=get_user_token,
**resolved,
)

# 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] = {}
for k, v in properties.items():
for safe, orig in name_map.items():
v = properties[orig]
py_type, type_nullable = _resolve_type(v.get("type"))
optional = k not in required
optional = orig not in required
if optional or type_nullable:
fields[k] = (py_type | None, Field(default=None))
fields[safe] = (py_type | None, Field(default=None))
else:
fields[k] = (py_type, ...)
fields[safe] = (py_type, ...)
args_schema = create_model(f"{mcp_tool.name}_args", **fields) if fields else None

return StructuredTool.from_function(
Expand Down
88 changes: 86 additions & 2 deletions tests/agentgateway/unit/test_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@


def _schema_fields(lc_tool):
"""Narrow args_schema to BaseModel and return model_fields."""
"""Return model_fields from the args_schema Pydantic model."""
schema = lc_tool.args_schema
assert isinstance(schema, type) and issubclass(schema, BaseModel)
return schema.model_fields
Expand Down Expand Up @@ -155,6 +155,7 @@ def test_object_type_maps_to_dict(self):

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(),
Expand All @@ -164,6 +165,7 @@ def test_unknown_type_maps_to_any(self):

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(),
Expand All @@ -181,6 +183,7 @@ def test_optional_non_string_field_is_nullable(self):
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__
Expand All @@ -193,6 +196,7 @@ def test_array_type_integer_null_maps_to_int(self):
)
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__
Expand All @@ -205,19 +209,23 @@ def test_array_type_number_null_maps_to_float(self):
)
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"]),
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__
Expand All @@ -232,6 +240,82 @@ def test_array_type_without_null_is_not_nullable(self):
assert field.annotation is int


class TestMcpToolToLangchainUnderscoredParams:
"""OData CSDL §15.2 allows '_'-prefixed identifiers; Pydantic v2 rejects them.

The converter strips leading underscores for the Pydantic model and restores
originals before forwarding to call_tool via an internal name_map.
"""

def _tool_with_underscore_params(self):
return MCPTool(
name="get_variant_config",
server_name="s4hana",
description="Get variant configuration",
input_schema={
"type": "object",
"required": ["_VariantConfiguration"],
"properties": {
"_VariantConfiguration": {"type": "string"},
"_Product": {"type": "string"},
"NormalParam": {"type": "string"},
},
},
url="https://example.com/mcp",
)

def test_underscored_required_param_present_in_schema(self):
"""'_VariantConfiguration' must appear in the args schema (stripped to safe name)."""
lc_tool = mcp_tool_to_langchain(
self._tool_with_underscore_params(), AsyncMock(return_value="ok"), lambda: "token"
)
fields = _schema_fields(lc_tool)
assert "VariantConfiguration" in fields
assert fields["VariantConfiguration"].is_required()

def test_underscored_optional_param_present_in_schema(self):
lc_tool = mcp_tool_to_langchain(
self._tool_with_underscore_params(), AsyncMock(return_value="ok"), lambda: "token"
)
fields = _schema_fields(lc_tool)
assert "Product" in fields
assert not fields["Product"].is_required()

def test_non_underscored_param_unaffected(self):
lc_tool = mcp_tool_to_langchain(
self._tool_with_underscore_params(), AsyncMock(return_value="ok"), lambda: "token"
)
assert "NormalParam" in _schema_fields(lc_tool)

@pytest.mark.asyncio
async def test_original_underscore_name_restored_on_invocation(self):
"""call_tool must receive '_VariantConfiguration', not 'VariantConfiguration'."""
call_tool = AsyncMock(return_value="ok")
lc_tool = mcp_tool_to_langchain(
self._tool_with_underscore_params(), call_tool, lambda: "token"
)

await lc_tool.arun({"VariantConfiguration": "VC001"})

kwargs = call_tool.call_args.kwargs
assert "_VariantConfiguration" in kwargs
assert kwargs["_VariantConfiguration"] == "VC001"
assert "VariantConfiguration" not in kwargs

@pytest.mark.asyncio
async def test_optional_underscore_param_restored_when_supplied(self):
call_tool = AsyncMock(return_value="ok")
lc_tool = mcp_tool_to_langchain(
self._tool_with_underscore_params(), call_tool, lambda: "token"
)

await lc_tool.arun({"VariantConfiguration": "VC001", "Product": "P001"})

kwargs = call_tool.call_args.kwargs
assert kwargs.get("_Product") == "P001"
assert "Product" not in kwargs


class TestMcpToolToLangchainInvocation:
"""End-to-end invocation tests: verify what actually reaches call_tool."""

Expand Down
Loading
Loading