From 0fb4370b814e54bf001fef38a283ceaf7f8d4503 Mon Sep 17 00:00:00 2001 From: Erik Manor Date: Tue, 26 May 2026 00:19:59 -0700 Subject: [PATCH] feature_get_users_groups --- README.md | 10 +- src/okta_mcp_server/server.py | 1 + .../tools/user_resources/__init__.py | 6 + .../tools/user_resources/user_resources.py | 55 +++++++ src/okta_mcp_server/utils/scope_registry.py | 4 + tests/test_user_resources.py | 145 ++++++++++++++++++ uv.lock | 2 +- 7 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 src/okta_mcp_server/tools/user_resources/__init__.py create mode 100644 src/okta_mcp_server/tools/user_resources/user_resources.py create mode 100644 tests/test_user_resources.py diff --git a/README.md b/README.md index 04351f0..ead9474 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,14 @@ The Okta MCP Server provides the following tools for LLMs to interact with your | `delete_deactivated_user` | Permanently delete a deactivated user (prompts for confirmation) | - `Delete the deactivated user john.doe@company.com`
- `Remove former employee Jane Smith permanently`
- `Clean up old contractor accounts` | | `get_user_profile_attributes` | Retrieve all supported user profile attributes | - `What user profile fields are available?`
- `Show me all the custom attributes we can set`
- `List the standard Okta user attributes` | +### User Resources + +> **Required scope:** `okta.users.read` + +| Tool | Description | Usage Examples | +| --------------------- | ------------------------------------------------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `list_user_groups` | List all groups that a specific user is a member of | - `What groups is john.doe@company.com in?`
- `Show me all group memberships for user ID 00u1234567890`
- `Which teams does Jane Smith belong to?` | + ### Groups > **Required scope:** `okta.groups.read` (read) · `okta.groups.manage` (write) @@ -654,7 +662,7 @@ The Okta MCP Server uses a **scope-based tool loading** mechanism to ensure that | Scope | Tools Unlocked | | ----- | -------------- | -| `okta.users.read` | `list_users`, `get_user`, `get_user_profile_attributes` | +| `okta.users.read` | `list_users`, `get_user`, `get_user_profile_attributes`, `list_user_groups` | | `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` | diff --git a/src/okta_mcp_server/server.py b/src/okta_mcp_server/server.py index a7615d3..251960d 100644 --- a/src/okta_mcp_server/server.py +++ b/src/okta_mcp_server/server.py @@ -143,6 +143,7 @@ def main(): from okta_mcp_server.tools.policies import policies # noqa: F401 from okta_mcp_server.tools.system_logs import system_logs # noqa: F401 from okta_mcp_server.tools.system_logs import login_failures # noqa: F401 + from okta_mcp_server.tools.user_resources import user_resources # noqa: F401 from okta_mcp_server.tools.users import users # noqa: F401 from okta_mcp_server.utils import scope_stubs # noqa: F401 diff --git a/src/okta_mcp_server/tools/user_resources/__init__.py b/src/okta_mcp_server/tools/user_resources/__init__.py new file mode 100644 index 0000000..b50663a --- /dev/null +++ b/src/okta_mcp_server/tools/user_resources/__init__.py @@ -0,0 +1,6 @@ +# The Okta software accompanied by this notice is provided pursuant to the following terms: +# Copyright © 2025-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. diff --git a/src/okta_mcp_server/tools/user_resources/user_resources.py b/src/okta_mcp_server/tools/user_resources/user_resources.py new file mode 100644 index 0000000..422ea00 --- /dev/null +++ b/src/okta_mcp_server/tools/user_resources/user_resources.py @@ -0,0 +1,55 @@ +# The Okta software accompanied by this notice is provided pursuant to the following terms: +# Copyright © 2025-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. + +from loguru import logger +from mcp.server.fastmcp import Context + +from okta_mcp_server.server import mcp +from okta_mcp_server.utils.client import get_okta_client +from okta_mcp_server.utils.scope_guard import require_scopes +from okta_mcp_server.utils.validation import validate_ids + + +@mcp.tool() +@require_scopes("okta.users.read", error_return_type="list") +@validate_ids("user_id") +async def list_user_groups(user_id: str, ctx: Context = None) -> list: + """List all groups that a user is a member of. + + This tool retrieves all groups of which the specified user is a member. + To list all groups in your org, use list_groups() from the Groups tools instead. + + Parameters: + user_id (str, required): The ID, login, or login shortname of the user. + + Returns: + List of group objects the user belongs to. + """ + logger.info(f"Listing groups for user: {user_id}") + + manager = ctx.request_context.lifespan_context.okta_auth_manager + + try: + client = await get_okta_client(manager) + logger.debug(f"Calling Okta API to list groups for user {user_id}") + + groups, _, err = await client.list_user_groups(user_id) + + if err: + logger.error(f"Okta API error while listing groups for user {user_id}: {err}") + return [f"Error: {err}"] + + if not groups: + logger.info(f"No groups found for user {user_id}") + return [] + + logger.info(f"Successfully retrieved {len(groups)} groups for user {user_id}") + return groups + + except Exception as e: + logger.error(f"Exception while listing groups for user {user_id}: {type(e).__name__}: {e}") + return [f"Exception: {e}"] diff --git a/src/okta_mcp_server/utils/scope_registry.py b/src/okta_mcp_server/utils/scope_registry.py index b713588..db697f7 100644 --- a/src/okta_mcp_server/utils/scope_registry.py +++ b/src/okta_mcp_server/utils/scope_registry.py @@ -36,6 +36,10 @@ "deactivate_user": "okta.users.manage", "delete_deactivated_user": "okta.users.manage", # ------------------------------------------------------------------ + # User Resources (src/okta_mcp_server/tools/user_resources/user_resources.py) + # ------------------------------------------------------------------ + "list_user_groups": "okta.users.read", + # ------------------------------------------------------------------ # Groups (src/okta_mcp_server/tools/groups/groups.py) # ------------------------------------------------------------------ "list_groups": "okta.groups.read", diff --git a/tests/test_user_resources.py b/tests/test_user_resources.py new file mode 100644 index 0000000..48b4ed7 --- /dev/null +++ b/tests/test_user_resources.py @@ -0,0 +1,145 @@ +# The Okta software accompanied by this notice is provided pursuant to the following terms: +# Copyright © 2025-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 user_resources tools — list_user_groups.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from okta_mcp_server.tools.user_resources.user_resources import list_user_groups + + +USER_ID = "00uTEST000000001" + + +def _make_ctx(): + """Build a minimal fake Context (no elicitation needed for read-only tools).""" + 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 _make_group_mock(group_id: str, name: str): + group = MagicMock() + group.id = group_id + group.profile = MagicMock() + group.profile.name = name + return group + + +class TestListUserGroups: + """Tests for list_user_groups tool.""" + + @pytest.mark.asyncio + @patch("okta_mcp_server.tools.user_resources.user_resources.get_okta_client") + async def test_returns_groups_for_user(self, mock_get_client): + """A valid user_id should return the list of groups the user belongs to.""" + client = AsyncMock() + groups = [ + _make_group_mock("00gTEST0000001", "Engineering"), + _make_group_mock("00gTEST0000002", "Everyone"), + ] + client.list_user_groups.return_value = (groups, MagicMock(), None) + mock_get_client.return_value = client + + result = await list_user_groups(user_id=USER_ID, ctx=_make_ctx()) + + client.list_user_groups.assert_called_once_with(USER_ID) + assert len(result) == 2 + assert result[0].profile.name == "Engineering" + assert result[1].profile.name == "Everyone" + + @pytest.mark.asyncio + @patch("okta_mcp_server.tools.user_resources.user_resources.get_okta_client") + async def test_returns_empty_list_when_user_has_no_groups(self, mock_get_client): + """A user with no group memberships should return an empty list.""" + client = AsyncMock() + client.list_user_groups.return_value = ([], MagicMock(), None) + mock_get_client.return_value = client + + result = await list_user_groups(user_id=USER_ID, ctx=_make_ctx()) + + assert result == [] + + @pytest.mark.asyncio + @patch("okta_mcp_server.tools.user_resources.user_resources.get_okta_client") + async def test_returns_empty_list_when_api_returns_none(self, mock_get_client): + """A None groups response (no memberships) should return an empty list.""" + client = AsyncMock() + client.list_user_groups.return_value = (None, MagicMock(), None) + mock_get_client.return_value = client + + result = await list_user_groups(user_id=USER_ID, ctx=_make_ctx()) + + assert result == [] + + @pytest.mark.asyncio + @patch("okta_mcp_server.tools.user_resources.user_resources.get_okta_client") + async def test_okta_api_error_is_surfaced(self, mock_get_client): + """An Okta API error should be returned as an error string in the list.""" + client = AsyncMock() + client.list_user_groups.return_value = (None, None, "Error: user not found") + mock_get_client.return_value = client + + result = await list_user_groups(user_id=USER_ID, ctx=_make_ctx()) + + assert len(result) == 1 + assert "Error" in result[0] + + @pytest.mark.asyncio + @patch("okta_mcp_server.tools.user_resources.user_resources.get_okta_client") + async def test_exception_is_surfaced(self, mock_get_client): + """An unexpected exception should be returned as an Exception string.""" + mock_get_client.side_effect = Exception("Connection refused") + + result = await list_user_groups(user_id=USER_ID, ctx=_make_ctx()) + + assert len(result) == 1 + assert "Exception" in result[0] + + @pytest.mark.asyncio + async def test_invalid_user_id_rejected_before_api_call(self): + """A user_id with path traversal characters should be rejected without hitting the API.""" + result = await list_user_groups(user_id="../admin", ctx=_make_ctx()) + + assert len(result) == 1 + assert "Error" in result[0] + + @pytest.mark.asyncio + async def test_login_shortname_accepted(self): + """A login shortname (valid Okta ID format) should pass validation.""" + with patch("okta_mcp_server.tools.user_resources.user_resources.get_okta_client") as mock_get_client: + client = AsyncMock() + client.list_user_groups.return_value = ([], MagicMock(), None) + mock_get_client.return_value = client + + result = await list_user_groups(user_id="jdoe", ctx=_make_ctx()) + + client.list_user_groups.assert_called_once_with("jdoe") + assert result == [] + + @pytest.mark.asyncio + async def test_email_login_accepted(self): + """An email address used as login should pass validation.""" + with patch("okta_mcp_server.tools.user_resources.user_resources.get_okta_client") as mock_get_client: + client = AsyncMock() + client.list_user_groups.return_value = ([], MagicMock(), None) + mock_get_client.return_value = client + + result = await list_user_groups(user_id="jdoe@example.com", ctx=_make_ctx()) + + client.list_user_groups.assert_called_once_with("jdoe@example.com") + assert result == [] diff --git a/uv.lock b/uv.lock index c4fad52..ef1c794 100644 --- a/uv.lock +++ b/uv.lock @@ -619,7 +619,7 @@ wheels = [ [[package]] name = "okta-mcp-server" -version = "0.1.0" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "flatdict" },