diff --git a/pyproject.toml b/pyproject.toml index 6a524dda..1bb95676 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.48.1" +version = "0.48.2" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agentgateway/_compat.py b/src/sap_cloud_sdk/agentgateway/_compat.py new file mode 100644 index 00000000..ab23e832 --- /dev/null +++ b/src/sap_cloud_sdk/agentgateway/_compat.py @@ -0,0 +1,27 @@ +"""Compatibility shims for reading MCP result objects across mcp 1.x and 2.x.""" + +from typing import Any + + +def mcp_server_name(init_result: Any) -> str | None: + """Return ``serverInfo.name`` / ``server_info.name`` if present, else None.""" + info = getattr(init_result, "server_info", None) + if info is None: + info = getattr(init_result, "serverInfo", None) + return getattr(info, "name", None) if info is not None else None + + +def mcp_input_schema(tool: Any) -> dict[str, Any]: + """Return the tool's input schema across mcp 1.x/2.x, defaulting to {}.""" + schema = getattr(tool, "input_schema", None) + if schema is None: + schema = getattr(tool, "inputSchema", None) + return schema or {} + + +def mcp_is_error(result: Any) -> bool: + """Return the tool-call error flag across mcp 1.x/2.x, defaulting to False.""" + flag = getattr(result, "is_error", None) + if flag is None: + flag = getattr(result, "isError", None) + return bool(flag) diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index e36f9d0e..676f2384 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -29,6 +29,11 @@ except ImportError: from mcp.shared.exceptions import MCPError as McpError # type: ignore[no-redef] # ty: ignore[unresolved-import] +from sap_cloud_sdk.agentgateway._compat import ( + mcp_input_schema, + mcp_is_error, + mcp_server_name, +) from sap_cloud_sdk.agentgateway._dependencies_resolver import ( EnvironmentDependenciesResolver, IntegrationDependenciesResolver, @@ -637,7 +642,8 @@ async def _list_server_tools( List of MCPTool objects from this server. Raises: - AgentGatewaySDKError: If server does not provide serverInfo.name. + AgentGatewaySDKError: If server does not provide a server name + (serverInfo/server_info). """ async with httpx.AsyncClient( headers={ @@ -654,17 +660,13 @@ async def _list_server_tools( async with ClientSession(read, write) as session: init_result = await session.initialize() - if not ( - init_result - and init_result.serverInfo - and init_result.serverInfo.name - ): + server_name = mcp_server_name(init_result) + if not server_name: raise AgentGatewaySDKError( - f"MCP server at '{url}' did not provide serverInfo.name. " - "This is required by the MCP protocol." + f"MCP server at '{url}' did not provide its server name " + "(serverInfo/server_info). This is required by the MCP protocol." ) - server_name = init_result.serverInfo.name result = await session.list_tools() return [ @@ -672,7 +674,7 @@ async def _list_server_tools( name=t.name, server_name=server_name, description=t.description or "", - input_schema=t.inputSchema or {}, + input_schema=mcp_input_schema(t), url=url, ) for t in result.tools @@ -837,7 +839,7 @@ async def call_mcp_tool_customer( first = result.content[0] text = str(getattr(first, "text", "")) - if result.isError: + if mcp_is_error(result): logger.error( "Tool '%s' on '%s' returned an error: %s", tool.name, diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index d5ace2fc..fd9d352a 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -33,6 +33,11 @@ list_mcp_fragments, list_a2a_fragments, ) +from sap_cloud_sdk.agentgateway._compat import ( + mcp_input_schema, + mcp_is_error, + mcp_server_name, +) from sap_cloud_sdk.agentgateway._models import ( JsonRpcError, Agent, @@ -379,20 +384,14 @@ async def list_server_tools( ): async with ClientSession(read, write) as session: init_result = await session.initialize() - server_name = ( - init_result.serverInfo.name - if init_result - and init_result.serverInfo - and init_result.serverInfo.name - else fragment_name - ) + server_name = mcp_server_name(init_result) or fragment_name result = await session.list_tools() return [ MCPTool( name=t.name, server_name=server_name, description=t.description or "", - input_schema=t.inputSchema or {}, + input_schema=mcp_input_schema(t), url=dest_url, fragment_name=fragment_name, ) @@ -520,7 +519,7 @@ async def call_mcp_tool_lob( first = result.content[0] text = str(getattr(first, "text", "")) - if result.isError: + if mcp_is_error(result): logger.error( "Tool '%s' on '%s' returned an error: %s", tool.name, diff --git a/tests/agentgateway/unit/test_compat.py b/tests/agentgateway/unit/test_compat.py new file mode 100644 index 00000000..aa3830b8 --- /dev/null +++ b/tests/agentgateway/unit/test_compat.py @@ -0,0 +1,89 @@ +"""Unit tests for the mcp 1.x/2.x compatibility shims.""" + +from types import SimpleNamespace + +from sap_cloud_sdk.agentgateway._compat import ( + mcp_input_schema, + mcp_is_error, + mcp_server_name, +) + + +class TestMcpServerName: + """Tests for mcp_server_name across both mcp majors.""" + + def test_reads_snake_case_server_info_mcp_2x(self): + """mcp 2.x exposes ``server_info`` (snake_case).""" + init = SimpleNamespace(server_info=SimpleNamespace(name="srv-2x")) + assert mcp_server_name(init) == "srv-2x" + + def test_reads_camel_case_server_info_mcp_1x(self): + """mcp 1.x exposes ``serverInfo`` (camelCase).""" + init = SimpleNamespace(serverInfo=SimpleNamespace(name="srv-1x")) + assert mcp_server_name(init) == "srv-1x" + + def test_returns_none_when_server_info_missing(self): + """No server info field on either name -> None.""" + assert mcp_server_name(SimpleNamespace()) is None + + def test_returns_none_when_name_missing(self): + """server_info present but without a ``name`` -> None.""" + init = SimpleNamespace(server_info=SimpleNamespace()) + assert mcp_server_name(init) is None + + def test_returns_none_when_init_result_is_none(self): + """A falsy init_result must not raise -> None.""" + assert mcp_server_name(None) is None + + def test_works_against_real_installed_mcp_types(self): + """Prove it works against the actually-installed mcp library.""" + from mcp.types import Implementation, InitializeResult + + init = InitializeResult( + protocolVersion="2025-06-18", + capabilities={}, + serverInfo=Implementation(name="real-srv", version="1.0.0"), + ) + assert mcp_server_name(init) == "real-srv" + + +class TestMcpInputSchema: + """Tests for mcp_input_schema across both mcp majors.""" + + def test_reads_snake_case_input_schema_mcp_2x(self): + """mcp 2.x exposes ``input_schema`` (snake_case).""" + tool = SimpleNamespace(input_schema={"type": "object"}) + assert mcp_input_schema(tool) == {"type": "object"} + + def test_reads_camel_case_input_schema_mcp_1x(self): + """mcp 1.x exposes ``inputSchema`` (camelCase).""" + tool = SimpleNamespace(inputSchema={"type": "string"}) + assert mcp_input_schema(tool) == {"type": "string"} + + def test_defaults_to_empty_dict_when_missing(self): + """No schema field on either name -> {}.""" + assert mcp_input_schema(SimpleNamespace()) == {} + + def test_defaults_to_empty_dict_when_none(self): + """Schema explicitly None -> {}.""" + assert mcp_input_schema(SimpleNamespace(input_schema=None)) == {} + + +class TestMcpIsError: + """Tests for mcp_is_error across both mcp majors.""" + + def test_reads_snake_case_is_error_mcp_2x(self): + """mcp 2.x exposes ``is_error`` (snake_case).""" + assert mcp_is_error(SimpleNamespace(is_error=True)) is True + + def test_reads_camel_case_is_error_mcp_1x(self): + """mcp 1.x exposes ``isError`` (camelCase).""" + assert mcp_is_error(SimpleNamespace(isError=True)) is True + + def test_false_when_flag_false(self): + """An explicit False flag stays False.""" + assert mcp_is_error(SimpleNamespace(is_error=False)) is False + + def test_defaults_to_false_when_missing(self): + """No error field on either name -> False.""" + assert mcp_is_error(SimpleNamespace()) is False diff --git a/uv.lock b/uv.lock index 1a81d39b..c6a54ef2 100644 --- a/uv.lock +++ b/uv.lock @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.48.1" +version = "0.48.2" source = { editable = "." } dependencies = [ { name = "cryptography" },