Skip to content

Commit de590f5

Browse files
committed
Merge PR sooperset#686: feat(jira): add custom field options MCP tools (resolving uv.lock conflict)
2 parents be3a745 + f189589 commit de590f5

File tree

8 files changed

+1146
-0
lines changed

8 files changed

+1146
-0
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,9 @@ Here's a complete example of setting up multi-user authentication with streamabl
730730
- `jira_update_issue`: Update an existing issue
731731
- `jira_transition_issue`: Transition an issue to a new status
732732
- `jira_add_comment`: Add a comment to an issue
733+
- `jira_get_customfield_options`: Get available options for custom fields
734+
- `jira_get_customfield_contexts`: Get contexts for custom fields
735+
- `jira_get_customfield_context_options`: Get options for custom fields within specific contexts
733736
734737
#### Confluence Tools
735738
@@ -749,6 +752,9 @@ Here's a complete example of setting up multi-user authentication with streamabl
749752
| | `jira_get_worklog` | `confluence_get_labels` |
750753
| | `jira_get_transitions` | `confluence_search_user` |
751754
| | `jira_search_fields` | |
755+
| | `jira_get_customfield_options` | |
756+
| | `jira_get_customfield_contexts` | |
757+
| | `jira_get_customfield_context_options` | |
752758
| | `jira_get_agile_boards` | |
753759
| | `jira_get_board_issues` | |
754760
| | `jira_get_sprints_from_board` | |

src/mcp_atlassian/jira/fields.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55

66
from thefuzz import fuzz
77

8+
from mcp_atlassian.models.jira.field_option import (
9+
JiraFieldContextOptionsResponse,
10+
JiraFieldContextsResponse,
11+
JiraFieldOptionsResponse,
12+
)
13+
814
from .client import JiraClient
915
from .protocols import EpicOperationsProto, UsersOperationsProto
1016

@@ -524,3 +530,228 @@ def similarity(keyword: str, field: dict) -> int:
524530
except Exception as e:
525531
logger.error(f"Error searching fields: {str(e)}")
526532
return []
533+
534+
def get_field_contexts(
535+
self,
536+
field_id: str,
537+
start_at: int = 0,
538+
max_results: int = 10000,
539+
) -> JiraFieldContextsResponse:
540+
"""
541+
Get contexts for a custom field.
542+
543+
Args:
544+
field_id: The ID of the field (e.g., 'customfield_10001')
545+
start_at: Starting index for pagination (default: 0)
546+
max_results: Maximum number of results per page (default: 10000)
547+
548+
Returns:
549+
JiraFieldContextsResponse with contexts for the field
550+
551+
Raises:
552+
ValueError: If the field_id is not provided or invalid
553+
"""
554+
if not field_id:
555+
raise ValueError("Field ID is required")
556+
557+
if not field_id.startswith("customfield_"):
558+
raise ValueError(
559+
"Field ID must be a custom field (starting with 'customfield_')"
560+
)
561+
562+
try:
563+
logger.debug(f"Getting contexts for field '{field_id}'")
564+
565+
# Use different API endpoints for Cloud vs DC/Server
566+
if self.config.is_cloud:
567+
# Cloud API
568+
path = f"/rest/api/3/field/{field_id}/context"
569+
else:
570+
# DC/Server API - contexts endpoint may not exist or be different
571+
# For DC, we'll use the same endpoint as Cloud but with API v2
572+
path = f"/rest/api/2/field/{field_id}/context"
573+
574+
params = {
575+
"startAt": start_at,
576+
"maxResults": max_results,
577+
}
578+
579+
result = self.jira.get(
580+
path=path,
581+
params=params,
582+
)
583+
584+
if not isinstance(result, dict):
585+
error_msg = (
586+
f"Unexpected response type from field contexts API: {type(result)}"
587+
)
588+
logger.error(error_msg)
589+
raise ValueError(error_msg)
590+
591+
# Parse the response using our model
592+
contexts_response = JiraFieldContextsResponse.from_api_response(
593+
result, max_results=max_results
594+
)
595+
logger.debug(
596+
f"Retrieved {len(contexts_response.values)} contexts for field '{field_id}'"
597+
)
598+
return contexts_response
599+
600+
except Exception as e:
601+
logger.error(f"Error getting contexts for field '{field_id}': {str(e)}")
602+
raise
603+
604+
def get_field_options(
605+
self,
606+
field_id: str,
607+
start_at: int = 0,
608+
max_results: int = 10000,
609+
) -> JiraFieldOptionsResponse:
610+
"""
611+
Get options for a custom field (global options).
612+
613+
Args:
614+
field_id: The ID of the field (e.g., 'customfield_10001')
615+
start_at: Starting index for pagination (default: 0)
616+
max_results: Maximum number of results per page (default: 10000)
617+
618+
Returns:
619+
JiraFieldOptionsResponse with options for the field
620+
621+
Raises:
622+
ValueError: If the field_id is not provided or invalid
623+
"""
624+
if not field_id:
625+
raise ValueError("Field ID is required")
626+
627+
if not field_id.startswith("customfield_"):
628+
raise ValueError(
629+
"Field ID must be a custom field (starting with 'customfield_')"
630+
)
631+
632+
try:
633+
logger.debug(f"Getting global options for field '{field_id}'")
634+
635+
# Use different API endpoints for Cloud vs DC/Server
636+
if self.config.is_cloud:
637+
# Cloud API - different endpoint structure
638+
path = f"/rest/api/3/field/{field_id}/option"
639+
else:
640+
# DC/Server API - uses customFields endpoint with numerical ID only
641+
# Extract numerical ID from customfield_XXXXX
642+
numerical_id = field_id.replace("customfield_", "")
643+
path = f"/rest/api/2/customFields/{numerical_id}/options"
644+
645+
params = {
646+
"startAt": start_at,
647+
"maxResults": max_results,
648+
}
649+
650+
result = self.jira.get(
651+
path=path,
652+
params=params,
653+
)
654+
655+
if not isinstance(result, dict):
656+
error_msg = (
657+
f"Unexpected response type from field options API: {type(result)}"
658+
)
659+
logger.error(error_msg)
660+
raise ValueError(error_msg)
661+
662+
# Parse the response using our model
663+
options_response = JiraFieldOptionsResponse.from_api_response(
664+
result, max_results=max_results
665+
)
666+
logger.debug(
667+
f"Retrieved {len(options_response.values)} options for field '{field_id}'"
668+
)
669+
return options_response
670+
671+
except Exception as e:
672+
logger.error(f"Error getting options for field '{field_id}': {str(e)}")
673+
raise
674+
675+
def get_field_context_options(
676+
self,
677+
field_id: str,
678+
context_id: str,
679+
start_at: int = 0,
680+
max_results: int = 10000,
681+
) -> JiraFieldContextOptionsResponse:
682+
"""
683+
Get options for a custom field within a specific context.
684+
This is the most precise way to get field options as they can differ by context.
685+
686+
Args:
687+
field_id: The ID of the field (e.g., 'customfield_10001')
688+
context_id: The ID of the context
689+
start_at: Starting index for pagination (default: 0)
690+
max_results: Maximum number of results per page (default: 10000)
691+
692+
Returns:
693+
JiraFieldContextOptionsResponse with options for the field in the specified context
694+
695+
Raises:
696+
ValueError: If the field_id or context_id is not provided or invalid
697+
"""
698+
if not field_id:
699+
raise ValueError("Field ID is required")
700+
if not context_id:
701+
raise ValueError("Context ID is required")
702+
703+
if not field_id.startswith("customfield_"):
704+
raise ValueError(
705+
"Field ID must be a custom field (starting with 'customfield_')"
706+
)
707+
708+
try:
709+
logger.debug(
710+
f"Getting context options for field '{field_id}' in context '{context_id}'"
711+
)
712+
713+
# Use different API endpoints for Cloud vs DC/Server
714+
if self.config.is_cloud:
715+
# Cloud API
716+
path = f"/rest/api/3/field/{field_id}/context/{context_id}/option"
717+
else:
718+
# DC/Server API - context-specific options may not be available
719+
# Fall back to general options endpoint with numerical ID only
720+
# Extract numerical ID from customfield_XXXXX
721+
numerical_id = field_id.replace("customfield_", "")
722+
path = f"/rest/api/2/customFields/{numerical_id}/options"
723+
logger.warning(
724+
f"DC/Server may not support context-specific options for field '{field_id}', using general options"
725+
)
726+
727+
params = {
728+
"startAt": start_at,
729+
"maxResults": max_results,
730+
}
731+
732+
result = self.jira.get(
733+
path=path,
734+
params=params,
735+
)
736+
737+
if not isinstance(result, dict):
738+
error_msg = f"Unexpected response type from field context options API: {type(result)}"
739+
logger.error(error_msg)
740+
raise ValueError(error_msg)
741+
742+
# Parse the response using our model
743+
context_options_response = (
744+
JiraFieldContextOptionsResponse.from_api_response(
745+
result, max_results=max_results
746+
)
747+
)
748+
logger.debug(
749+
f"Retrieved {len(context_options_response.values)} options for field '{field_id}' in context '{context_id}'"
750+
)
751+
return context_options_response
752+
753+
except Exception as e:
754+
logger.error(
755+
f"Error getting context options for field '{field_id}' in context '{context_id}': {str(e)}"
756+
)
757+
raise

src/mcp_atlassian/jira/issues.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,88 @@ def _format_field_value_for_write(
954954
)
955955
return value # Return original on error
956956

957+
# Handle schema-based formatting for custom fields
958+
elif schema_type == "array":
959+
# Handle array-type custom fields (multi-select, checkboxes, etc.)
960+
if isinstance(value, list):
961+
# Check if it's an array of options (common for multi-select fields)
962+
if (
963+
field_definition
964+
and field_definition.get("schema", {}).get("items") == "option"
965+
):
966+
# Multi-select option fields expect [{"value": "..."}, ...] format
967+
formatted_list = []
968+
for item in value:
969+
if isinstance(item, str):
970+
formatted_list.append({"value": item})
971+
elif isinstance(item, dict) and (
972+
"value" in item or "id" in item
973+
):
974+
formatted_list.append(item)
975+
else:
976+
logger.warning(
977+
f"Invalid item format in array field {field_id}: {item}"
978+
)
979+
return formatted_list
980+
else:
981+
# For other array types, try to format as standard objects
982+
formatted_list = []
983+
for item in value:
984+
if isinstance(item, str):
985+
formatted_list.append({"name": item})
986+
elif isinstance(item, dict):
987+
formatted_list.append(item)
988+
else:
989+
logger.warning(
990+
f"Invalid item format in array field {field_id}: {item}"
991+
)
992+
return formatted_list
993+
elif isinstance(value, str):
994+
# Convert comma-separated string to array
995+
items = [item.strip() for item in value.split(",") if item.strip()]
996+
if (
997+
field_definition
998+
and field_definition.get("schema", {}).get("items") == "option"
999+
):
1000+
return [{"value": item} for item in items]
1001+
else:
1002+
return [{"name": item} for item in items]
1003+
else:
1004+
logger.warning(
1005+
f"Invalid format for array field {field_id}: {value}. Expected list or comma-separated string."
1006+
)
1007+
return None
1008+
elif schema_type == "option":
1009+
# Handle single-select option fields
1010+
if isinstance(value, str):
1011+
return {"value": value}
1012+
elif isinstance(value, dict) and ("value" in value or "id" in value):
1013+
return value
1014+
else:
1015+
logger.warning(
1016+
f"Invalid format for option field {field_id}: {value}. Expected string or dict with value/id."
1017+
)
1018+
return None
1019+
elif schema_type == "user":
1020+
# Handle user fields (assignee, reporter, custom user fields)
1021+
if isinstance(value, str):
1022+
try:
1023+
user_identifier = self._get_account_id(value)
1024+
if self.config.is_cloud:
1025+
return {"accountId": user_identifier}
1026+
else:
1027+
return {"name": user_identifier}
1028+
except ValueError as e:
1029+
logger.warning(f"Could not format user field {field_id}: {str(e)}")
1030+
return None
1031+
elif isinstance(value, dict) and ("accountId" in value or "name" in value):
1032+
return value
1033+
else:
1034+
logger.warning(
1035+
f"Invalid format for user field {field_id}: {value}. Expected string username or dict."
1036+
)
1037+
return None
1038+
9571039
# Default: return value as is if no specific formatting needed/identified
9581040
return value
9591041

src/mcp_atlassian/models/jira/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717
JiraTimetracking,
1818
JiraUser,
1919
)
20+
from .field_option import (
21+
JiraFieldContext,
22+
JiraFieldContextOptionsResponse,
23+
JiraFieldContextsResponse,
24+
JiraFieldOption,
25+
JiraFieldOptionsResponse,
26+
)
2027
from .issue import JiraIssue
2128
from .link import (
2229
JiraIssueLink,
@@ -52,4 +59,10 @@
5259
"JiraIssueLink",
5360
"JiraLinkedIssue",
5461
"JiraLinkedIssueFields",
62+
# Field options models
63+
"JiraFieldOption",
64+
"JiraFieldContext",
65+
"JiraFieldOptionsResponse",
66+
"JiraFieldContextOptionsResponse",
67+
"JiraFieldContextsResponse",
5568
]

0 commit comments

Comments
 (0)