Skip to content
Draft
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
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
[project]
name = "sap-cloud-sdk"

version = "0.49.0"
version = "0.49.1"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
28 changes: 18 additions & 10 deletions src/sap_cloud_sdk/agentgateway/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,6 @@ def mcp_tool_to_langchain(
"Install it with: pip install sap-cloud-sdk[langchain]"
) from None

async def run(**kwargs) -> str:
resolved = (
{k: v for k, v in kwargs.items() if v is not None} if omit_none else kwargs
)
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", []))
Expand All @@ -114,6 +104,24 @@ async def run(**kwargs) -> str:
fields[k] = (py_type, ...)
args_schema = create_model(f"{mcp_tool.name}_args", **fields) if fields else None

# Closed over at conversion time: only keys declared in the MCP schema are
# forwarded, so any kwargs LangChain injects (config, run_manager, …) are
# silently ignored without needing an explicit deny-list.
allowed_keys = frozenset(properties)

async def run(**kwargs) -> str:
tool_kwargs = {k: v for k, v in kwargs.items() if k in allowed_keys}
resolved = (
{k: v for k, v in tool_kwargs.items() if v is not None}
if omit_none
else tool_kwargs
)
return await call_tool(
mcp_tool,
user_token=get_user_token,
**resolved,
)

return StructuredTool.from_function(
coroutine=run,
name=mcp_tool.name,
Expand Down
42 changes: 42 additions & 0 deletions tests/agentgateway/unit/test_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,45 @@ async def test_none_values_forwarded_when_omit_none_false(self):
kwargs = call_tool.call_args.kwargs
assert "showdeclinedreason" in kwargs
assert kwargs["showdeclinedreason"] is None

@pytest.mark.asyncio
async def test_langchain_config_kwarg_not_forwarded_to_call_tool(self):
"""LangChain >= 0.3 injects 'config' into _arun; it must not reach call_tool."""
call_tool = AsyncMock(return_value="ok")
lc_tool = mcp_tool_to_langchain(_make_tool(), call_tool, lambda: "token")

assert lc_tool.coroutine is not None
await lc_tool.coroutine(eventid="E001", config={"configurable": {}})

assert "config" not in call_tool.call_args.kwargs

@pytest.mark.asyncio
async def test_langchain_run_manager_kwarg_not_forwarded_to_call_tool(self):
"""LangChain injects 'run_manager' into _arun; it must not reach call_tool."""
call_tool = AsyncMock(return_value="ok")
lc_tool = mcp_tool_to_langchain(_make_tool(), call_tool, lambda: "token")

assert lc_tool.coroutine is not None
await lc_tool.coroutine(eventid="E001", run_manager=object())

assert "run_manager" not in call_tool.call_args.kwargs

@pytest.mark.asyncio
async def test_langchain_injected_kwargs_stripped_while_tool_params_pass_through(self):
"""Both 'config' and 'run_manager' are stripped; real tool params still reach call_tool."""
call_tool = AsyncMock(return_value="ok")
lc_tool = mcp_tool_to_langchain(_make_tool(), call_tool, lambda: "token")

assert lc_tool.coroutine is not None
await lc_tool.coroutine(
eventid="E001",
showdeclinedreason="true",
config={"configurable": {}},
run_manager=object(),
)

kwargs = call_tool.call_args.kwargs
assert kwargs["eventid"] == "E001"
assert kwargs["showdeclinedreason"] == "true"
assert "config" not in kwargs
assert "run_manager" not in kwargs
Loading