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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ The Okta MCP Server provides the following tools for LLMs to interact with your
| `update_user` | Update an existing user's profile information | - `Update John Doe's department to Engineering` <br> - `Change the phone number for user jane.smith@company.com` <br> - `Update the manager for this user` |
| `deactivate_user` | Deactivate a user (prompts for confirmation) | - `Deactivate the user john.doe@company.com` <br> - `Disable access for former employee Jane Smith` <br> - `Suspend the contractor account temporarily` |
| `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` |
| `unlock_user` | Unlock a user account with LOCKED_OUT status | - `Unlock the account for john.doe@company.com` <br> - `Unlock user 00u1234567890 so they can sign in again` <br> - `Restore access for locked-out user` |
| `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` |

### Groups
Expand Down
36 changes: 36 additions & 0 deletions src/okta_mcp_server/tools/users/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,42 @@ async def update_user(user_id: str, profile: dict, ctx: Context = None) -> list:
@mcp.tool()
@require_scopes("okta.users.manage", error_return_type="list")
@validate_ids("user_id")
async def unlock_user(user_id: str, ctx: Context = None) -> list:
"""Unlock a user account that has a LOCKED_OUT status in the Okta organization.

This tool unlocks a user whose account has been locked due to exceeding
the failed login attempt threshold. The user is returned to ACTIVE status
and can sign in with their existing password.

Parameters:
user_id (str, required): The ID of the locked-out user to unlock.

Returns:
List containing the result of the unlock operation.
"""
logger.info(f"Unlock requested 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 unlock user {user_id}")

_, err = await client.unlock_user(user_id)

if err:
logger.error(f"Okta API error while unlocking user {user_id}: {err}")
return [f"Error: {err}"]

logger.info(f"Successfully unlocked user: {user_id}")
return [f"User {user_id} unlocked successfully."]
except Exception as e:
logger.error(f"Exception while unlocking user {user_id}: {type(e).__name__}: {e}")
return [f"Exception: {e}"]


@mcp.tool()
@validate_ids("user_id")
async def deactivate_user(user_id: str, ctx: Context = None) -> list:
"""Deactivates a user from the Okta organization.

Expand Down
44 changes: 43 additions & 1 deletion tests/elicitation/test_users_elicitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# 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 deactivation and deletion with elicitation support."""
"""Tests for user lifecycle operations including unlock, deactivation, and deletion."""

from __future__ import annotations

Expand All @@ -16,12 +16,54 @@
from okta_mcp_server.tools.users.users import (
deactivate_user,
delete_deactivated_user,
unlock_user,
)


USER_ID = "00u1234567890ABCDEF"


# ===================================================================
# unlock_user
# ===================================================================

class TestUnlockUser:
"""Tests for unlock_user (non-destructive, no elicitation required)."""

@pytest.mark.asyncio
@patch("okta_mcp_server.tools.users.users.get_okta_client")
async def test_unlock_success(self, mock_get_client, ctx_elicit_accept_true, mock_okta_client):
"""Unlock succeeds and returns a success message."""
mock_get_client.return_value = mock_okta_client

result = await unlock_user(user_id=USER_ID, ctx=ctx_elicit_accept_true)

mock_okta_client.unlock_user.assert_awaited_once_with(USER_ID)
assert "unlocked successfully" in result[0]

@pytest.mark.asyncio
@patch("okta_mcp_server.tools.users.users.get_okta_client")
async def test_unlock_okta_api_error(self, mock_get_client, ctx_elicit_accept_true):
"""Okta API error is surfaced to the caller."""
client = AsyncMock()
client.unlock_user.return_value = (None, "API Error: user is not locked out")
mock_get_client.return_value = client

result = await unlock_user(user_id=USER_ID, ctx=ctx_elicit_accept_true)

assert "Error" in result[0]

@pytest.mark.asyncio
@patch("okta_mcp_server.tools.users.users.get_okta_client")
async def test_unlock_exception(self, mock_get_client, ctx_elicit_accept_true):
"""Generic exception is surfaced to the caller."""
mock_get_client.side_effect = Exception("Connection refused")

result = await unlock_user(user_id=USER_ID, ctx=ctx_elicit_accept_true)

assert "Exception" in result[0]


# ===================================================================
# deactivate_user — elicitation flows
# ===================================================================
Expand Down