Repository: sooperset/mcp-atlassian

Issue:
[Bug]: custom fields IDs are not translated
### Prerequisites

- [x] I have searched the [existing issues](https://github.com/sooperset/mcp-atlassian/issues) to make sure this bug has not already been reported.
- [x] I have checked the [README](https://github.com/sooperset/mcp-atlassian/blob/main/README.md) for relevant information.

### Bug Description

The key used for custom fields in the `jira_get_issue` tool response is the custom field ID instead of the custom field name.

So, for instance, I’m getting:
```
{
   "key": "37744",
   "customfield_1234": "Opened by customer"
}
```
Instead of:
```
{
   "key": "37744",
   "Ticket Origin": "Opened by customer"
}
```

This is the case also if I pass `expand=names` to the tool.

I've noticed that [there is already some logic in the code to perform this translation](https://github.com/sooperset/mcp-atlassian/blob/b6d23dfa3cb310f4a3b8d145604765a4817a5044/src/mcp_atlassian/models/jira/issue.py#L425-L435) the problem is that this logic assumes that the custom field name is contained inside the custom field value
e.g.
```
{
   "key": "37744",
   "customfield_1234": {
       "value": "Opened by customer",
       "name": "Ticket Origin"
}
```

Instead, what I observed is that the name mapping is returned in a top level field "names" of the REST API response only when "expand=names" is passed.

e.g.
```
{
   "key": "37744",
   "customfield_1234": "Opened by customer",
   "names": {
      "customfield_1234": "Ticket Origin",
   }
}
```

### Steps to Reproduce

 - Start the server with SSE transport mode
 - Invoke the jira_get_issue tool with or without `expand=names`
 - the response will contain untranslated custom field names

### Expected Behavior

Example of `jira_get_issue` tool response:
```
{
   "key": "37744",
   "Ticket Origin": "Opened by customer"
}
```

### Actual Behavior

```shell
{
   "key": "37744",
   "customfield_1234": "Opened by customer"
}
```

### mcp-atlassian Version

0.11.0

### Installation Method

From source (git clone)

### Operating System

Linux (Specify distribution below if relevant)

### Python Version

3.10.12

### Atlassian Instance Type

Jira Server / Data Center

### Client Application

MCP Inspector

### Additional Context

I'm using Jira v10.3.6.

Hints from issue comments:
@toto-dev: I've done an attempt at fixing this issue, you can find [the code here](https://github.com/toto-dev/mcp-atlassian/commit/4c3c8e3194f9f084f5bcbd0b936c19604c70e188)@sooperset: Hi @toto-dev, thanks for the suggestion! I added this in #450 

Solution patch:
diff --git a/src/mcp_atlassian/models/jira/issue.py b/src/mcp_atlassian/models/jira/issue.py
index 47a0397..1f33142 100644
--- a/src/mcp_atlassian/models/jira/issue.py
+++ b/src/mcp_atlassian/models/jira/issue.py
@@ -421,18 +421,14 @@ class JiraIssue(ApiModel, TimestampMixin):
 
         # Store custom fields
         custom_fields = {}
-        for field_id, field_value in fields.items():
-            if field_id.startswith("customfield_"):
-                # Extract custom field name if it's a nested object with a name
-                if isinstance(field_value, dict) and "name" in field_value:
-                    field_name = field_value.get("name", "")
-                    if field_name:
-                        # Use the field name as a key for easier access
-                        custom_field_name = field_name.lower().replace(" ", "_")
-                        custom_fields[custom_field_name] = field_value
-                else:
-                    # Use the original ID as the key (instead of shortened version)
-                    custom_fields[field_id] = field_value
+        fields_name_map = data.get("names", {})
+        for orig_field_id, orig_field_value in fields.items():
+            if orig_field_id.startswith("customfield_"):
+                value_obj_to_store = {"value": orig_field_value}
+                human_readable_name = fields_name_map.get(orig_field_id)
+                if human_readable_name:
+                    value_obj_to_store["name"] = human_readable_name
+                custom_fields[orig_field_id] = value_obj_to_store
 
         # Handle requested_fields parameter
         requested_fields_param = kwargs.get("requested_fields")
@@ -606,36 +602,61 @@ class JiraIssue(ApiModel, TimestampMixin):
 
         # Process custom fields
         if self.custom_fields:
-            # Add all custom fields if "*all" is requested
             if self.requested_fields == "*all":
-                # Loop through custom fields and add all of them
-                for field_id, field_value in self.custom_fields.items():
-                    # Process the value to make it more user-friendly
-                    processed_value = self._process_custom_field_value(field_value)
-                    result[field_id] = processed_value
-
-            # Add specific requested custom fields
+                for internal_id, field_data_obj in self.custom_fields.items():
+                    processed_value = self._process_custom_field_value(
+                        field_data_obj.get("value")
+                    )
+                    output_value_obj = {"value": processed_value}
+                    if "name" in field_data_obj:
+                        output_value_obj["name"] = field_data_obj["name"]
+                    result[internal_id] = output_value_obj
             elif isinstance(self.requested_fields, list):
-                for field in self.requested_fields:
-                    # Check for customfield_ format
-                    if field.startswith("customfield_"):
-                        # Try to find the field in custom_fields
-                        if field in self.custom_fields:
-                            value = self._process_custom_field_value(
-                                self.custom_fields[field]
+                for requested_key_or_name in self.requested_fields:
+                    found_by_id_or_name = False
+                    if (
+                        requested_key_or_name.startswith("customfield_")
+                        and requested_key_or_name in self.custom_fields
+                    ):
+                        field_data_obj = self.custom_fields[requested_key_or_name]
+                        output_value_obj = {
+                            "value": self._process_custom_field_value(
+                                field_data_obj.get("value")
                             )
-                            result[field] = value
-
-                    # Check for cf_ format (for backward compatibility in requests)
-                    elif field.startswith("cf_"):
-                        full_id = "customfield_" + field[3:]  # Convert to full form
-
-                        # Try to find the full version in custom_fields
+                        }
+                        if "name" in field_data_obj:
+                            output_value_obj["name"] = field_data_obj["name"]
+                        result[requested_key_or_name] = output_value_obj
+                        found_by_id_or_name = True
+                    else:
+                        for internal_id, field_data_obj in self.custom_fields.items():
+                            if (
+                                field_data_obj.get("name", "").lower()
+                                == requested_key_or_name.lower()
+                            ):
+                                output_value_obj = {
+                                    "value": self._process_custom_field_value(
+                                        field_data_obj.get("value")
+                                    )
+                                }
+                                output_value_obj["name"] = field_data_obj["name"]
+                                result[internal_id] = output_value_obj
+                                found_by_id_or_name = True
+                                break
+                    if not found_by_id_or_name and requested_key_or_name.startswith(
+                        "cf_"
+                    ):
+                        full_id = "customfield_" + requested_key_or_name[3:]
                         if full_id in self.custom_fields:
-                            value = self._process_custom_field_value(
-                                self.custom_fields[full_id]
-                            )
-                            result[full_id] = value
+                            field_data_obj = self.custom_fields[full_id]
+                            output_value_obj = {
+                                "value": self._process_custom_field_value(
+                                    field_data_obj.get("value")
+                                )
+                            }
+                            if "name" in field_data_obj:
+                                output_value_obj["name"] = field_data_obj["name"]
+                            result[full_id] = output_value_obj
 
         return {k: v for k, v in result.items() if v is not None}
 
@@ -653,16 +674,15 @@ class JiraIssue(ApiModel, TimestampMixin):
             return field_value
 
         if isinstance(field_value, dict):
+            # For single-select, user pickers, etc., try to extract 'value' or 'name'
             if "value" in field_value:
                 return field_value["value"]
             elif "name" in field_value:
                 return field_value["name"]
+            return field_value
 
         if isinstance(field_value, list):
-            return [
-                item.get("value", str(item)) if isinstance(item, dict) else str(item)
-                for item in field_value
-            ]
+            return [self._process_custom_field_value(item) for item in field_value]
 
         return str(field_value)
 


Test patch:
diff --git a/tests/fixtures/jira_mocks.py b/tests/fixtures/jira_mocks.py
index 1d3e1bc..2bb3001 100644
--- a/tests/fixtures/jira_mocks.py
+++ b/tests/fixtures/jira_mocks.py
@@ -128,6 +128,13 @@ MOCK_JIRA_ISSUE_RESPONSE = {
             {"value": "Custom MultiSelect 2"},
         ],
     },
+    "names": {
+        "customfield_10011": "Epic Name",
+        "customfield_10014": "Epic Link",
+        "customfield_10001": "My Custom Text Field",
+        "customfield_10002": "My Custom Select",
+        "customfield_10003": "My Custom MultiSelect",
+    },
 }
 
 MOCK_JIRA_JQL_RESPONSE = {
@@ -239,6 +246,10 @@ MOCK_JIRA_JQL_RESPONSE = {
             },
         }
     ],
+    "names": {
+        "customfield_10011": "Epic Name",
+        "customfield_10014": "Epic Link",
+    },
 }
 
 # Generic mock Jira comments data without any company-specific information
diff --git a/tests/unit/jira/test_issues.py b/tests/unit/jira/test_issues.py
index 92fc5f7..ef307e4 100644
--- a/tests/unit/jira/test_issues.py
+++ b/tests/unit/jira/test_issues.py
@@ -189,8 +189,10 @@ class TestIssuesMixin:
             assert issue.summary == "Test Issue"
 
             # Verify that the epic information is in the custom fields
-            assert issue.custom_fields.get("customfield_10010") == "EPIC-456"
-            assert issue.custom_fields.get("customfield_10011") == "Epic Name Value"
+            assert issue.custom_fields.get("customfield_10010") == {"value": "EPIC-456"}
+            assert issue.custom_fields.get("customfield_10011") == {
+                "value": "Epic Name Value"
+            }
 
         except Exception as e:
             pytest.fail(f"Test failed: {e}")
@@ -865,7 +867,7 @@ class TestIssuesMixin:
         # Check the result
         simplified = issue.to_simplified_dict()
         assert "customfield_10049" in simplified
-        assert simplified["customfield_10049"] == "Custom value"
+        assert simplified["customfield_10049"] == {"value": "Custom value"}
         assert "description" not in simplified
 
         # Test with list format
@@ -886,7 +888,7 @@ class TestIssuesMixin:
         # Check the result
         simplified = issue.to_simplified_dict()
         assert "customfield_10050" in simplified
-        assert simplified["customfield_10050"] == "Option value"
+        assert simplified["customfield_10050"] == {"value": "Option value"}
 
     def test_get_issue_with_all_fields(self, issues_mixin: IssuesMixin):
         """Test get_issue with '*all' fields parameter."""
diff --git a/tests/unit/jira/test_search.py b/tests/unit/jira/test_search.py
index 7808cfa..e2ca2ce 100644
--- a/tests/unit/jira/test_search.py
+++ b/tests/unit/jira/test_search.py
@@ -434,7 +434,7 @@ class TestSearchMixin:
         assert "assignee" in simplified
         assert "customfield_10049" in simplified
 
-        assert simplified["customfield_10049"] == "Custom value"
+        assert simplified["customfield_10049"] == {"value": "Custom value"}
         assert "assignee" in simplified
         assert simplified["assignee"]["display_name"] == "Test User"
 
diff --git a/tests/unit/models/test_jira_models.py b/tests/unit/models/test_jira_models.py
index 46c0451..486152e 100644
--- a/tests/unit/models/test_jira_models.py
+++ b/tests/unit/models/test_jira_models.py
@@ -492,6 +492,18 @@ class TestJiraIssue:
         assert issue.worklog["total"] == 0
         assert issue.worklog["maxResults"] == 20
 
+        # Verify custom_fields structure after from_api_response
+        assert "customfield_10001" in issue.custom_fields
+        assert issue.custom_fields["customfield_10001"] == {
+            "value": "Custom Text Field Value",
+            "name": "My Custom Text Field",
+        }
+        assert "customfield_10002" in issue.custom_fields
+        assert issue.custom_fields["customfield_10002"] == {
+            "value": {"value": "Custom Select Value"},  # Original value is a dict
+            "name": "My Custom Select",
+        }
+
     def test_from_api_response_with_new_fields(self):
         """Test creating a JiraIssue focusing on parsing the new fields."""
         # Construct local mock data including the new fields
@@ -689,7 +701,14 @@ class TestJiraIssue:
         assert simplified_specific["project"]["key"] == "PROJ"
         assert simplified_specific["resolution"]["name"] == "Fixed"
         assert len(simplified_specific["subtasks"]) == 1
-        assert simplified_specific["customfield_10011"] == "Epic Name Example"
+        # Check custom field output
+        assert (
+            simplified_specific["customfield_10011"]
+            == {
+                "value": "Epic Name Example",
+                "name": "Epic Name",  # Comes from the "names" map in MOCK_JIRA_ISSUE_RESPONSE
+            }
+        )
 
     def test_find_custom_field_in_api_response(self):
         """Test the _find_custom_field_in_api_response method with different field patterns."""
@@ -840,6 +859,9 @@ class TestJiraIssue:
         simplified = issue.to_simplified_dict()
         assert simplified["key"] == "PROJ-123"
         assert simplified["summary"] == "Test Issue Summary"
+        # By default (no requested_fields or default set), custom fields are not included
+        # unless they are part of DEFAULT_READ_JIRA_FIELDS (which they are not).
+        # So, this assertion should be that they are NOT present.
         assert "customfield_10001" not in simplified
         assert "customfield_10002" not in simplified
         assert "customfield_10003" not in simplified
@@ -851,6 +873,8 @@ class TestJiraIssue:
         assert "key" in simplified
         assert "summary" in simplified
         assert "customfield_10001" in simplified
+        assert simplified["customfield_10001"]["value"] == "Custom Text Field Value"
+        assert simplified["customfield_10001"]["name"] == "My Custom Text Field"
         assert "customfield_10002" not in simplified
 
         issue = JiraIssue.from_api_response(
@@ -861,13 +885,19 @@ class TestJiraIssue:
         assert "customfield_10002" in simplified
         assert "summary" not in simplified
         assert "customfield_10001" not in simplified
+        assert simplified["customfield_10002"]["value"] == "Custom Select Value"
+        assert simplified["customfield_10002"]["name"] == "My Custom Select"
 
         issue = JiraIssue.from_api_response(jira_issue_data, requested_fields="*all")
         simplified = issue.to_simplified_dict()
         assert "key" in simplified
         assert "summary" in simplified
         assert "customfield_10001" in simplified
+        assert simplified["customfield_10001"]["value"] == "Custom Text Field Value"
+        assert simplified["customfield_10001"]["name"] == "My Custom Text Field"
         assert "customfield_10002" in simplified
+        assert simplified["customfield_10002"]["value"] == "Custom Select Value"
+        assert simplified["customfield_10002"]["name"] == "My Custom Select"
         assert "customfield_10003" in simplified
 
         issue_specific = JiraIssue.from_api_response(
@@ -875,7 +905,10 @@ class TestJiraIssue:
         )
         simplified_specific = issue_specific.to_simplified_dict()
         assert "customfield_10014" in simplified_specific
-        assert simplified_specific.get("customfield_10014") == "EPIC-KEY-1"
+        assert simplified_specific.get("customfield_10014") == {
+            "value": "EPIC-KEY-1",
+            "name": "Epic Link",
+        }
 
     def test_jira_issue_with_default_fields(self, jira_issue_data):
         """Test that JiraIssue returns only essential fields by default."""