Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/okta_mcp_server/tools/applications/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,18 @@ def _build_application_model(app_config: Dict[str, Any]) -> Any:
The SDK v3 requires typed model objects, not plain dicts. Without this,
subclass-specific fields like `name`, `settings`, and `visibility` are
silently dropped by the base Application model, causing API validation errors.

We deserialize via the model's ``from_dict`` rather than the ``Model(**dict)``
constructor because nested ``anyOf`` unions — notably SAML
``settings.signOn.attributeStatements`` (``SamlAttributeStatement``) — are only
bound to their concrete member type by ``from_dict``. The plain constructor
leaves the union's ``actual_instance`` as ``None``, so every attribute
statement serializes to ``null`` and never reaches Okta.
"""
sign_on_mode = app_config.get("signOnMode") or app_config.get("sign_on_mode", "")
model_cls = _SIGN_ON_MODE_MODEL_MAP.get(str(sign_on_mode).upper(), okta_models.Application)
logger.debug(f"Using model class '{model_cls.__name__}' for signOnMode '{sign_on_mode}'")
return model_cls(**app_config)
return model_cls.from_dict(app_config)


from okta_mcp_server.utils.client import get_okta_client
Expand Down
78 changes: 78 additions & 0 deletions tests/test_application_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# The Okta software accompanied by this notice is provided pursuant to the following terms:
# Copyright © 2026-Present, Okta, Inc.
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
# 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.
# See the License for the specific language governing permissions and limitations under the License.

"""Tests for _build_application_model — nested model fields must survive serialization."""

from __future__ import annotations

import okta.models as okta_models

from okta_mcp_server.tools.applications.applications import _build_application_model


def _saml_config_with_attributes():
return {
"label": "Attr Test SAML",
"name": "attrtestsaml",
"signOnMode": "SAML_2_0",
"settings": {
"signOn": {
"ssoAcsUrl": "https://sp.example.com/acs",
"audience": "sp-entity-id",
"recipient": "https://sp.example.com/acs",
"destination": "https://sp.example.com/acs",
"subjectNameIdFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
"subjectNameIdTemplate": "${user.userName}",
"allowMultipleAcsEndpoints": False,
"assertionSigned": True,
"authnContextClassRef": "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport",
"digestAlgorithm": "SHA256",
"honorForceAuthn": True,
"idpIssuer": "http://www.okta.com/${org.externalKey}",
"requestCompressed": False,
"responseSigned": True,
"signatureAlgorithm": "RSA_SHA256",
"attributeStatements": [
{
"type": "EXPRESSION",
"name": "email",
"namespace": "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
"values": ["user.email"],
},
{
"type": "EXPRESSION",
"name": "firstName",
"namespace": "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
"values": ["user.firstName"],
},
],
}
},
}


class TestBuildApplicationModel:
def test_saml_attribute_statements_survive_serialization(self):
model = _build_application_model(_saml_config_with_attributes())
assert isinstance(model, okta_models.SamlApplication)

statements = model.to_dict()["settings"]["signOn"]["attributeStatements"]
# Pre-fix (Model(**dict)) left these as [null, null]; the union members
# must now be bound and round-trip with their full content.
assert len(statements) == 2
assert all(s is not None for s in statements)
assert [s["name"] for s in statements] == ["email", "firstName"]
assert statements[0]["type"] == "EXPRESSION"
assert statements[0]["values"] == ["user.email"]

def test_builds_correct_subclass_per_sign_on_mode(self):
bookmark = _build_application_model(
{"label": "B", "name": "bookmark", "signOnMode": "BOOKMARK",
"settings": {"app": {"url": "https://example.com"}}}
)
assert isinstance(bookmark, okta_models.BookmarkApplication)
assert bookmark.settings.app.url == "https://example.com"