From 9482bd008c45445c6fecd53608eff121f6e60c29 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Wed, 12 Aug 2026 15:23:42 -0700 Subject: [PATCH 1/4] fix: authentication error on agent gateway integration --- src/sap_cloud_sdk/agentgateway/_lob.py | 4 +++ src/sap_cloud_sdk/destination/_models.py | 8 ++++-- tests/agentgateway/unit/test_lob.py | 22 +++++++++++++++++ tests/destination/unit/test_models.py | 31 ++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 0c46124c..8b2caeef 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -112,6 +112,10 @@ def _fetch_auth_token( ) auth_token = dest.auth_tokens[0] + if auth_token.error: + raise MCPServerNotFoundError( + f"Auth token error for destination '{dest_name}': {auth_token.error}" + ) header_value = auth_token.http_header.get("value") or "" if not header_value: raise MCPServerNotFoundError(f"Empty auth header for destination '{dest_name}'") diff --git a/src/sap_cloud_sdk/destination/_models.py b/src/sap_cloud_sdk/destination/_models.py index aa69a980..948b89ac 100644 --- a/src/sap_cloud_sdk/destination/_models.py +++ b/src/sap_cloud_sdk/destination/_models.py @@ -413,6 +413,7 @@ class AuthToken: type: Token type (e.g., "Bearer", "Basic") value: Base64 encoded token binary content http_header: Dictionary with 'key' and 'value' for the prepared HTTP header + error: Error message returned by the Destination Service when token retrieval fails refresh_token: Optional base64 encoded refresh token scope: Optional token scopes as space-delimited string """ @@ -420,6 +421,7 @@ class AuthToken: type: str value: str http_header: Dict[str, str] + error: Optional[str] = None refresh_token: Optional[str] = None scope: Optional[str] = None @@ -434,15 +436,16 @@ def from_dict(cls, obj: Dict[str, Any]) -> "AuthToken": AuthToken: Parsed auth token dataclass. Raises: - DestinationOperationError: If required fields are missing. + DestinationOperationError: If required fields are missing and no error is present. """ token_type = obj.get("type") or "" value = obj.get("value") or "" http_header = obj.get("http_header") or {} + error = obj.get("error") or None refresh_token = obj.get("refresh_token") scope = obj.get("scope") - if not token_type or not value or not http_header: + if not error and (not token_type or not value or not http_header): raise DestinationOperationError( "auth token is missing required fields (type/value/http_header)" ) @@ -451,6 +454,7 @@ def from_dict(cls, obj: Dict[str, Any]) -> "AuthToken": type=token_type, value=value, http_header=http_header, + error=error, refresh_token=refresh_token, scope=scope, ) diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index 0f1b15e3..55cf5003 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -89,6 +89,7 @@ def test_fetches_and_decodes_token_and_url(self): header_value = "Bearer my-raw-jwt-token-123" mock_dest = MagicMock() mock_dest.auth_tokens = [MagicMock()] + mock_dest.auth_tokens[0].error = None mock_dest.auth_tokens[0].http_header = {"value": header_value} mock_dest.url = "https://agw.example.com/" @@ -112,6 +113,7 @@ def test_strips_trailing_slashes_from_url(self): header_value = "Bearer token" mock_dest = MagicMock() mock_dest.auth_tokens = [MagicMock()] + mock_dest.auth_tokens[0].error = None mock_dest.auth_tokens[0].http_header = {"value": header_value} mock_dest.url = "https://agw.example.com/v1/mcp///" @@ -149,6 +151,7 @@ def test_raises_when_empty_token_value(self): """Raise MCPServerNotFoundError when http_header value is empty.""" mock_dest = MagicMock() mock_dest.auth_tokens = [MagicMock()] + mock_dest.auth_tokens[0].error = None mock_dest.auth_tokens[0].http_header = {"value": ""} with patch( @@ -159,10 +162,29 @@ def test_raises_when_empty_token_value(self): with pytest.raises(MCPServerNotFoundError, match="Empty auth header"): _fetch_auth_token("dest-name", "tenant-sub") + def test_raises_with_error_field_from_destination(self): + """Raise MCPServerNotFoundError with the error message from the auth token.""" + mock_dest = MagicMock() + mock_dest.auth_tokens = [MagicMock()] + mock_dest.auth_tokens[0].error = "No consumed apis matching provided resource parameter found." + mock_dest.auth_tokens[0].http_header = {"value": ""} + + with patch( + "sap_cloud_sdk.agentgateway._lob.create_destination_client" + ) as mock_client: + mock_client.return_value.get_destination.return_value = mock_dest + + with pytest.raises( + MCPServerNotFoundError, + match="No consumed apis matching provided resource parameter found.", + ): + _fetch_auth_token("dest-name", "tenant-sub") + def test_passes_options_to_destination(self): """Pass consumption options to get_destination.""" mock_dest = MagicMock() mock_dest.auth_tokens = [MagicMock()] + mock_dest.auth_tokens[0].error = None mock_dest.auth_tokens[0].http_header = {"value": "Bearer token"} mock_dest.url = "https://agw.example.com" mock_options = MagicMock() diff --git a/tests/destination/unit/test_models.py b/tests/destination/unit/test_models.py index c72df1c6..84de00e9 100644 --- a/tests/destination/unit/test_models.py +++ b/tests/destination/unit/test_models.py @@ -293,6 +293,37 @@ def test_get_headers_includes_auth_tokens(self): assert dest.get_headers()["Authorization"] == "Bearer eyJ123" +class TestAuthTokenModel: + """Tests for AuthToken dataclass.""" + + def test_from_dict_valid(self): + """Parse a valid auth token dict.""" + token = AuthToken.from_dict({ + "type": "Bearer", + "value": "eyJ123", + "http_header": {"key": "Authorization", "value": "Bearer eyJ123"}, + }) + assert token.type == "Bearer" + assert token.value == "eyJ123" + assert token.error is None + + def test_from_dict_with_error_field(self): + """Parse an auth token dict that contains an error from the Destination Service.""" + token = AuthToken.from_dict({ + "type": "", + "value": "", + "error": "No consumed apis matching provided resource parameter found.", + "expires_in": "0", + }) + assert token.error == "No consumed apis matching provided resource parameter found." + assert token.value == "" + + def test_from_dict_missing_fields_without_error_raises(self): + """Raise DestinationOperationError when required fields are missing and no error is set.""" + with pytest.raises(DestinationOperationError, match="missing required fields"): + AuthToken.from_dict({"type": "", "value": "", "http_header": {}}) + + class TestFragmentModel: """Tests for Fragment dataclass.""" From df7bd0d8a576a3265732fa5a15c1c3f338207f26 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Wed, 12 Aug 2026 15:26:51 -0700 Subject: [PATCH 2/4] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4f7042a1..9baab7b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.43.0" +version = "0.43.2" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" From 581439af1de2f152ed4a7384a95941ef819b165c Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Wed, 12 Aug 2026 15:46:00 -0700 Subject: [PATCH 3/4] show original error on destination --- pyproject.toml | 2 +- src/sap_cloud_sdk/agentgateway/_lob.py | 4 ---- src/sap_cloud_sdk/destination/_models.py | 7 ++++++- tests/agentgateway/unit/test_lob.py | 18 ------------------ tests/destination/unit/test_models.py | 20 +++++++++++--------- 5 files changed, 18 insertions(+), 33 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9baab7b9..05f1d1b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.43.2" +version = "0.43.1" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 8b2caeef..0c46124c 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -112,10 +112,6 @@ def _fetch_auth_token( ) auth_token = dest.auth_tokens[0] - if auth_token.error: - raise MCPServerNotFoundError( - f"Auth token error for destination '{dest_name}': {auth_token.error}" - ) header_value = auth_token.http_header.get("value") or "" if not header_value: raise MCPServerNotFoundError(f"Empty auth header for destination '{dest_name}'") diff --git a/src/sap_cloud_sdk/destination/_models.py b/src/sap_cloud_sdk/destination/_models.py index 948b89ac..146983cf 100644 --- a/src/sap_cloud_sdk/destination/_models.py +++ b/src/sap_cloud_sdk/destination/_models.py @@ -436,7 +436,8 @@ def from_dict(cls, obj: Dict[str, Any]) -> "AuthToken": AuthToken: Parsed auth token dataclass. Raises: - DestinationOperationError: If required fields are missing and no error is present. + DestinationOperationError: If required fields are missing, or if the token + carries an error from the Destination Service. """ token_type = obj.get("type") or "" value = obj.get("value") or "" @@ -449,6 +450,10 @@ def from_dict(cls, obj: Dict[str, Any]) -> "AuthToken": raise DestinationOperationError( "auth token is missing required fields (type/value/http_header)" ) + if error and (not token_type or not value or not http_header): + raise DestinationOperationError( + f"auth token retrieval failed: {error}" + ) return cls( type=token_type, diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index 55cf5003..6d972b8d 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -162,24 +162,6 @@ def test_raises_when_empty_token_value(self): with pytest.raises(MCPServerNotFoundError, match="Empty auth header"): _fetch_auth_token("dest-name", "tenant-sub") - def test_raises_with_error_field_from_destination(self): - """Raise MCPServerNotFoundError with the error message from the auth token.""" - mock_dest = MagicMock() - mock_dest.auth_tokens = [MagicMock()] - mock_dest.auth_tokens[0].error = "No consumed apis matching provided resource parameter found." - mock_dest.auth_tokens[0].http_header = {"value": ""} - - with patch( - "sap_cloud_sdk.agentgateway._lob.create_destination_client" - ) as mock_client: - mock_client.return_value.get_destination.return_value = mock_dest - - with pytest.raises( - MCPServerNotFoundError, - match="No consumed apis matching provided resource parameter found.", - ): - _fetch_auth_token("dest-name", "tenant-sub") - def test_passes_options_to_destination(self): """Pass consumption options to get_destination.""" mock_dest = MagicMock() diff --git a/tests/destination/unit/test_models.py b/tests/destination/unit/test_models.py index 84de00e9..3bb6a385 100644 --- a/tests/destination/unit/test_models.py +++ b/tests/destination/unit/test_models.py @@ -308,15 +308,17 @@ def test_from_dict_valid(self): assert token.error is None def test_from_dict_with_error_field(self): - """Parse an auth token dict that contains an error from the Destination Service.""" - token = AuthToken.from_dict({ - "type": "", - "value": "", - "error": "No consumed apis matching provided resource parameter found.", - "expires_in": "0", - }) - assert token.error == "No consumed apis matching provided resource parameter found." - assert token.value == "" + """Raise DestinationOperationError with the Destination Service error message.""" + with pytest.raises( + DestinationOperationError, + match="No consumed apis matching provided resource parameter found.", + ): + AuthToken.from_dict({ + "type": "", + "value": "", + "error": "No consumed apis matching provided resource parameter found.", + "expires_in": "0", + }) def test_from_dict_missing_fields_without_error_raises(self): """Raise DestinationOperationError when required fields are missing and no error is set.""" From 350906a0a2c012d07e9fae3708c5903b117b989e Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Wed, 12 Aug 2026 15:53:10 -0700 Subject: [PATCH 4/4] fix quality check --- src/sap_cloud_sdk/destination/_models.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/sap_cloud_sdk/destination/_models.py b/src/sap_cloud_sdk/destination/_models.py index 146983cf..8667164c 100644 --- a/src/sap_cloud_sdk/destination/_models.py +++ b/src/sap_cloud_sdk/destination/_models.py @@ -451,9 +451,7 @@ def from_dict(cls, obj: Dict[str, Any]) -> "AuthToken": "auth token is missing required fields (type/value/http_header)" ) if error and (not token_type or not value or not http_header): - raise DestinationOperationError( - f"auth token retrieval failed: {error}" - ) + raise DestinationOperationError(f"auth token retrieval failed: {error}") return cls( type=token_type,