Skip to content

Commit 83b44fc

Browse files
committed
fix: tolerate non-conforming apps in get_application
Completes the #48 fix. get_application went through the typed client.get_application, which validates the record into a strict SDK model — so an App Catalog SAML app with a partial settings.signOn (or a custom SWA whose name is outside the template enum) raised and the call failed outright, as reported in #48. Fetches the record via the request executor (GET /api/v1/apps/{id}) and parses it through _safe_parse_app, returning the raw dict with a warning marker when strict deserialization fails — same approach as list_applications and list_group_apps. Refs #48
1 parent c26149f commit 83b44fc

2 files changed

Lines changed: 127 additions & 5 deletions

File tree

src/okta_mcp_server/tools/applications/applications.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,14 +239,34 @@ async def get_application(ctx: Context, app_id: str, expand: Optional[str] = Non
239239
if expand:
240240
query_params["expand"] = expand
241241

242-
app, _, err = await client.get_application(app_id, **query_params)
242+
# The typed client.get_application validates the record into a strict SDK
243+
# model, so an App Catalog SAML app with a partial settings.signOn (or a
244+
# custom SWA whose name is outside the template enum) raises and the call
245+
# fails outright. Fetch the raw record through the request executor and
246+
# parse it via _safe_parse_app, falling back to the raw dict on failure.
247+
executor = client.get_request_executor()
248+
query_string = urlencode(
249+
{_camel_case_param(k): v for k, v in query_params.items()}
250+
)
251+
url = f"/api/v1/apps/{app_id}" + (f"?{query_string}" if query_string else "")
252+
request, request_err = await executor.create_request(
253+
method="GET", url=url, body={}, headers={}, oauth=False
254+
)
255+
if request_err:
256+
logger.error(f"Okta API error while getting application {app_id}: {request_err}")
257+
return {"error": str(request_err)}
243258

244-
if err:
245-
logger.error(f"Okta API error while getting application {app_id}: {err}")
246-
return {"error": str(err)}
259+
_, response_body, response_err = await executor.execute(request)
260+
if response_err:
261+
logger.error(f"Okta API error while getting application {app_id}: {response_err}")
262+
return {"error": str(response_err)}
263+
264+
item = json.loads(response_body) if response_body else None
265+
if item is None:
266+
return {"error": f"Application {app_id} not found"}
247267

248268
logger.info(f"Successfully retrieved application: {app_id}")
249-
return app
269+
return _safe_parse_app(item)
250270
except Exception as e:
251271
logger.error(f"Exception while getting application {app_id}: {type(e).__name__}: {e}")
252272
return {"error": str(e)}

tests/test_get_application.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# The Okta software accompanied by this notice is provided pursuant to the following terms:
2+
# Copyright © 2026-Present, Okta, Inc.
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
5+
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
6+
# See the License for the specific language governing permissions and limitations under the License.
7+
8+
"""Tests for get_application — resilient parsing of a single app record (#48)."""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
from unittest.mock import AsyncMock, MagicMock, patch
14+
15+
import okta.models as okta_models
16+
import pytest
17+
18+
from okta_mcp_server.tools.applications.applications import get_application
19+
20+
21+
# A bookmark app parses cleanly into a typed model.
22+
GOOD_BOOKMARK = {
23+
"id": "0oaBOOKMARK001",
24+
"label": "Bookmark App",
25+
"name": "bookmark",
26+
"signOnMode": "BOOKMARK",
27+
"settings": {"app": {"url": "https://example.com"}},
28+
}
29+
30+
# An App Catalog SAML app with a partial settings.signOn fails strict SDK
31+
# validation (the model marks ~15 signOn fields required) — the record that
32+
# previously made get_application fail outright (#48).
33+
BAD_SPARSE_SAML = {
34+
"id": "0oaSAML0001",
35+
"label": "Sparse SAML",
36+
"name": "sparsesaml",
37+
"signOnMode": "SAML_2_0",
38+
"settings": {"signOn": {"defaultRelayState": ""}},
39+
}
40+
41+
42+
def _make_ctx():
43+
manager = MagicMock()
44+
ctx = MagicMock()
45+
ctx.request_context.lifespan_context.okta_auth_manager = manager
46+
return ctx
47+
48+
49+
def _client_returning(body, execute_error=None):
50+
"""Build a fake Okta client whose request executor returns the given record."""
51+
executor = MagicMock()
52+
executor.create_request = AsyncMock(return_value=({"method": "GET"}, None))
53+
if execute_error is not None:
54+
executor.execute = AsyncMock(return_value=(None, None, execute_error))
55+
else:
56+
executor.execute = AsyncMock(return_value=(MagicMock(), body, None))
57+
client = MagicMock()
58+
client.get_request_executor = MagicMock(return_value=executor)
59+
return client
60+
61+
62+
class TestGetApplicationResilientParsing:
63+
@pytest.mark.asyncio
64+
@patch("okta_mcp_server.tools.applications.applications.get_okta_client")
65+
async def test_good_app_returns_typed_model(self, mock_get_client):
66+
mock_get_client.return_value = _client_returning(json.dumps(GOOD_BOOKMARK))
67+
68+
result = await get_application(_make_ctx(), "0oaBOOKMARK001")
69+
70+
assert isinstance(result, okta_models.BookmarkApplication)
71+
72+
@pytest.mark.asyncio
73+
@patch("okta_mcp_server.tools.applications.applications.get_okta_client")
74+
async def test_non_conforming_app_falls_back_to_raw_dict(self, mock_get_client):
75+
mock_get_client.return_value = _client_returning(json.dumps(BAD_SPARSE_SAML))
76+
77+
result = await get_application(_make_ctx(), "0oaSAML0001")
78+
79+
assert isinstance(result, dict)
80+
assert result["id"] == "0oaSAML0001"
81+
assert "_deserialization_warning" in result
82+
83+
@pytest.mark.asyncio
84+
@patch("okta_mcp_server.tools.applications.applications.get_okta_client")
85+
async def test_executor_error_is_returned(self, mock_get_client):
86+
mock_get_client.return_value = _client_returning(None, execute_error="Error: 404 not found")
87+
88+
result = await get_application(_make_ctx(), "0oaMISSING")
89+
90+
assert result == {"error": "Error: 404 not found"}
91+
92+
@pytest.mark.asyncio
93+
@patch("okta_mcp_server.tools.applications.applications.get_okta_client")
94+
async def test_expand_is_sent_as_query_param(self, mock_get_client):
95+
client = _client_returning(json.dumps(GOOD_BOOKMARK))
96+
mock_get_client.return_value = client
97+
98+
await get_application(_make_ctx(), "0oaBOOKMARK001", expand="user/abc")
99+
100+
url = client.get_request_executor.return_value.create_request.call_args.kwargs["url"]
101+
assert "/api/v1/apps/0oaBOOKMARK001" in url
102+
assert "expand=user" in url

0 commit comments

Comments
 (0)