Repository: sooperset/mcp-atlassian

Issue:
[Feature]: Support getting user profile information
### Is your feature request related to a problem? Please describe.

I'm unable to fetch user profile information properly as the MCP tries to fetch is only via related tickets.

### Describe the solution you'd like

It would be great if this MCP server could support fetching user profile information (name, email, account ID, avatar URL etc.) for any user in Jira.

### Describe alternatives you've considered

_No response_

### Use Case

_No response_

### Additional Context

_No response_

Hints from issue comments:
@sooperset: Hi, @choxnox. Thanks for this feature request! I agree user profile information would be valuable to add. I'll add this to backlog for implementation.

Solution patch:
diff --git a/src/mcp_atlassian/jira/users.py b/src/mcp_atlassian/jira/users.py
index 0a33fc1..2d827cf 100644
--- a/src/mcp_atlassian/jira/users.py
+++ b/src/mcp_atlassian/jira/users.py
@@ -2,11 +2,24 @@
 
 import json
 import logging
+import re
+from typing import TYPE_CHECKING, TypeVar
 
 import requests
+from requests.exceptions import HTTPError
+
+from mcp_atlassian.exceptions import MCPAtlassianAuthenticationError
+from mcp_atlassian.models.jira.common import JiraUser
 
 from .client import JiraClient
 
+# Forward reference for JiraUser
+if TYPE_CHECKING:
+    from mcp_atlassian.models.jira.common import JiraUser
+
+# Type variable for the return type
+JiraUserType = TypeVar("JiraUserType", bound="JiraUser")
+
 logger = logging.getLogger("mcp-jira")
 
 
@@ -226,3 +239,156 @@ class UsersMixin(JiraClient):
         except Exception as e:
             logger.info(f"Error looking up user by permissions: {str(e)}")
             return None
+
+    def _determine_user_api_params(self, identifier: str) -> dict[str, str]:
+        """
+        Determines the correct API parameter and value for the jira.user() call based on the identifier and instance type.
+
+        Args:
+            identifier: User identifier (accountId, username, key, or email).
+
+        Returns:
+            A dictionary containing the single keyword argument for self.jira.user().
+
+        Raises:
+            ValueError: If a usable parameter cannot be determined.
+        """
+        api_kwargs: dict[str, str] = {}
+
+        # Cloud: identifier is accountId
+        if self.config.is_cloud and (
+            re.match(r"^[0-9a-f]{24}$", identifier) or re.match(r"^\d+:\w+", identifier)
+        ):
+            api_kwargs["account_id"] = identifier
+            logger.debug(f"Determined param: account_id='{identifier}' (Cloud)")
+        # Server/DC: username, key, or email
+        elif not self.config.is_cloud:
+            if "@" in identifier:
+                api_kwargs["username"] = identifier
+                logger.debug(
+                    f"Determined param: username='{identifier}' (Server/DC email - might not work)"
+                )
+            elif "-" in identifier and any(
+                c.isdigit() for c in identifier
+            ):  # Basic check for key format
+                api_kwargs["key"] = identifier
+                logger.debug(f"Determined param: key='{identifier}' (Server/DC)")
+            else:  # Assume username
+                api_kwargs["username"] = identifier
+                logger.debug(f"Determined param: username='{identifier}' (Server/DC)")
+        # Cloud: identifier is email
+        elif self.config.is_cloud and "@" in identifier:
+            try:
+                resolved_id = self._lookup_user_directly(identifier)
+                if resolved_id and (
+                    re.match(r"^[0-9a-f]{24}$", resolved_id)
+                    or re.match(r"^\d+:\w+", resolved_id)
+                ):
+                    api_kwargs["account_id"] = resolved_id
+                    logger.debug(
+                        f"Resolved email '{identifier}' to accountId '{resolved_id}'. Determined param: account_id (Cloud)"
+                    )
+                else:
+                    raise ValueError(
+                        f"Could not resolve email '{identifier}' to a valid account ID for Jira Cloud."
+                    )
+            except Exception as e:
+                logger.warning(f"Failed to resolve email '{identifier}': {e}")
+                raise ValueError(
+                    f"Could not resolve email '{identifier}' to a valid account ID for Jira Cloud."
+                ) from e
+        # Cloud: identifier is not accountId or email, try to resolve
+        else:  # self.config.is_cloud is True and identifier is not accountId/email
+            logger.debug(
+                f"Identifier '{identifier}' on Cloud is not an account ID or email. Attempting resolution."
+            )
+            try:
+                account_id_resolved = self._get_account_id(
+                    identifier
+                )  # This might call user_find_by_user_string
+                api_kwargs["account_id"] = account_id_resolved
+                logger.debug(
+                    f"Resolved identifier '{identifier}' to accountId '{account_id_resolved}'. Determined param: account_id (Cloud)"
+                )
+            except ValueError as e:
+                logger.error(
+                    f"Could not resolve identifier '{identifier}' to a usable format (accountId/username/key)."
+                )
+                raise ValueError(
+                    f"Could not determine how to look up user '{identifier}'."
+                ) from e
+
+        if not api_kwargs:
+            # This should theoretically not be reached if the logic above is exhaustive
+            logger.error(
+                f"Logic failed to determine API parameters for identifier '{identifier}'"
+            )
+            raise ValueError(
+                f"Could not determine the correct parameter to use for identifier '{identifier}'."
+            )
+
+        return api_kwargs
+
+    def get_user_profile_by_identifier(self, identifier: str) -> "JiraUser":
+        """
+        Retrieve Jira user profile information by identifier.
+
+        Args:
+            identifier: User identifier (accountId, username, key, or email).
+
+        Returns:
+            JiraUser model with profile information.
+
+        Raises:
+            ValueError: If the user cannot be found or identifier cannot be resolved.
+            MCPAtlassianAuthenticationError: If authentication fails.
+            Exception: For other API errors.
+        """
+        # Get the correct API parameters using the helper method
+        api_kwargs = self._determine_user_api_params(identifier)
+
+        try:
+            logger.debug(f"Calling self.jira.user() with parameters: {api_kwargs}")
+            # Call self.jira.user() with the single determined keyword argument
+            user_data = self.jira.user(**api_kwargs)
+            if not isinstance(user_data, dict):
+                logger.error(
+                    f"User lookup for '{identifier}' returned unexpected type: {type(user_data)}. Data: {user_data}"
+                )
+                raise ValueError(f"User '{identifier}' not found or lookup failed.")
+            return JiraUser.from_api_response(user_data)
+        except HTTPError as http_err:
+            if http_err.response is not None:
+                response_text = http_err.response.text[:200]  # Log snippet of response
+                status_code = http_err.response.status_code
+                if status_code == 404:
+                    raise ValueError(f"User '{identifier}' not found.") from http_err
+                elif status_code in [401, 403]:
+                    logger.error(
+                        f"Authentication/Permission error for '{identifier}': {status_code}"
+                    )
+                    raise MCPAtlassianAuthenticationError(
+                        f"Permission denied accessing user '{identifier}'."
+                    ) from http_err
+                else:
+                    logger.error(
+                        f"HTTP error {status_code} for '{identifier}': {http_err}. Response: {response_text}"
+                    )
+                    raise Exception(
+                        f"API error getting user profile for '{identifier}': {http_err}"
+                    ) from http_err
+            else:
+                logger.error(
+                    f"Network or unknown HTTP error (no response object) for '{identifier}': {http_err}"
+                )
+                raise Exception(
+                    f"Network error getting user profile for '{identifier}': {http_err}"
+                ) from http_err
+        except Exception as e:
+            # Log with traceback for unexpected errors
+            logger.exception(
+                f"Unexpected error getting/processing user profile for '{identifier}':"
+            )
+            raise Exception(
+                f"Error processing user profile for '{identifier}': {str(e)}"
+            ) from e
diff --git a/src/mcp_atlassian/servers/jira.py b/src/mcp_atlassian/servers/jira.py
index 2a2b96e..a822e37 100644
--- a/src/mcp_atlassian/servers/jira.py
+++ b/src/mcp_atlassian/servers/jira.py
@@ -6,8 +6,11 @@ from typing import Annotated, Any
 
 from fastmcp import Context, FastMCP
 from pydantic import Field
+from requests.exceptions import HTTPError
 
+from mcp_atlassian.exceptions import MCPAtlassianAuthenticationError
 from mcp_atlassian.jira.constants import DEFAULT_READ_JIRA_FIELDS
+from mcp_atlassian.models.jira.common import JiraUser
 
 from .context import MainAppContext
 
@@ -19,6 +22,71 @@ jira_mcp = FastMCP(
 )
 
 
+@jira_mcp.tool(tags={"jira", "read"})
+async def get_user_profile(
+    ctx: Context[Any, MainAppContext],
+    user_identifier: Annotated[
+        str,
+        Field(
+            description="Identifier for the user (e.g., email address 'user@example.com', username 'johndoe', account ID 'accountid:...', or key for Server/DC)."
+        ),
+    ],
+) -> str:
+    """
+    Retrieve profile information for a specific Jira user.
+
+    Args:
+        ctx: The FastMCP context.
+        user_identifier: User identifier (email, username, key, or account ID).
+
+    Returns:
+        JSON string representing the Jira user profile object, or an error object if not found.
+
+    Raises:
+        ValueError: If the Jira client is not configured or available.
+    """
+    lifespan_ctx = ctx.request_context.lifespan_context
+    if not lifespan_ctx or not lifespan_ctx.jira:
+        raise ValueError("Jira client is not configured or available.")
+    jira = lifespan_ctx.jira
+
+    try:
+        user: JiraUser = jira.get_user_profile_by_identifier(user_identifier)
+        result = user.to_simplified_dict()
+        response_data = {"success": True, "user": result}
+    except Exception as e:
+        error_message = ""
+        log_level = logging.ERROR
+
+        if isinstance(e, ValueError) and "not found" in str(e).lower():
+            log_level = logging.WARNING
+            error_message = str(e)
+        elif isinstance(e, MCPAtlassianAuthenticationError):
+            error_message = f"Authentication/Permission Error: {str(e)}"
+        elif isinstance(e, OSError | HTTPError):
+            error_message = f"Network or API Error: {str(e)}"
+        else:
+            error_message = (
+                "An unexpected error occurred while fetching the user profile."
+            )
+            logger.exception(
+                f"Unexpected error in get_user_profile for '{user_identifier}':"
+            )
+
+        error_result = {
+            "success": False,
+            "error": str(e),
+            "user_identifier": user_identifier,
+        }
+        logger.log(
+            log_level,
+            f"get_user_profile failed for '{user_identifier}': {error_message}",
+        )
+        response_data = error_result
+
+    return json.dumps(response_data, indent=2, ensure_ascii=False)
+
+
 @jira_mcp.tool(tags={"jira", "read"})
 async def get_issue(
     ctx: Context[Any, MainAppContext],


Test patch:
diff --git a/tests/unit/jira/test_users.py b/tests/unit/jira/test_users.py
index 98e11f8..32ebd89 100644
--- a/tests/unit/jira/test_users.py
+++ b/tests/unit/jira/test_users.py
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch
 import pytest
 import requests
 
+from mcp_atlassian.jira.config import JiraConfig
 from mcp_atlassian.jira.users import UsersMixin
 
 
@@ -516,3 +517,125 @@ class TestUsersMixin:
                 "query": "username",
                 "permissions": "BROWSE",
             }
+
+    def test_get_user_profile_by_identifier_cloud_account_id(self, users_mixin):
+        """Test get_user_profile_by_identifier with Cloud and accountId."""
+        users_mixin.config = MagicMock(spec=JiraConfig)
+        users_mixin.config.is_cloud = True
+
+        with patch(
+            "src.mcp_atlassian.jira.users.JiraUser.from_api_response"
+        ) as mock_from_api_response:
+            mock_user_instance = MagicMock()
+            mock_from_api_response.return_value = mock_user_instance
+            mock_response_data = {
+                "accountId": "5b10ac8d82e05b22cc7d4ef5",
+                "displayName": "Cloud User",
+                "emailAddress": "cloud@example.com",
+                "active": True,
+            }
+            users_mixin.jira.user = MagicMock(return_value=mock_response_data)
+            test_account_id = "5b10ac8d82e05b22cc7d4ef5"
+            user = users_mixin.get_user_profile_by_identifier(test_account_id)
+            assert user == mock_user_instance
+            users_mixin.jira.user.assert_called_once_with(account_id=test_account_id)
+            mock_from_api_response.assert_called_once_with(mock_response_data)
+
+    def test_get_user_profile_by_identifier_server_username(self, users_mixin):
+        """Test get_user_profile_by_identifier with Server/DC and username."""
+        users_mixin.config = MagicMock(spec=JiraConfig)
+        users_mixin.config.is_cloud = False
+
+        with patch(
+            "src.mcp_atlassian.jira.users.JiraUser.from_api_response"
+        ) as mock_from_api_response:
+            mock_user_instance = MagicMock()
+            mock_from_api_response.return_value = mock_user_instance
+            mock_response_data = {
+                "name": "server_user",
+                "displayName": "Server User",
+                "emailAddress": "server@example.com",
+                "active": True,
+            }
+            users_mixin.jira.user = MagicMock(return_value=mock_response_data)
+            user = users_mixin.get_user_profile_by_identifier("server_user")
+            assert user == mock_user_instance
+            users_mixin.jira.user.assert_called_once_with(username="server_user")
+            mock_from_api_response.assert_called_once_with(mock_response_data)
+
+    def test_get_user_profile_by_identifier_cloud_email(self, users_mixin):
+        """Test get_user_profile_by_identifier with Cloud and email."""
+        users_mixin.config = MagicMock(spec=JiraConfig)
+        users_mixin.config.is_cloud = True
+        users_mixin._lookup_user_directly = MagicMock(
+            return_value="5b10ac8d82e05b22cc7d4ef5"
+        )
+        with patch(
+            "src.mcp_atlassian.jira.users.JiraUser.from_api_response"
+        ) as mock_from_api_response:
+            mock_user_instance = MagicMock()
+            mock_from_api_response.return_value = mock_user_instance
+            mock_response_data = {
+                "accountId": "5b10ac8d82e05b22cc7d4ef5",
+                "displayName": "Email User",
+                "emailAddress": "email@example.com",
+                "active": True,
+            }
+            users_mixin.jira.user = MagicMock(return_value=mock_response_data)
+            user = users_mixin.get_user_profile_by_identifier("email@example.com")
+            assert user == mock_user_instance
+            users_mixin.jira.user.assert_called_once_with(
+                account_id="5b10ac8d82e05b22cc7d4ef5"
+            )
+            users_mixin._lookup_user_directly.assert_called_once_with(
+                "email@example.com"
+            )
+            mock_from_api_response.assert_called_once_with(mock_response_data)
+
+    def test_get_user_profile_by_identifier_not_found(self, users_mixin):
+        """Test get_user_profile_by_identifier when user is not found (404 or cannot resolve)."""
+        users_mixin.config = MagicMock(spec=JiraConfig)
+        users_mixin.config.is_cloud = True
+        users_mixin._lookup_user_directly = MagicMock(return_value=None)
+        users_mixin._lookup_user_by_permissions = MagicMock(return_value=None)
+        # Simulate the identifier cannot be resolved to an account ID
+        with pytest.raises(
+            ValueError, match="Could not determine how to look up user 'nonexistent'."
+        ):
+            users_mixin.get_user_profile_by_identifier("nonexistent")
+
+    def test_get_user_profile_by_identifier_permission_error(self, users_mixin):
+        """Test get_user_profile_by_identifier with a permission error (403)."""
+        users_mixin.config = MagicMock(spec=JiraConfig)
+        users_mixin.config.is_cloud = True
+        users_mixin._get_account_id = MagicMock(
+            return_value="account-id-for-restricted"
+        )
+        mock_response = MagicMock(spec=requests.Response)
+        mock_response.status_code = 403
+        http_error = requests.exceptions.HTTPError(response=mock_response)
+        users_mixin.jira.user = MagicMock(side_effect=http_error)
+        from mcp_atlassian.exceptions import MCPAtlassianAuthenticationError
+
+        with pytest.raises(
+            MCPAtlassianAuthenticationError,
+            match="Permission denied accessing user 'restricted_user'.",
+        ):
+            users_mixin.get_user_profile_by_identifier("restricted_user")
+
+    def test_get_user_profile_by_identifier_api_error(self, users_mixin):
+        """Test get_user_profile_by_identifier with a generic API error."""
+        # Mock config
+        users_mixin.config = MagicMock(spec=JiraConfig)
+        users_mixin.config.is_cloud = True
+        # Mock resolution methods to succeed
+        users_mixin._get_account_id = MagicMock(return_value="account-id-for-error")
+
+        # Mock API to raise a generic exception
+        users_mixin.jira.user = MagicMock(side_effect=Exception("Network Timeout"))
+
+        # Call method and assert generic Exception
+        with pytest.raises(
+            Exception, match="Error processing user profile for 'error_user'"
+        ):
+            users_mixin.get_user_profile_by_identifier("error_user")
diff --git a/tests/unit/servers/test_jira_server.py b/tests/unit/servers/test_jira_server.py
index 4e25343..109f78a 100644
--- a/tests/unit/servers/test_jira_server.py
+++ b/tests/unit/servers/test_jira_server.py
@@ -194,6 +194,30 @@ def mock_jira_fetcher():
         ]
     }
 
+    # Configure get_user_profile_by_identifier for the new tool
+    from src.mcp_atlassian.models.jira.common import JiraUser
+
+    # Create a mock JiraUser for the test response
+    mock_user = MagicMock(spec=JiraUser)
+    mock_user.to_simplified_dict.return_value = {
+        "display_name": "Test User (test.profile@example.com)",
+        "name": "Test User (test.profile@example.com)",  # For backward compatibility
+        "email": "test.profile@example.com",
+        "avatar_url": "https://test.atlassian.net/avatar/test.profile@example.com",
+    }
+
+    # Create a mock for the get_user_profile_by_identifier method
+    mock_get_user_profile = MagicMock()
+
+    # Configure it to return the mock_user for valid inputs and raise ValueError for "nonexistent@example.com"
+    def side_effect_func(identifier):
+        if identifier == "nonexistent@example.com":
+            raise ValueError(f"User '{identifier}' not found.")
+        return mock_user
+
+    mock_get_user_profile.side_effect = side_effect_func
+    mock_fetcher.get_user_profile_by_identifier = mock_get_user_profile
+
     return mock_fetcher
 
 
@@ -234,6 +258,7 @@ def test_jira_mcp(mock_jira_fetcher):
         get_sprint_issues,
         get_sprints_from_board,
         get_transitions,
+        get_user_profile,  # Add import for our new tool
         get_worklog,
         link_to_epic,
         remove_issue_link,
@@ -257,6 +282,7 @@ def test_jira_mcp(mock_jira_fetcher):
     test_mcp.tool()(get_sprints_from_board)
     test_mcp.tool()(get_sprint_issues)
     test_mcp.tool()(get_link_types)
+    test_mcp.tool()(get_user_profile)  # Register our new tool
     # Write tools
     test_mcp.tool()(create_issue)
     test_mcp.tool()(batch_create_issues)
@@ -474,3 +500,47 @@ async def test_batch_create_issues_invalid_json(jira_client):
 
     # Check error message comes from Pydantic/FastMCP validation
     assert "invalid json in issues" in str(excinfo.value).lower()
+
+
+@pytest.mark.anyio
+async def test_get_user_profile_tool_success(jira_client, mock_jira_fetcher):
+    """Test the get_user_profile tool successfully retrieves user info."""
+    # Call the tool
+    response = await jira_client.call_tool(
+        "get_user_profile", {"user_identifier": "test.profile@example.com"}
+    )
+
+    # Verify the fetcher was called
+    mock_jira_fetcher.get_user_profile_by_identifier.assert_called_once_with(
+        "test.profile@example.com"
+    )
+
+    # Verify the response
+    assert len(response) == 1
+    result_data = json.loads(response[0].text)
+    assert result_data["success"] is True
+    assert "user" in result_data
+    user_info = result_data["user"]
+    assert user_info["display_name"] == "Test User (test.profile@example.com)"
+    assert user_info["email"] == "test.profile@example.com"
+    assert (
+        user_info["avatar_url"]
+        == "https://test.atlassian.net/avatar/test.profile@example.com"
+    )
+
+
+@pytest.mark.anyio
+async def test_get_user_profile_tool_not_found(jira_client, mock_jira_fetcher):
+    """Test the get_user_profile tool handles 'user not found' errors."""
+    # Call the tool with a non-existent user
+    response = await jira_client.call_tool(
+        "get_user_profile", {"user_identifier": "nonexistent@example.com"}
+    )
+
+    # Verify the response contains an error
+    assert len(response) == 1
+    result_data = json.loads(response[0].text)
+    assert result_data["success"] is False
+    assert "error" in result_data
+    assert "not found" in result_data["error"]
+    assert result_data["user_identifier"] == "nonexistent@example.com"