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

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.