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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @json_response above this (and on get_catalog_app / install_oin_app below) — see the general comment. This is the mandatory innermost decorator on every tool; your branch predates the commit that introduced it.

"""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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This create_request/execute/error-check sequence (lines ~489-501) is repeated near-verbatim in get_catalog_app and install_oin_app below — this raw-request pattern is new (it doesn't exist elsewhere in the codebase, which otherwise goes through the typed SDK client methods), so there's no established convention to match here. Worth factoring the three copies into one small helper, e.g. _catalog_request(client, method, url, body=None), since it's introduced fresh in this PR rather than being pre-existing duplication.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response object is discarded here, so any Link-header pagination cursor from the catalog endpoint is unreachable. If /api/v1/catalog/apps paginates (worth checking against a live org — it's a large catalog), this will silently return only the first page with no has_more signal, unlike every other list_* tool in this file/codebase.

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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three error-path tests only cover the (None, None, err) tuple case; none exercise a raised exception (e.g. mock_get_client.side_effect = Exception(...)). Once @json_response is added, that path routes through _failure_envelope — worth a test confirming that shape.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use one more test here: app_name with path-traversal characters (e.g. "../admin") should be rejected by @validate_ids before the API call — this is exercised for equivalent tools elsewhere in the test suite (see tests/test_validation.py and test_user_resources.py's test_invalid_user_id_rejected_before_api_call) and would be good to mirror here.

@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"}