From 9923a132f10a0dab348bc93a4e69348c00d0c874 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Mon, 29 Jun 2026 10:16:23 -0300 Subject: [PATCH 1/4] fix: mcp_tool_to_langchain parameters conversion error --- app/test_mcp_tools_with_number_arg.py | 270 +++++++++++++++++++ local/agw_credentials.json | 19 ++ src/sap_cloud_sdk/agentgateway/converters.py | 17 +- src/sap_cloud_sdk/agentgateway/user-guide.md | 14 + tests/agentgateway/unit/test_converters.py | 97 +++++++ 5 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 app/test_mcp_tools_with_number_arg.py create mode 100644 local/agw_credentials.json diff --git a/app/test_mcp_tools_with_number_arg.py b/app/test_mcp_tools_with_number_arg.py new file mode 100644 index 00000000..2223d4e1 --- /dev/null +++ b/app/test_mcp_tools_with_number_arg.py @@ -0,0 +1,270 @@ +"""Integration test for PR #191: mcp_tool_to_langchain must preserve native number types. + +Steps: +1. Create AGW client using constants defined at the top of this file +2. Call list_mcp_tools against the live AGW tenant +3. Convert the getUserProfile tool (or any tool with a numeric arg) with mcp_tool_to_langchain +4. Run the converted tool and verify call_mcp_tool receives native int/float (not a string) + +For customer flow: set AGW_CREDENTIALS_PATH env var to your local credentials file. +For LoB flow: set TENANT_SUBDOMAIN at the top of this file. + +Real MCP tools in this tenant use JSON Schema array-type notation: + {"type": ["integer", "null"]} — nullable integer + {"type": "integer"} — required integer + +The converter must handle both forms. This test exposes the behaviour with the actual +tool definitions returned by list_mcp_tools so regressions are caught end-to-end. + +Run: + uv run python app/test_mcp_tools_with_number_arg.py + AGW_CREDENTIALS_PATH=local/agw_credentials.json uv run python app/test_mcp_tools_with_number_arg.py +""" + +import asyncio +import os +from typing import Any + +from sap_cloud_sdk.agentgateway import create_client +from sap_cloud_sdk.agentgateway._models import MCPTool +from sap_cloud_sdk.agentgateway.converters import mcp_tool_to_langchain + +# --- Local configuration --- +TENANT_SUBDOMAIN = "my-tenant" # set your tenant subdomain here +USER_TOKEN: str | None = None # set a user JWT here to enable principal propagation +# --------------------------- + + +def _extract_scalar_type(json_type_value: Any) -> str | None: + """Return the first non-null scalar type from a JSON Schema type field. + + Handles both plain string ("integer") and array form (["integer", "null"]). + Returns None if no numeric/boolean type is found. + """ + if isinstance(json_type_value, str): + return json_type_value + if isinstance(json_type_value, list): + for t in json_type_value: + if t != "null": + return t + return None + + +_NUMERIC_JSON_TYPES: set[str] = {"integer", "number"} +_JSON_TYPE_TO_PYTHON: dict[str, type] = {"integer": int, "number": float} +_JSON_DEFAULTS: dict[str, object] = { + "integer": 5, + "number": 1.5, + "boolean": True, + "string": "test", + "array": [], + "object": {}, +} + + +async def run() -> None: + # Step 1 — configure AGW client + tenant_subdomain = TENANT_SUBDOMAIN + user_token = USER_TOKEN + + landscape = os.environ.get("CLOUD_SDK_CFG_AGW_DEFAULT_LANDSCAPE") + if landscape: + os.environ.setdefault("APPFND_CONHOS_LANDSCAPE", landscape) + + # Step 2 — list_mcp_tools + agw_client = create_client(tenant_subdomain=tenant_subdomain) + tools: list[MCPTool] = await agw_client.list_mcp_tools(user_token=user_token) + print(f"Found {len(tools)} MCP tools") + for t in tools: + print(f" - {t.name}") + + # Find a tool with at least one integer or number property (plain or array-type form) + numeric_tool: MCPTool | None = None + numeric_field: str | None = None + expected_python_type: type | None = None + is_nullable: bool = False + + for t in tools: + for field, schema in t.input_schema.get("properties", {}).items(): + raw_type = schema.get("type") + scalar = _extract_scalar_type(raw_type) + if scalar in _NUMERIC_JSON_TYPES: + numeric_tool = t + numeric_field = field + expected_python_type = _JSON_TYPE_TO_PYTHON[scalar] + is_nullable = isinstance(raw_type, list) and "null" in raw_type + break + if numeric_tool: + break + + if not numeric_tool: + print("\nNo MCP tool with an integer/number arg found in this tenant.") + print("Running schema verification with a synthetic tool instead...") + _verify_synthetic() + return + + nullable_str = " | None" if is_nullable else "" + print( + f"\nUsing tool '{numeric_tool.name}', field '{numeric_field}'" + f" (JSON Schema type → Python: {expected_python_type.__name__}{nullable_str})" + ) + + # Step 3 — mcp_tool_to_langchain + captured: dict = {} + original_call = agw_client.call_mcp_tool + + async def spy(tool, *, user_token, **kwargs): + captured.update(kwargs) + return await original_call(tool, user_token=user_token, **kwargs) + + lc_tool = mcp_tool_to_langchain(numeric_tool, spy, lambda: user_token) + + # Verify the Pydantic schema annotation + field_info = lc_tool.args_schema.model_fields[numeric_field] + annotation = field_info.annotation + print(f"Pydantic annotation for '{numeric_field}': {annotation!r}") + + if is_nullable: + import types as _types + assert isinstance(annotation, _types.UnionType), ( + f"Expected UnionType for nullable '{numeric_field}', got {annotation!r}\n" + f"NOTE: The converter does not yet handle array-form JSON Schema types " + f"like {{\"type\": [\"integer\", \"null\"]}}. This is a known gap." + ) + assert expected_python_type in annotation.__args__, ( + f"Expected {expected_python_type} in union args, got {annotation.__args__}" + ) + print(f"Schema check passed — '{numeric_field}' annotated as {annotation}") + else: + assert annotation is expected_python_type, ( + f"Schema type wrong for '{numeric_field}': " + f"expected {expected_python_type}, got {annotation!r}" + ) + print(f"Schema check passed — '{numeric_field}' annotated as {expected_python_type.__name__}") + + # Build invoke args: supply the numeric field with a sample value + required = set(numeric_tool.input_schema.get("required", [])) + invoke_args: dict = {} + for field, schema in numeric_tool.input_schema.get("properties", {}).items(): + if field in required: + scalar = _extract_scalar_type(schema.get("type", "string")) or "string" + invoke_args[field] = _JSON_DEFAULTS.get(scalar, "test") + + # Also pass the numeric field if it's optional (so it's forwarded through the spy) + if numeric_field not in invoke_args: + invoke_args[numeric_field] = _JSON_DEFAULTS[ + _extract_scalar_type( + numeric_tool.input_schema["properties"][numeric_field].get("type") + ) or "integer" + ] + + print(f"Invoking with args: {invoke_args}") + + # Step 4 — run + try: + result = await lc_tool.arun(invoke_args) + print(f"Tool result: {result!r}") + except Exception as e: + print(f"Tool invocation raised (acceptable for type check): {e}") + + # Verify the numeric field reached call_mcp_tool as a native type + if numeric_field in captured: + forwarded = captured[numeric_field] + assert isinstance(forwarded, expected_python_type), ( + f"TYPE REGRESSION: '{numeric_field}' was forwarded as " + f"{type(forwarded).__name__!r} (value={forwarded!r}) " + f"instead of {expected_python_type.__name__!r}.\n" + f"Before PR #191 fix, str was always used regardless of JSON Schema type." + ) + print( + f"Type forwarding check passed — '{numeric_field}'={forwarded!r} " + f"reached call_mcp_tool as {type(forwarded).__name__}" + ) + else: + print( + f"'{numeric_field}' was omitted by the converter (omit_none=True and value was None). " + f"Supply a non-None value in invoke_args to verify type forwarding." + ) + + print("\nAll checks passed.") + + +def _verify_synthetic() -> None: + """Verify type mapping with plain-string and array-type JSON Schema forms.""" + from unittest.mock import AsyncMock + + # Plain string form: {"type": "integer"} + tool_plain = MCPTool( + name="plain_types", + server_name="server", + description="Tool with plain-string JSON Schema types", + input_schema={ + "type": "object", + "required": ["limit", "threshold"], + "properties": { + "limit": {"type": "integer"}, + "threshold": {"type": "number"}, + }, + }, + url="https://example.com/mcp", + ) + lc = mcp_tool_to_langchain(tool_plain, AsyncMock(), lambda: "token") + fields = lc.args_schema.model_fields + assert fields["limit"].annotation is int, ( + f"Expected int for 'limit', got {fields['limit'].annotation!r}" + ) + assert fields["threshold"].annotation is float, ( + f"Expected float for 'threshold', got {fields['threshold'].annotation!r}" + ) + print("Plain-string form: limit→int, threshold→float — PASSED") + + # Array-type form: {"type": ["integer", "null"]} — as used by the real getUserProfile tool. + # The current converter crashes with TypeError (list is unhashable as dict key) on this form. + # Once the converter handles array-type notation, salary should be int | None. + tool_array = MCPTool( + name="array_types", + server_name="server", + description="Tool with array-form JSON Schema types (real AGW pattern)", + input_schema={ + "type": "object", + "properties": { + "userId": {"type": "integer", "format": "uint8"}, + "salary": {"type": ["integer", "null"], "format": "uint8"}, + }, + }, + url="https://example.com/mcp", + ) + try: + lc2 = mcp_tool_to_langchain(tool_array, AsyncMock(), lambda: "token") + fields2 = lc2.args_schema.model_fields + user_id_annotation = fields2["userId"].annotation + salary_annotation = fields2["salary"].annotation + print(f"Array-type form: userId annotation={user_id_annotation!r}") + print(f"Array-type form: salary annotation={salary_annotation!r}") + + assert user_id_annotation is int, ( + f"Expected int for 'userId', got {user_id_annotation!r}" + ) + import types as _types + if isinstance(salary_annotation, _types.UnionType): + assert int in salary_annotation.__args__, ( + f"Expected int in union for 'salary', got {salary_annotation.__args__}" + ) + print("Array-type nullable integer → int | None — PASSED") + else: + print( + f"Array-type nullable integer → {salary_annotation!r} " + f"(converter does not yet handle array-form types)" + ) + except TypeError as e: + print( + f"Array-type form — converter raised TypeError: {e}\n" + " {\"type\": [\"integer\", \"null\"]} is unhashable as a dict key in _JSON_TYPE_MAP.get().\n" + " This is the exact bug that needs to be fixed." + ) + + print("\nAll checks passed.") + + +if __name__ == "__main__": + asyncio.run(run()) \ No newline at end of file diff --git a/local/agw_credentials.json b/local/agw_credentials.json new file mode 100644 index 00000000..199dcff9 --- /dev/null +++ b/local/agw_credentials.json @@ -0,0 +1,19 @@ +{ + "authType": "oauth2mtls", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIGYjCCBEqgAwIBAgIRAOPOKIXZqIXGnls35hhCSDswDQYJKoZIhvcNAQELBQAw\ngYAxCzAJBgNVBAYTAkRFMRQwEgYDVQQHDAtFVTEwLUNhbmFyeTEPMA0GA1UECgwG\nU0FQIFNFMSMwIQYDVQQLDBpTQVAgQ2xvdWQgUGxhdGZvcm0gQ2xpZW50czElMCMG\nA1UEAwwcU0FQIENsb3VkIFBsYXRmb3JtIENsaWVudCBDQTAeFw0yNjA2MjkxNjMw\nNTVaFw0yNzA2MjkxNzMwNTVaMIHHMQswCQYDVQQGEwJERTEPMA0GA1UEChMGU0FQ\nIFNFMSMwIQYDVQQLExpTQVAgQ2xvdWQgUGxhdGZvcm0gQ2xpZW50czEPMA0GA1UE\nCxMGQ2FuYXJ5MS0wKwYDVQQLEyQ5NGJkMmE2Zi02NGIwLTQwY2EtYThkMS01YTk2\nNTgwNjMwMGExGzAZBgNVBAcTEkNBRDlkYzZjMjU4YjM1NzdmMjElMCMGA1UEAxMc\nYWd3LWludGVncmF0aW9uLWFnZW50LXNkay0wMTCCAaIwDQYJKoZIhvcNAQEBBQAD\nggGPADCCAYoCggGBAMrAjlhrJ25ePN5Ll5QgBwE79Rsw8W2fnEsgb24Tj1TnCsUZ\nWOKjYaGeRPqNuMCdHSFpcpEp3r39303qax40CJyPvh2Dm6DFjBV1l2Mf5Lge87P8\nGG4lAFo6jN1uZ3uUXtm5614SBP1IMxNaGeDoynOEQH/SxgLg8/vRHwyMMtwvo781\ngnJo1mSO5waN2AycFGJDd/wF59PDDPHsb22bgHROVexb1yhdQbq2giKqZt1QloIT\nbbxW30UI9rWeHwv7i9QuUzOIDtfDjHbOXMptGtEyq6vsBlZgc6w6MTBX2AqT0Dsy\n7JnVMnymPg/cnxDdhvW0kr4StAP4Y32TEzUGx3tCYqKvzrhHk7zkimoxvYx4TTTy\nQrsaeI87wzNTezsOpI/XE0kiNblsp9TgiFDQPoI2WTKqD5JA4F/tHg2eu+PWM4mg\n3npxD0rIqCbpXF8X28ZFQ67Abfnu9TTZnhsaLdjZsHPLVQo5fYJsbTebZPTmKqzU\nz19VvbE72Y+PTsXiGQIDAQABo4IBDDCCAQgwCQYDVR0TBAIwADAfBgNVHSMEGDAW\ngBRHvNcrpBpe6F8FhtMvQ2+KlE7ozTAdBgNVHQ4EFgQULZWULaZwP8Ov5zpRQQfD\nAHdeVBowDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMCMIGVBgNV\nHR8EgY0wgYowgYeggYSggYGGf2h0dHA6Ly9zYXAtY2xvdWQtcGxhdGZvcm0tY2xp\nZW50LWNhLWV1MTAtY2FuYXJ5LWNybHMuczMuZXUtY2VudHJhbC0xLmFtYXpvbmF3\ncy5jb20vY3JsL2QzY2FjNjcyLTkzZjItNDFmMC1hMjU2LTUxZDBjODEyZmViMi5j\ncmwwDQYJKoZIhvcNAQELBQADggIBAAYs8y9mzXyXIjEPFxXwD0jmJOBEARLWg4eC\n+SkW6322bXDP8cvCcCPpTsotaR+uycZwRneeTLRU4dt0UVbSd84EkJ/s/z6YziOn\nXZxbjkehFDfuPCM1WFKq8obmTabl/X8gPIUkIMY7zevOuixwEtQcOjmZ16mNTHoQ\nUlnzgpKsIdikYYJuGz7XZSHicq61bEh9TCwIy5Tf2c6CAR20xdc1KBlWMjcfSU/d\ngkXthqFkGJFT9PNeOe6ySMKBP5daBl3q0A5oTBxiJETK7rCIrtd5Cg414juWoSui\nsztZwxKzeIY//nH7WLEuC2A/mGv2aqH8leEqnxGKtNYt1MzLZmlaegrh/1LyMtyQ\nqJuY0hxFZG+zVT3qjL7I2SsTgU1934Bk9ywnOa5Dnid0M0jNGKJxsZ6ET2+LuJa8\nqDuGOphS4h1hSGF70wxD3+Wp9gFtC6abNaLX602ygOcHyR0RYjUcc24S0pglqwpw\nhgFW8nMzMSzz5SJkpxskau8OIMXuu6gdsyz0LY7059kvYPZz9yUOyOqgAGlCce1A\nN1e11mulpjnEprQ+sf8C9m2gfg9evjfRzGBKSRa/NZU8lxg7ycJWGy6CUOH+k6s/\ntFlffJpuDlK35ZSzYETYntB6dWBUHE5LycyqFNhC3JGqZIR+ClDJfAjp6HZG+Nzi\nJqtWqjUb\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\nU9Bw1+h6Zp2AgNn8\n-----END CERTIFICATE-----\n", + "clientid": "b98b4637-b668-4264-a75e-b4ee1bdb120d", + "gatewayUrl": "https://eu12.access.sapdas-dev.cloud.sap", + "integrationDependencies": [ + { + "ordId": "sap.btpn8n:apiResource:ManagedN8nMcpServer:v1", + "globalTenantId": "704324ad-0d51-4ad0-b118-4bf1c18b7629" + }, + { + "ordId": "sap.s4:apiResource:API_PRODUCT_0002_MCP:v1", + "globalTenantId": "731473562" + } + ], + "privateKey": "-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----\n", + "tokenServiceUrl": "https://ahjf4wqjs.accounts400.ondemand.com/oauth2/token", + "uri": "https://ahjf4wqjs.accounts400.ondemand.com" +} \ No newline at end of file diff --git a/src/sap_cloud_sdk/agentgateway/converters.py b/src/sap_cloud_sdk/agentgateway/converters.py index 180194ff..80f3faf0 100644 --- a/src/sap_cloud_sdk/agentgateway/converters.py +++ b/src/sap_cloud_sdk/agentgateway/converters.py @@ -79,12 +79,25 @@ async def run(**kwargs) -> str: **resolved, ) + _JSON_TYPE_MAP: dict[str, type] = { + "string": str, + "integer": int, + "number": float, + "boolean": bool, + "array": list, + "object": dict, + } + # 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 + k: ( + (_JSON_TYPE_MAP.get(v.get("type", ""), Any), ...) + if k in required + else (_JSON_TYPE_MAP.get(v.get("type", ""), Any) | None, Field(default=None)) + ) + for k, v in properties.items() } args_schema = create_model(f"{mcp_tool.name}_args", **fields) if fields else None 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..d80ecf66 100644 --- a/tests/agentgateway/unit/test_converters.py +++ b/tests/agentgateway/unit/test_converters.py @@ -89,6 +89,103 @@ 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) -> object: + 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__ + + class TestMcpToolToLangchainInvocation: """End-to-end invocation tests: verify what actually reaches call_tool.""" From 91c9c15ac2bd06f2464757425275cd325ea9a38f Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Mon, 29 Jun 2026 10:21:01 -0300 Subject: [PATCH 2/4] fix quality check --- src/sap_cloud_sdk/agentgateway/converters.py | 23 +++++++++++--------- tests/agentgateway/unit/test_converters.py | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/converters.py b/src/sap_cloud_sdk/agentgateway/converters.py index 80f3faf0..42f1e466 100644 --- a/src/sap_cloud_sdk/agentgateway/converters.py +++ b/src/sap_cloud_sdk/agentgateway/converters.py @@ -16,6 +16,15 @@ 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 mcp_tool_to_langchain( mcp_tool: MCPTool, @@ -79,15 +88,6 @@ async def run(**kwargs) -> str: **resolved, ) - _JSON_TYPE_MAP: dict[str, type] = { - "string": str, - "integer": int, - "number": float, - "boolean": bool, - "array": list, - "object": dict, - } - # Build args schema from input_schema properties = mcp_tool.input_schema.get("properties", {}) required = set(mcp_tool.input_schema.get("required", [])) @@ -95,7 +95,10 @@ async def run(**kwargs) -> str: k: ( (_JSON_TYPE_MAP.get(v.get("type", ""), Any), ...) if k in required - else (_JSON_TYPE_MAP.get(v.get("type", ""), Any) | None, Field(default=None)) + else ( + _JSON_TYPE_MAP.get(v.get("type", ""), Any) | None, + Field(default=None), + ) ) for k, v in properties.items() } diff --git a/tests/agentgateway/unit/test_converters.py b/tests/agentgateway/unit/test_converters.py index d80ecf66..fa2ebd92 100644 --- a/tests/agentgateway/unit/test_converters.py +++ b/tests/agentgateway/unit/test_converters.py @@ -92,7 +92,7 @@ def test_input_schema_without_properties_key(self): 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) -> object: + def _tool_with_types(self, properties: dict, required: list[str] | None = None) -> MCPTool: return MCPTool( name="typed_tool", server_name="server", From 722a4feef3a501c45847f2588b4bbf4092b78761 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Tue, 30 Jun 2026 13:16:54 -0300 Subject: [PATCH 3/4] fix: support list of types --- src/sap_cloud_sdk/agentgateway/converters.py | 32 +++++++++----- tests/agentgateway/unit/test_converters.py | 46 ++++++++++++++++++++ 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/converters.py b/src/sap_cloud_sdk/agentgateway/converters.py index 42f1e466..18788c60 100644 --- a/src/sap_cloud_sdk/agentgateway/converters.py +++ b/src/sap_cloud_sdk/agentgateway/converters.py @@ -26,6 +26,19 @@ } +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, call_tool: Callable, @@ -91,17 +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: ( - (_JSON_TYPE_MAP.get(v.get("type", ""), Any), ...) - if k in required - else ( - _JSON_TYPE_MAP.get(v.get("type", ""), Any) | None, - Field(default=None), - ) - ) - for k, v in properties.items() - } + 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/tests/agentgateway/unit/test_converters.py b/tests/agentgateway/unit/test_converters.py index fa2ebd92..d6dce001 100644 --- a/tests/agentgateway/unit/test_converters.py +++ b/tests/agentgateway/unit/test_converters.py @@ -185,6 +185,52 @@ def test_optional_non_string_field_is_nullable(self): 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.""" From c3b65beb11b9567fd645287e6060d4580c5cfe59 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Fri, 3 Jul 2026 17:46:37 -0300 Subject: [PATCH 4/4] bump version --- app/test_mcp_tools_with_number_arg.py | 270 -------------------------- local/agw_credentials.json | 19 -- pyproject.toml | 2 +- 3 files changed, 1 insertion(+), 290 deletions(-) delete mode 100644 app/test_mcp_tools_with_number_arg.py delete mode 100644 local/agw_credentials.json diff --git a/app/test_mcp_tools_with_number_arg.py b/app/test_mcp_tools_with_number_arg.py deleted file mode 100644 index 2223d4e1..00000000 --- a/app/test_mcp_tools_with_number_arg.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Integration test for PR #191: mcp_tool_to_langchain must preserve native number types. - -Steps: -1. Create AGW client using constants defined at the top of this file -2. Call list_mcp_tools against the live AGW tenant -3. Convert the getUserProfile tool (or any tool with a numeric arg) with mcp_tool_to_langchain -4. Run the converted tool and verify call_mcp_tool receives native int/float (not a string) - -For customer flow: set AGW_CREDENTIALS_PATH env var to your local credentials file. -For LoB flow: set TENANT_SUBDOMAIN at the top of this file. - -Real MCP tools in this tenant use JSON Schema array-type notation: - {"type": ["integer", "null"]} — nullable integer - {"type": "integer"} — required integer - -The converter must handle both forms. This test exposes the behaviour with the actual -tool definitions returned by list_mcp_tools so regressions are caught end-to-end. - -Run: - uv run python app/test_mcp_tools_with_number_arg.py - AGW_CREDENTIALS_PATH=local/agw_credentials.json uv run python app/test_mcp_tools_with_number_arg.py -""" - -import asyncio -import os -from typing import Any - -from sap_cloud_sdk.agentgateway import create_client -from sap_cloud_sdk.agentgateway._models import MCPTool -from sap_cloud_sdk.agentgateway.converters import mcp_tool_to_langchain - -# --- Local configuration --- -TENANT_SUBDOMAIN = "my-tenant" # set your tenant subdomain here -USER_TOKEN: str | None = None # set a user JWT here to enable principal propagation -# --------------------------- - - -def _extract_scalar_type(json_type_value: Any) -> str | None: - """Return the first non-null scalar type from a JSON Schema type field. - - Handles both plain string ("integer") and array form (["integer", "null"]). - Returns None if no numeric/boolean type is found. - """ - if isinstance(json_type_value, str): - return json_type_value - if isinstance(json_type_value, list): - for t in json_type_value: - if t != "null": - return t - return None - - -_NUMERIC_JSON_TYPES: set[str] = {"integer", "number"} -_JSON_TYPE_TO_PYTHON: dict[str, type] = {"integer": int, "number": float} -_JSON_DEFAULTS: dict[str, object] = { - "integer": 5, - "number": 1.5, - "boolean": True, - "string": "test", - "array": [], - "object": {}, -} - - -async def run() -> None: - # Step 1 — configure AGW client - tenant_subdomain = TENANT_SUBDOMAIN - user_token = USER_TOKEN - - landscape = os.environ.get("CLOUD_SDK_CFG_AGW_DEFAULT_LANDSCAPE") - if landscape: - os.environ.setdefault("APPFND_CONHOS_LANDSCAPE", landscape) - - # Step 2 — list_mcp_tools - agw_client = create_client(tenant_subdomain=tenant_subdomain) - tools: list[MCPTool] = await agw_client.list_mcp_tools(user_token=user_token) - print(f"Found {len(tools)} MCP tools") - for t in tools: - print(f" - {t.name}") - - # Find a tool with at least one integer or number property (plain or array-type form) - numeric_tool: MCPTool | None = None - numeric_field: str | None = None - expected_python_type: type | None = None - is_nullable: bool = False - - for t in tools: - for field, schema in t.input_schema.get("properties", {}).items(): - raw_type = schema.get("type") - scalar = _extract_scalar_type(raw_type) - if scalar in _NUMERIC_JSON_TYPES: - numeric_tool = t - numeric_field = field - expected_python_type = _JSON_TYPE_TO_PYTHON[scalar] - is_nullable = isinstance(raw_type, list) and "null" in raw_type - break - if numeric_tool: - break - - if not numeric_tool: - print("\nNo MCP tool with an integer/number arg found in this tenant.") - print("Running schema verification with a synthetic tool instead...") - _verify_synthetic() - return - - nullable_str = " | None" if is_nullable else "" - print( - f"\nUsing tool '{numeric_tool.name}', field '{numeric_field}'" - f" (JSON Schema type → Python: {expected_python_type.__name__}{nullable_str})" - ) - - # Step 3 — mcp_tool_to_langchain - captured: dict = {} - original_call = agw_client.call_mcp_tool - - async def spy(tool, *, user_token, **kwargs): - captured.update(kwargs) - return await original_call(tool, user_token=user_token, **kwargs) - - lc_tool = mcp_tool_to_langchain(numeric_tool, spy, lambda: user_token) - - # Verify the Pydantic schema annotation - field_info = lc_tool.args_schema.model_fields[numeric_field] - annotation = field_info.annotation - print(f"Pydantic annotation for '{numeric_field}': {annotation!r}") - - if is_nullable: - import types as _types - assert isinstance(annotation, _types.UnionType), ( - f"Expected UnionType for nullable '{numeric_field}', got {annotation!r}\n" - f"NOTE: The converter does not yet handle array-form JSON Schema types " - f"like {{\"type\": [\"integer\", \"null\"]}}. This is a known gap." - ) - assert expected_python_type in annotation.__args__, ( - f"Expected {expected_python_type} in union args, got {annotation.__args__}" - ) - print(f"Schema check passed — '{numeric_field}' annotated as {annotation}") - else: - assert annotation is expected_python_type, ( - f"Schema type wrong for '{numeric_field}': " - f"expected {expected_python_type}, got {annotation!r}" - ) - print(f"Schema check passed — '{numeric_field}' annotated as {expected_python_type.__name__}") - - # Build invoke args: supply the numeric field with a sample value - required = set(numeric_tool.input_schema.get("required", [])) - invoke_args: dict = {} - for field, schema in numeric_tool.input_schema.get("properties", {}).items(): - if field in required: - scalar = _extract_scalar_type(schema.get("type", "string")) or "string" - invoke_args[field] = _JSON_DEFAULTS.get(scalar, "test") - - # Also pass the numeric field if it's optional (so it's forwarded through the spy) - if numeric_field not in invoke_args: - invoke_args[numeric_field] = _JSON_DEFAULTS[ - _extract_scalar_type( - numeric_tool.input_schema["properties"][numeric_field].get("type") - ) or "integer" - ] - - print(f"Invoking with args: {invoke_args}") - - # Step 4 — run - try: - result = await lc_tool.arun(invoke_args) - print(f"Tool result: {result!r}") - except Exception as e: - print(f"Tool invocation raised (acceptable for type check): {e}") - - # Verify the numeric field reached call_mcp_tool as a native type - if numeric_field in captured: - forwarded = captured[numeric_field] - assert isinstance(forwarded, expected_python_type), ( - f"TYPE REGRESSION: '{numeric_field}' was forwarded as " - f"{type(forwarded).__name__!r} (value={forwarded!r}) " - f"instead of {expected_python_type.__name__!r}.\n" - f"Before PR #191 fix, str was always used regardless of JSON Schema type." - ) - print( - f"Type forwarding check passed — '{numeric_field}'={forwarded!r} " - f"reached call_mcp_tool as {type(forwarded).__name__}" - ) - else: - print( - f"'{numeric_field}' was omitted by the converter (omit_none=True and value was None). " - f"Supply a non-None value in invoke_args to verify type forwarding." - ) - - print("\nAll checks passed.") - - -def _verify_synthetic() -> None: - """Verify type mapping with plain-string and array-type JSON Schema forms.""" - from unittest.mock import AsyncMock - - # Plain string form: {"type": "integer"} - tool_plain = MCPTool( - name="plain_types", - server_name="server", - description="Tool with plain-string JSON Schema types", - input_schema={ - "type": "object", - "required": ["limit", "threshold"], - "properties": { - "limit": {"type": "integer"}, - "threshold": {"type": "number"}, - }, - }, - url="https://example.com/mcp", - ) - lc = mcp_tool_to_langchain(tool_plain, AsyncMock(), lambda: "token") - fields = lc.args_schema.model_fields - assert fields["limit"].annotation is int, ( - f"Expected int for 'limit', got {fields['limit'].annotation!r}" - ) - assert fields["threshold"].annotation is float, ( - f"Expected float for 'threshold', got {fields['threshold'].annotation!r}" - ) - print("Plain-string form: limit→int, threshold→float — PASSED") - - # Array-type form: {"type": ["integer", "null"]} — as used by the real getUserProfile tool. - # The current converter crashes with TypeError (list is unhashable as dict key) on this form. - # Once the converter handles array-type notation, salary should be int | None. - tool_array = MCPTool( - name="array_types", - server_name="server", - description="Tool with array-form JSON Schema types (real AGW pattern)", - input_schema={ - "type": "object", - "properties": { - "userId": {"type": "integer", "format": "uint8"}, - "salary": {"type": ["integer", "null"], "format": "uint8"}, - }, - }, - url="https://example.com/mcp", - ) - try: - lc2 = mcp_tool_to_langchain(tool_array, AsyncMock(), lambda: "token") - fields2 = lc2.args_schema.model_fields - user_id_annotation = fields2["userId"].annotation - salary_annotation = fields2["salary"].annotation - print(f"Array-type form: userId annotation={user_id_annotation!r}") - print(f"Array-type form: salary annotation={salary_annotation!r}") - - assert user_id_annotation is int, ( - f"Expected int for 'userId', got {user_id_annotation!r}" - ) - import types as _types - if isinstance(salary_annotation, _types.UnionType): - assert int in salary_annotation.__args__, ( - f"Expected int in union for 'salary', got {salary_annotation.__args__}" - ) - print("Array-type nullable integer → int | None — PASSED") - else: - print( - f"Array-type nullable integer → {salary_annotation!r} " - f"(converter does not yet handle array-form types)" - ) - except TypeError as e: - print( - f"Array-type form — converter raised TypeError: {e}\n" - " {\"type\": [\"integer\", \"null\"]} is unhashable as a dict key in _JSON_TYPE_MAP.get().\n" - " This is the exact bug that needs to be fixed." - ) - - print("\nAll checks passed.") - - -if __name__ == "__main__": - asyncio.run(run()) \ No newline at end of file diff --git a/local/agw_credentials.json b/local/agw_credentials.json deleted file mode 100644 index 199dcff9..00000000 --- a/local/agw_credentials.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "authType": "oauth2mtls", - "certificate": "-----BEGIN CERTIFICATE-----\nMIIGYjCCBEqgAwIBAgIRAOPOKIXZqIXGnls35hhCSDswDQYJKoZIhvcNAQELBQAw\ngYAxCzAJBgNVBAYTAkRFMRQwEgYDVQQHDAtFVTEwLUNhbmFyeTEPMA0GA1UECgwG\nU0FQIFNFMSMwIQYDVQQLDBpTQVAgQ2xvdWQgUGxhdGZvcm0gQ2xpZW50czElMCMG\nA1UEAwwcU0FQIENsb3VkIFBsYXRmb3JtIENsaWVudCBDQTAeFw0yNjA2MjkxNjMw\nNTVaFw0yNzA2MjkxNzMwNTVaMIHHMQswCQYDVQQGEwJERTEPMA0GA1UEChMGU0FQ\nIFNFMSMwIQYDVQQLExpTQVAgQ2xvdWQgUGxhdGZvcm0gQ2xpZW50czEPMA0GA1UE\nCxMGQ2FuYXJ5MS0wKwYDVQQLEyQ5NGJkMmE2Zi02NGIwLTQwY2EtYThkMS01YTk2\nNTgwNjMwMGExGzAZBgNVBAcTEkNBRDlkYzZjMjU4YjM1NzdmMjElMCMGA1UEAxMc\nYWd3LWludGVncmF0aW9uLWFnZW50LXNkay0wMTCCAaIwDQYJKoZIhvcNAQEBBQAD\nggGPADCCAYoCggGBAMrAjlhrJ25ePN5Ll5QgBwE79Rsw8W2fnEsgb24Tj1TnCsUZ\nWOKjYaGeRPqNuMCdHSFpcpEp3r39303qax40CJyPvh2Dm6DFjBV1l2Mf5Lge87P8\nGG4lAFo6jN1uZ3uUXtm5614SBP1IMxNaGeDoynOEQH/SxgLg8/vRHwyMMtwvo781\ngnJo1mSO5waN2AycFGJDd/wF59PDDPHsb22bgHROVexb1yhdQbq2giKqZt1QloIT\nbbxW30UI9rWeHwv7i9QuUzOIDtfDjHbOXMptGtEyq6vsBlZgc6w6MTBX2AqT0Dsy\n7JnVMnymPg/cnxDdhvW0kr4StAP4Y32TEzUGx3tCYqKvzrhHk7zkimoxvYx4TTTy\nQrsaeI87wzNTezsOpI/XE0kiNblsp9TgiFDQPoI2WTKqD5JA4F/tHg2eu+PWM4mg\n3npxD0rIqCbpXF8X28ZFQ67Abfnu9TTZnhsaLdjZsHPLVQo5fYJsbTebZPTmKqzU\nz19VvbE72Y+PTsXiGQIDAQABo4IBDDCCAQgwCQYDVR0TBAIwADAfBgNVHSMEGDAW\ngBRHvNcrpBpe6F8FhtMvQ2+KlE7ozTAdBgNVHQ4EFgQULZWULaZwP8Ov5zpRQQfD\nAHdeVBowDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMCMIGVBgNV\nHR8EgY0wgYowgYeggYSggYGGf2h0dHA6Ly9zYXAtY2xvdWQtcGxhdGZvcm0tY2xp\nZW50LWNhLWV1MTAtY2FuYXJ5LWNybHMuczMuZXUtY2VudHJhbC0xLmFtYXpvbmF3\ncy5jb20vY3JsL2QzY2FjNjcyLTkzZjItNDFmMC1hMjU2LTUxZDBjODEyZmViMi5j\ncmwwDQYJKoZIhvcNAQELBQADggIBAAYs8y9mzXyXIjEPFxXwD0jmJOBEARLWg4eC\n+SkW6322bXDP8cvCcCPpTsotaR+uycZwRneeTLRU4dt0UVbSd84EkJ/s/z6YziOn\nXZxbjkehFDfuPCM1WFKq8obmTabl/X8gPIUkIMY7zevOuixwEtQcOjmZ16mNTHoQ\nUlnzgpKsIdikYYJuGz7XZSHicq61bEh9TCwIy5Tf2c6CAR20xdc1KBlWMjcfSU/d\ngkXthqFkGJFT9PNeOe6ySMKBP5daBl3q0A5oTBxiJETK7rCIrtd5Cg414juWoSui\nsztZwxKzeIY//nH7WLEuC2A/mGv2aqH8leEqnxGKtNYt1MzLZmlaegrh/1LyMtyQ\nqJuY0hxFZG+zVT3qjL7I2SsTgU1934Bk9ywnOa5Dnid0M0jNGKJxsZ6ET2+LuJa8\nqDuGOphS4h1hSGF70wxD3+Wp9gFtC6abNaLX602ygOcHyR0RYjUcc24S0pglqwpw\nhgFW8nMzMSzz5SJkpxskau8OIMXuu6gdsyz0LY7059kvYPZz9yUOyOqgAGlCce1A\nN1e11mulpjnEprQ+sf8C9m2gfg9evjfRzGBKSRa/NZU8lxg7ycJWGy6CUOH+k6s/\ntFlffJpuDlK35ZSzYETYntB6dWBUHE5LycyqFNhC3JGqZIR+ClDJfAjp6HZG+Nzi\nJqtWqjUb\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n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\nU9Bw1+h6Zp2AgNn8\n-----END CERTIFICATE-----\n", - "clientid": "b98b4637-b668-4264-a75e-b4ee1bdb120d", - "gatewayUrl": "https://eu12.access.sapdas-dev.cloud.sap", - "integrationDependencies": [ - { - "ordId": "sap.btpn8n:apiResource:ManagedN8nMcpServer:v1", - "globalTenantId": "704324ad-0d51-4ad0-b118-4bf1c18b7629" - }, - { - "ordId": "sap.s4:apiResource:API_PRODUCT_0002_MCP:v1", - "globalTenantId": "731473562" - } - ], - "privateKey": "-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----\n", - "tokenServiceUrl": "https://ahjf4wqjs.accounts400.ondemand.com/oauth2/token", - "uri": "https://ahjf4wqjs.accounts400.ondemand.com" -} \ No newline at end of file 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"