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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` <br> - `Remove former employee Jane Smith permanently` <br> - `Clean up old contractor accounts` |
| `get_user_profile_attributes` | Retrieve all supported user profile attributes | - `What user profile fields are available?` <br> - `Show me all the custom attributes we can set` <br> - `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?` <br> - `Show me all group memberships for user ID 00u1234567890` <br> - `Which teams does Jane Smith belong to?` |

### Groups

> **Required scope:** `okta.groups.read` (read) · `okta.groups.manage` (write)
Expand Down Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions src/okta_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions src/okta_mcp_server/tools/user_resources/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 55 additions & 0 deletions src/okta_mcp_server/tools/user_resources/user_resources.py
Original file line number Diff line number Diff line change
@@ -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")

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 here as the innermost decorator (should sit between this and async def). Without it, this tool returns raw Okta SDK objects instead of JSON — see the top-level comment for details.

async def list_user_groups(user_id: str, ctx: Context = None) -> list:

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.

Nit: most tools in this codebase take ctx: Context as the first parameter with no default. list_group_users uses the same ctx placement as this PR, so this isn't strictly wrong, but double-check against whichever sibling tool you're most closely mirroring for consistency. Not a functional issue since FastMCP injects Context by type annotation regardless of position.

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

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 call isn't paginated. list_group_users in groups.py (the read-the-other-direction tool) accepts fetch_all/after/limit and calls build_query_params(...) / paginate_all_results(...). A user belonging to more groups than one page returns will be silently truncated here. Please add pagination support to match.


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

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 returns a list of raw Okta SDK Group Pydantic objects. Without @json_response wrapping this function, FastMCP can't serialize these to JSON content. Once @json_response is added above, this line can stay as-is — the decorator will normalize the return value via to_jsonable.


except Exception as e:
logger.error(f"Exception while listing groups for user {user_id}: {type(e).__name__}: {e}")
return [f"Exception: {e}"]
Comment on lines +53 to +55

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.

Once @json_response is added, this outer catch-all becomes redundant — the decorator already traps any exception that escapes the tool body into a structured failure envelope. Other tools only use their own try/except to translate the SDK's (result, response, err) tuple into an error response, letting genuinely unexpected exceptions bubble up to @json_response. Recommend dropping this except Exception block so error shapes stay consistent across the codebase.

4 changes: 4 additions & 0 deletions src/okta_mcp_server/utils/scope_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
145 changes: 145 additions & 0 deletions tests/test_user_resources.py
Original file line number Diff line number Diff line change
@@ -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())

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 test (and its assertions) will keep passing even after @json_response is added, since it asserts on live attribute access against MagicMock objects rather than verifying the tool's actual output is JSON-serializable. Once @json_response is added, please also add/update a test asserting the returned shape is plain dict/list/primitives (e.g. json.dumps(result) doesn't raise), so a future regression here gets caught.


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 == []
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.