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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,9 @@ The Okta MCP Server provides the following tools for LLMs to interact with your
| `delete_application` | Delete an application (prompts for confirmation) | - `Delete the old legacy application` <br> - `Remove the unused test application` <br> - `Clean up deprecated integrations` |
| `activate_application` | Activate an application | - `Activate the new HR application` <br> - `Enable the Salesforce integration` <br> - `Turn on the mobile app for users` |
| `deactivate_application` | Deactivate an application (prompts for confirmation) | - `Deactivate the legacy CRM application` <br> - `Temporarily disable the mobile app` <br> - `Turn off access to the test environment` |
| `list_catalog_apps` | Browse the OIN app catalog (find an app's `name` + features) | - `Find a SCIM 2.0 test app in the catalog` <br> - `Search the OIN catalog for Slack` |
| `get_catalog_app` | Get a single OIN catalog app definition (incl. schema) | - `Show the catalog definition for the SCIM 2.0 test app` |
| `install_oin_app` | Install an instance of an OIN catalog app (e.g. a provisioning-capable SCIM app) | - `Install the SCIM 2.0 test app as "HR Directory Sync"` <br> - `Add the Slack OIN app to my org` |

### Policies

Expand Down Expand Up @@ -658,8 +661,8 @@ 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.manage` | `create_application`, `update_application`, `delete_application`, `activate_application`, `deactivate_application` |
| `okta.apps.read` | `list_applications`, `get_application`, `list_catalog_apps`, `get_catalog_app` |
| `okta.apps.manage` | `create_application`, `update_application`, `delete_application`, `activate_application`, `deactivate_application`, `install_oin_app` |
| `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` |
| `okta.deviceAssurance.read` | `list_device_assurance_policies`, `get_device_assurance_policy` |
Expand Down
158 changes: 158 additions & 0 deletions src/okta_mcp_server/tools/applications/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
# 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 json
from typing import Any, Dict, Optional
from urllib.parse import urlencode

import okta.models as okta_models
from loguru import logger
Expand Down Expand Up @@ -444,3 +446,159 @@ async def deactivate_application(ctx: Context, app_id: str) -> list:
except Exception as e:
logger.error(f"Exception while deactivating application {app_id}: {type(e).__name__}: {e}")
return [f"Exception: {e}"]


# ---------------------------------------------------------------------------
# OIN catalog & app installation
# ---------------------------------------------------------------------------


@mcp.tool()
@require_scopes("okta.apps.read")
async def list_catalog_apps(ctx: Context, q: Optional[str] = None, limit: Optional[int] = None) -> Any:
"""Browse the Okta Integration Network (OIN) app catalog.

Use this to discover an app's ``name`` (the catalog key) to pass to
install_oin_app. Apps that support outbound provisioning list a provisioning
capability in their ``features`` (e.g. a SCIM 2.0 test app). A plain custom
SAML/OIDC app created with create_application cannot do provisioning; you need
an installed instance of a provisioning-capable catalog app.

Parameters:
q (str, optional): Filters the catalog by app name/keyword (e.g. "scim")
limit (int, optional): Maximum number of catalog entries to return

Returns:
Dict with a ``catalog_apps`` list (each has ``name``, ``displayName``,
``features``, ``signOnModes``), or error information.
"""
logger.info(f"Browsing OIN catalog (q='{q}', limit={limit})")

manager = ctx.request_context.lifespan_context.okta_auth_manager

try:
client = await get_okta_client(manager)
params: Dict[str, Any] = {}
if q:
params["q"] = q
if limit:
params["limit"] = limit
query_string = urlencode(params)
url = "/api/v1/catalog/apps" + (f"?{query_string}" if query_string else "")

executor = client.get_request_executor()
request, err = await executor.create_request(method="GET", url=url, body={}, headers={}, oauth=False)
if err:
logger.error(f"Error building catalog request: {err}")
return {"error": str(err)}

_, response_body, err = await executor.execute(request)
if err:
logger.error(f"Okta API error while browsing catalog: {err}")
return {"error": str(err)}

logger.info("Successfully retrieved OIN catalog page")
return {"catalog_apps": json.loads(response_body) if response_body else []}
except Exception as e:
logger.error(f"Exception while browsing OIN catalog: {type(e).__name__}: {e}")
return {"error": str(e)}


@mcp.tool()
@require_scopes("okta.apps.read")
@validate_ids("app_name", error_return_type="dict")
async def get_catalog_app(ctx: Context, app_name: str) -> Any:
"""Get a single OIN catalog app definition (including its provisioning schema).

Parameters:
app_name (str, required): The catalog app key (from list_catalog_apps)

Returns:
Dict with the catalog app definition, or error information.
"""
logger.info(f"Getting OIN catalog app: {app_name}")

manager = ctx.request_context.lifespan_context.okta_auth_manager

try:
client = await get_okta_client(manager)
url = f"/api/v1/catalog/apps/{app_name}?expand=schema"

executor = client.get_request_executor()
request, err = await executor.create_request(method="GET", url=url, body={}, headers={}, oauth=False)
if err:
logger.error(f"Error building catalog-app request for {app_name}: {err}")
return {"error": str(err)}

_, response_body, err = await executor.execute(request)
if err:
logger.error(f"Okta API error while getting catalog app {app_name}: {err}")
return {"error": str(err)}

logger.info(f"Successfully retrieved OIN catalog app: {app_name}")
return json.loads(response_body) if response_body else {}
except Exception as e:
logger.error(f"Exception while getting catalog app {app_name}: {type(e).__name__}: {e}")
return {"error": str(e)}


@mcp.tool()
@require_scopes("okta.apps.manage")
async def install_oin_app(
ctx: Context,
name: str,
label: str,
sign_on_mode: str,
settings: Optional[Dict[str, Any]] = None,
activate: bool = True,
) -> Any:
"""Install an instance of an OIN catalog app (e.g. a provisioning-capable SCIM app).

Unlike create_application (which builds custom apps), this preserves the
catalog ``name`` key. The typed SDK create path strips ``name`` from the
request body, so a catalog/OIN app can't be installed through create_application;
this issues the request directly. Provisioning capability is determined by the
catalog app definition at install time — it cannot be added to a custom app
afterwards. Discover ``name`` and the allowed ``signOnMode`` values via
list_catalog_apps / get_catalog_app.

Parameters:
name (str, required): The OIN catalog app key (e.g. from list_catalog_apps)
label (str, required): Display label for the installed instance
sign_on_mode (str, required): A sign-on mode the catalog app supports
(e.g. SAML_2_0, OPENID_CONNECT, SECURE_PASSWORD_STORE, BOOKMARK)
settings (dict, optional): Additional app settings/profile some OIN apps require
activate (bool, optional): Activate the app on creation. Defaults to True.

Returns:
Dict with the installed application, or error information.
"""
logger.info(f"Installing OIN app '{name}' (label='{label}', signOnMode='{sign_on_mode}')")

manager = ctx.request_context.lifespan_context.okta_auth_manager

try:
client = await get_okta_client(manager)
app_body: Dict[str, Any] = {"name": name, "label": label, "signOnMode": sign_on_mode}
if settings:
app_body["settings"] = settings

query_string = urlencode({"activate": "true" if activate else "false"})
url = f"/api/v1/apps?{query_string}"

executor = client.get_request_executor()
request, err = await executor.create_request(method="POST", url=url, body=app_body, headers={}, oauth=False)
if err:
logger.error(f"Error building OIN install request for '{name}': {err}")
return {"error": str(err)}

_, response_body, err = await executor.execute(request)
if err:
logger.error(f"Okta API error while installing OIN app '{name}': {err}")
return {"error": str(err)}

logger.info(f"Successfully installed OIN app '{name}'")
return json.loads(response_body) if response_body else {}
except Exception as e:
logger.error(f"Exception while installing OIN app '{name}': {type(e).__name__}: {e}")
return {"error": str(e)}
3 changes: 3 additions & 0 deletions src/okta_mcp_server/utils/scope_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
"confirm_delete_application": "okta.apps.manage",
"activate_application": "okta.apps.manage",
"deactivate_application": "okta.apps.manage",
"list_catalog_apps": "okta.apps.read",
"get_catalog_app": "okta.apps.read",
"install_oin_app": "okta.apps.manage",
# ------------------------------------------------------------------
# Policies (src/okta_mcp_server/tools/policies/policies.py)
# ------------------------------------------------------------------
Expand Down
131 changes: 131 additions & 0 deletions tests/test_oin_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# 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 OIN catalog browse + install tools."""

from __future__ import annotations

import json
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from okta_mcp_server.tools.applications.applications import (
get_catalog_app,
install_oin_app,
list_catalog_apps,
)


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": "X"}, 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


class TestListCatalogApps:
@pytest.mark.asyncio
@patch("okta_mcp_server.tools.applications.applications.get_okta_client")
async def test_returns_catalog_apps_and_passes_query(self, mock_get_client):
catalog = [{"name": "scim2testapp", "displayName": "SCIM 2.0 Test App", "features": ["IMPORT_NEW_USERS"]}]
client = _client_returning(json.dumps(catalog))
mock_get_client.return_value = client

result = await list_catalog_apps(ctx=_make_ctx(), q="scim")

assert result == {"catalog_apps": catalog}
kwargs = client.get_request_executor.return_value.create_request.call_args.kwargs
assert kwargs["method"] == "GET"
assert "/api/v1/catalog/apps?q=scim" in kwargs["url"]

@pytest.mark.asyncio
@patch("okta_mcp_server.tools.applications.applications.get_okta_client")
async def test_error_is_returned(self, mock_get_client):
mock_get_client.return_value = _client_returning(None, execute_error="403 forbidden")

result = await list_catalog_apps(ctx=_make_ctx())

assert result == {"error": "403 forbidden"}


class TestGetCatalogApp:
@pytest.mark.asyncio
@patch("okta_mcp_server.tools.applications.applications.get_okta_client")
async def test_returns_app_definition_with_schema_expand(self, mock_get_client):
client = _client_returning(json.dumps({"name": "scim2testapp", "status": "ACTIVE"}))
mock_get_client.return_value = client

result = await get_catalog_app(ctx=_make_ctx(), app_name="scim2testapp")

assert result["name"] == "scim2testapp"
url = client.get_request_executor.return_value.create_request.call_args.kwargs["url"]
assert url.endswith("/api/v1/catalog/apps/scim2testapp?expand=schema")


class TestInstallOinApp:
@pytest.mark.asyncio
@patch("okta_mcp_server.tools.applications.applications.get_okta_client")
async def test_posts_body_with_name_preserved(self, mock_get_client):
created = {"id": "0oaNEW0000001", "name": "scim2testapp", "label": "HR Directory Sync", "status": "ACTIVE"}
client = _client_returning(json.dumps(created))
mock_get_client.return_value = client

result = await install_oin_app(
ctx=_make_ctx(), name="scim2testapp", label="HR Directory Sync", sign_on_mode="SAML_2_0"
)

assert result == created
kwargs = client.get_request_executor.return_value.create_request.call_args.kwargs
assert kwargs["method"] == "POST"
assert kwargs["url"].startswith("/api/v1/apps?")
assert "activate=true" in kwargs["url"]
# The catalog `name` MUST be in the body — the whole point (the typed SDK path drops it).
assert kwargs["body"] == {"name": "scim2testapp", "label": "HR Directory Sync", "signOnMode": "SAML_2_0"}

@pytest.mark.asyncio
@patch("okta_mcp_server.tools.applications.applications.get_okta_client")
async def test_settings_and_activate_false_forwarded(self, mock_get_client):
client = _client_returning(json.dumps({}))
mock_get_client.return_value = client

await install_oin_app(
ctx=_make_ctx(), name="scim2testapp", label="X", sign_on_mode="SAML_2_0",
settings={"app": {"acsUrl": "https://x"}}, activate=False,
)

kwargs = client.get_request_executor.return_value.create_request.call_args.kwargs
assert "activate=false" in kwargs["url"]
assert kwargs["body"]["settings"] == {"app": {"acsUrl": "https://x"}}

@pytest.mark.asyncio
@patch("okta_mcp_server.tools.applications.applications.get_okta_client")
async def test_error_is_returned(self, mock_get_client):
mock_get_client.return_value = _client_returning(None, execute_error="400 invalid app name")

result = await install_oin_app(
ctx=_make_ctx(), name="bogus", label="X", sign_on_mode="SAML_2_0"
)

assert result == {"error": "400 invalid app name"}