diff --git a/pyproject.toml b/pyproject.toml index 52ece028..c15ed3e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/sap_cloud_sdk/agentgateway/converters.py b/src/sap_cloud_sdk/agentgateway/converters.py index 18788c60..0ca54a23 100644 --- a/src/sap_cloud_sdk/agentgateway/converters.py +++ b/src/sap_cloud_sdk/agentgateway/converters.py @@ -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", [])) @@ -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, diff --git a/tests/agentgateway/unit/test_converters.py b/tests/agentgateway/unit/test_converters.py index d6dce001..5c3e08ee 100644 --- a/tests/agentgateway/unit/test_converters.py +++ b/tests/agentgateway/unit/test_converters.py @@ -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