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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ The Okta MCP Server provides the following tools for LLMs to interact with your
| ----------------------------- | ------------------------------------------------- |---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `list_applications` | List all applications in your Okta organization | - `Show me the applications in my Okta org` <br> - `Find applications with 'API' in their name` <br> - `What SSO applications do we have configured?` |
| `get_application` | Get detailed information about a specific app | - `Show me details for the Salesforce application` <br> - `What are the callback URLs for our mobile app?` <br> - `Get the client ID for our web application` |
| `get_app_saml_metadata` | Get a SAML app's IdP metadata (XML, entityID, SSO URL, signing certificate) | - `Get the SAML metadata for our HR app` <br> - `What's the IdP SSO URL and signing cert for this application?` |
| `create_application` | Create a new application | - `Create a new SAML application for our HR system` <br> - `Set up a new API service application` <br> - `Add a mobile app integration` |
| `update_application` | Update an existing application | - `Update the callback URLs for our web app` <br> - `Change the logo for the Salesforce application` <br> - `Modify the SAML settings for our HR system` |
| `delete_application` | Delete an application (prompts for confirmation) | - `Delete the old legacy application` <br> - `Remove the unused test application` <br> - `Clean up deprecated integrations` |
Expand Down Expand Up @@ -658,7 +659,7 @@ The Okta MCP Server uses a **scope-based tool loading** mechanism to ensure that
| `okta.users.manage` | `create_user`, `update_user`, `deactivate_user`, `delete_deactivated_user` |
| `okta.groups.read` | `list_groups`, `get_group`, `list_group_users`, `list_group_apps` |
| `okta.groups.manage` | `create_group`, `update_group`, `delete_group`, `add_user_to_group`, `remove_user_from_group` |
| `okta.apps.read` | `list_applications`, `get_application` |
| `okta.apps.read` | `list_applications`, `get_application`, `get_app_saml_metadata` |
| `okta.apps.manage` | `create_application`, `update_application`, `delete_application`, `activate_application`, `deactivate_application` |
| `okta.policies.read` | `list_policies`, `get_policy`, `list_policy_rules`, `get_policy_rule` |
| `okta.policies.manage` | `create_policy`, `update_policy`, `delete_policy`, `activate_policy`, `deactivate_policy`, `create_policy_rule`, `update_policy_rule`, `delete_policy_rule`, `activate_policy_rule`, `deactivate_policy_rule` |
Expand Down
70 changes: 70 additions & 0 deletions src/okta_mcp_server/tools/applications/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# 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.

import xml.etree.ElementTree as ET
from typing import Any, Dict, Optional

import okta.models as okta_models
Expand Down Expand Up @@ -187,6 +188,75 @@ async def get_application(ctx: Context, app_id: str, expand: Optional[str] = Non
return {"error": str(e)}


@mcp.tool()
@require_scopes("okta.apps.read")
@validate_ids("app_id", error_return_type="dict")
async def get_app_saml_metadata(ctx: Context, app_id: str) -> Any:
"""Get the SAML IdP metadata for an application.

Fetches GET /api/v1/apps/{app_id}/sso/saml/metadata server-side using the
stored token. That endpoint requires authentication and returns XML rather
than JSON, so it is retrieved through the SDK request executor and parsed
locally — the raw XML is always returned, along with the parsed entityID,
SSO URL, and signing certificate when they can be extracted.

Parameters:
app_id (str, required): The ID of the SAML application

Returns:
Dict with metadata_xml plus entity_id, sso_url, and x509_certificate,
or error information.
"""
logger.info(f"Getting SAML IdP metadata for application: {app_id}")

manager = ctx.request_context.lifespan_context.okta_auth_manager

try:
client = await get_okta_client(manager)
executor = client.get_request_executor()
# oauth=False means "this is a normal API call" — the executor still
# attaches the cached bearer token. It does NOT disable authentication.
request, err = await executor.create_request(
method="GET",
url=f"/api/v1/apps/{app_id}/sso/saml/metadata",
body={},
headers={"Accept": "application/xml"},
oauth=False,
)
if err:
logger.error(f"Error building SAML metadata request for {app_id}: {err}")
return {"error": str(err)}

_, response_body, err = await executor.execute(request)
if err:
logger.error(f"Okta API error while getting SAML metadata for {app_id}: {err}")
return {"error": str(err)}

metadata_xml = response_body if isinstance(response_body, str) else str(response_body)
result: Dict[str, Any] = {"metadata_xml": metadata_xml}

try:
ns = {
"md": "urn:oasis:names:tc:SAML:2.0:metadata",
"ds": "http://www.w3.org/2000/09/xmldsig#",
}
root = ET.fromstring(metadata_xml)
result["entity_id"] = root.get("entityID")
sso = root.find(".//md:IDPSSODescriptor/md:SingleSignOnService", ns)
result["sso_url"] = sso.get("Location") if sso is not None else None
cert = root.find(".//md:IDPSSODescriptor//ds:X509Certificate", ns)
result["x509_certificate"] = cert.text.strip() if cert is not None and cert.text else None
except ET.ParseError as parse_err:
logger.warning(f"Could not parse SAML metadata XML for {app_id}: {parse_err}")
result["parse_error"] = str(parse_err)

logger.info(f"Successfully retrieved SAML metadata for application: {app_id}")
return result
except Exception as e:
logger.error(f"Exception while getting SAML metadata for {app_id}: {type(e).__name__}: {e}")
return {"error": str(e)}


@mcp.tool()
@require_scopes("okta.apps.manage")
async def create_application(ctx: Context, app_config: Dict[str, Any], activate: bool = True) -> Any:
Expand Down
1 change: 1 addition & 0 deletions src/okta_mcp_server/utils/scope_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
# ------------------------------------------------------------------
"list_applications": "okta.apps.read",
"get_application": "okta.apps.read",
"get_app_saml_metadata": "okta.apps.read",
"create_application": "okta.apps.manage",
"update_application": "okta.apps.manage",
"delete_application": "okta.apps.manage",
Expand Down
98 changes: 98 additions & 0 deletions tests/test_get_app_saml_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# 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 get_app_saml_metadata."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from okta_mcp_server.tools.applications.applications import get_app_saml_metadata


APP_ID = "0oaSAMLAPP000001"

SAML_METADATA_XML = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" '
'entityID="http://www.okta.com/exk1abc">'
'<md:IDPSSODescriptor WantAuthnRequestsSigned="false" '
'protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">'
'<md:KeyDescriptor use="signing">'
'<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">'
"<ds:X509Data><ds:X509Certificate>MIICertDATA123==</ds:X509Certificate></ds:X509Data>"
"</ds:KeyInfo></md:KeyDescriptor>"
'<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" '
'Location="https://test.okta.com/app/exk1abc/sso/saml"/>'
"</md:IDPSSODescriptor></md:EntityDescriptor>"
)


def _make_ctx():
from tests.conftest import FakeLifespanContext, FakeOktaAuthManager

request_context = MagicMock()
request_context.lifespan_context = FakeLifespanContext(
okta_auth_manager=FakeOktaAuthManager()
)
ctx = MagicMock()
ctx.request_context = request_context
return ctx


def _client_returning(body, execute_error=None):
executor = MagicMock()
executor.create_request = AsyncMock(return_value=({"method": "GET"}, None))
if execute_error is not None:
executor.execute = AsyncMock(return_value=(None, None, execute_error))
else:
executor.execute = AsyncMock(return_value=(MagicMock(), body, None))
client = MagicMock()
client.get_request_executor = MagicMock(return_value=executor)
return client, executor


class TestGetAppSamlMetadata:
@pytest.mark.asyncio
@patch("okta_mcp_server.tools.applications.applications.get_okta_client")
async def test_returns_raw_xml_and_parsed_fields(self, mock_get_client):
client, executor = _client_returning(SAML_METADATA_XML)
mock_get_client.return_value = client

result = await get_app_saml_metadata(ctx=_make_ctx(), app_id=APP_ID)

assert result["metadata_xml"] == SAML_METADATA_XML
assert result["entity_id"] == "http://www.okta.com/exk1abc"
assert result["sso_url"] == "https://test.okta.com/app/exk1abc/sso/saml"
assert result["x509_certificate"] == "MIICertDATA123=="

create_kwargs = executor.create_request.call_args.kwargs
assert create_kwargs["method"] == "GET"
assert create_kwargs["url"].endswith(f"/api/v1/apps/{APP_ID}/sso/saml/metadata")

@pytest.mark.asyncio
@patch("okta_mcp_server.tools.applications.applications.get_okta_client")
async def test_api_error_is_returned(self, mock_get_client):
client, _ = _client_returning(None, execute_error="Error: 404 not found")
mock_get_client.return_value = client

result = await get_app_saml_metadata(ctx=_make_ctx(), app_id=APP_ID)

assert result == {"error": "Error: 404 not found"}

@pytest.mark.asyncio
@patch("okta_mcp_server.tools.applications.applications.get_okta_client")
async def test_unparseable_xml_still_returns_raw(self, mock_get_client):
client, _ = _client_returning("this is not xml")
mock_get_client.return_value = client

result = await get_app_saml_metadata(ctx=_make_ctx(), app_id=APP_ID)

assert result["metadata_xml"] == "this is not xml"
assert "parse_error" in result