JIRA MCP Tools

by NZenitram
Verified
"""Tools for interacting with JIRA issues.""" from typing import List, Dict, Any, Optional, Union from fastmcp.tools import Tool from src.main import initialize_jira def search_issues( jql: str, max_results: Optional[int] = 10, fields: Optional[str] = "summary,status,assignee,priority,issuetype" ) -> Dict[str, Any]: """ Searches for JIRA issues using JQL (JIRA Query Language). Args: jql: JIRA Query Language string (e.g. "project=DEMO AND status=Open") max_results: Maximum number of results to return (default: 10) fields: Comma-separated list of fields to include in the results Returns: Dictionary containing total issues count and list of matching issues """ # Initialize JIRA client jira = initialize_jira() # Parse fields field_list = [f.strip() for f in fields.split(",")] # Execute the search search_results = jira.search_issues( jql_str=jql, maxResults=max_results, fields=field_list ) # Format response formatted_issues = [] for issue in search_results: issue_data = { "key": issue.key, "summary": getattr(issue.fields, "summary", "No summary provided") } # Add status if available if hasattr(issue.fields, "status") and issue.fields.status: issue_data["status"] = issue.fields.status.name # Add assignee if available if hasattr(issue.fields, "assignee") and issue.fields.assignee: issue_data["assignee"] = issue.fields.assignee.displayName # Add priority if available if hasattr(issue.fields, "priority") and issue.fields.priority: issue_data["priority"] = issue.fields.priority.name # Add issue type if available if hasattr(issue.fields, "issuetype") and issue.fields.issuetype: issue_data["issuetype"] = issue.fields.issuetype.name formatted_issues.append(issue_data) return { "total": len(search_results), "issues": formatted_issues } def create_issue( project_key: str, summary: str, description: Optional[str] = None, issue_type: Optional[str] = "Task", priority: Optional[str] = None, assignee: Optional[str] = None ) -> Dict[str, Any]: """ Create a new JIRA issue. Args: project_key: The key of the project to create the issue in summary: Issue summary description: Issue description issue_type: Type of issue (default: "Task") priority: Priority of the issue assignee: Username to assign the issue to Returns: Dictionary containing the created issue key and URL """ # Initialize JIRA client jira = initialize_jira() # Prepare issue fields issue_dict = { 'project': {'key': project_key}, 'summary': summary, 'issuetype': {'name': issue_type} } # Add optional fields if provided if description: issue_dict['description'] = description if priority: issue_dict['priority'] = {'name': priority} if assignee: issue_dict['assignee'] = {'name': assignee} # Create the issue new_issue = jira.create_issue(fields=issue_dict) # Prepare response response = { 'key': new_issue.key, 'summary': summary, 'project': project_key, 'url': f"{jira._options['server']}/browse/{new_issue.key}" } return response def update_issue( issue_key: str, summary: Optional[str] = None, description: Optional[str] = None, status: Optional[str] = None, priority: Optional[str] = None, assignee: Optional[str] = None, comment: Optional[str] = None ) -> Dict[str, Any]: """ Update an existing JIRA issue. Args: issue_key: The JIRA issue key (e.g., "PROJ-123") summary: New summary for the issue description: New description for the issue status: New status for the issue (transition) priority: New priority for the issue assignee: New assignee for the issue comment: Comment to add to the issue Returns: Dictionary containing the updated issue information """ # Initialize JIRA client jira = initialize_jira() # Get the issue issue = jira.issue(issue_key) # Check if issue exists if not issue: raise ValueError(f"Issue {issue_key} not found") # Dictionary to track changes changes = [] # Update fields if summary: issue.update(fields={'summary': summary}) changes.append(f"Summary updated to: {summary}") if description: issue.update(fields={'description': description}) changes.append("Description updated") if priority: issue.update(fields={'priority': {'name': priority}}) changes.append(f"Priority set to: {priority}") if assignee: issue.update(fields={'assignee': {'name': assignee}}) changes.append(f"Assigned to: {assignee}") # Add comment if provided if comment: jira.add_comment(issue, comment) changes.append("Comment added") # Handle status transition if status: # Get available transitions transitions = jira.transitions(issue) transition_id = None # Find the transition ID for the requested status for t in transitions: if t['name'].lower() == status.lower(): transition_id = t['id'] break # If transition is found, perform it if transition_id: jira.transition_issue(issue, transition_id) changes.append(f"Status changed to: {status}") else: available_statuses = [t['name'] for t in transitions] raise ValueError(f"Status '{status}' not found. Available statuses: {', '.join(available_statuses)}") # Refresh the issue data after updates updated_issue = jira.issue(issue_key) # Prepare response response = { 'key': updated_issue.key, 'summary': getattr(updated_issue.fields, 'summary', 'No summary'), 'status': getattr(updated_issue.fields.status, 'name', 'Unknown') if hasattr(updated_issue.fields, 'status') else 'Unknown', 'changes': changes, 'url': f"{jira._options['server']}/browse/{updated_issue.key}" } return response def delete_issue( issue_key: str, confirm: bool = False ) -> Dict[str, Any]: """ Delete a JIRA issue. Args: issue_key: The JIRA issue key (e.g., "PROJ-123") confirm: Confirmation flag to prevent accidental deletion (must be True) Returns: Dictionary containing status of the deletion """ # Require explicit confirmation if not confirm: raise ValueError("Deletion requires explicit confirmation. Set confirm=True to proceed.") # Initialize JIRA client jira = initialize_jira() # Get the issue to verify it exists and capture details for the response issue = jira.issue(issue_key) # Check if issue exists if not issue: raise ValueError(f"Issue {issue_key} not found") # Capture issue details before deletion summary = getattr(issue.fields, 'summary', 'No summary') project_key = issue_key.split('-')[0] if '-' in issue_key else 'Unknown' # Delete the issue issue.delete() # Prepare response return { 'status': 'success', 'message': f'Issue {issue_key} has been deleted', 'details': { 'key': issue_key, 'summary': summary, 'project': project_key } } def add_comment( issue_key: str, comment: str ) -> Dict[str, Any]: """ Add a comment to a JIRA issue. Args: issue_key: The JIRA issue key (e.g., "PROJ-123") comment: The comment text to add to the issue Returns: Dictionary containing the comment information and status """ # Initialize JIRA client jira = initialize_jira() # Get the issue to verify it exists issue = jira.issue(issue_key) # Check if issue exists if not issue: raise ValueError(f"Issue {issue_key} not found") # Add the comment comment_obj = jira.add_comment(issue, comment) # Prepare response return { 'status': 'success', 'message': f'Comment added to issue {issue_key}', 'details': { 'issue_key': issue_key, 'comment_id': comment_obj.id, 'comment_text': comment, 'author': getattr(comment_obj.author, 'displayName', 'Unknown'), 'created': str(comment_obj.created), 'url': f"{jira._options['server']}/browse/{issue_key}?focusedCommentId={comment_obj.id}" } } def transition_issue( issue_key: str, status: str, comment: Optional[str] = None ) -> Dict[str, Any]: """ Transition a JIRA issue to a new status. Args: issue_key: The JIRA issue key (e.g., "PROJ-123") status: The target status to transition the issue to comment: Optional comment to add with the transition Returns: Dictionary containing the transition information and status """ # Initialize JIRA client jira = initialize_jira() # Get the issue to verify it exists issue = jira.issue(issue_key) # Check if issue exists if not issue: raise ValueError(f"Issue {issue_key} not found") # Get current status current_status = getattr(issue.fields.status, 'name', 'Unknown') # Get available transitions transitions = jira.transitions(issue) transition_id = None available_statuses = [t['name'] for t in transitions] # Find the transition ID for the requested status for t in transitions: if t['name'].lower() == status.lower(): transition_id = t['id'] break # If transition is not found, raise error with available statuses if not transition_id: raise ValueError( f"Status '{status}' not found. Available transitions from '{current_status}': " f"{', '.join(available_statuses)}" ) # Prepare transition data transition_data = { 'transition': {'id': transition_id} } # Add comment if provided if comment: transition_data['update'] = { 'comment': [{'add': {'body': comment}}] } # Perform the transition jira.transition_issue(issue, transition_id, transition_data) # Refresh issue to get updated status updated_issue = jira.issue(issue_key) new_status = getattr(updated_issue.fields.status, 'name', 'Unknown') # Prepare response return { 'status': 'success', 'message': f'Issue {issue_key} transitioned from {current_status} to {new_status}', 'details': { 'issue_key': issue_key, 'previous_status': current_status, 'new_status': new_status, 'comment_added': bool(comment), 'url': f"{jira._options['server']}/browse/{issue_key}" } } def get_issue_details( issue_key: str, include_comments: bool = False ) -> Dict[str, Any]: """ Get detailed information about a JIRA issue. Args: issue_key: The JIRA issue key (e.g., "PROJ-123") include_comments: Whether to include issue comments in the response (default: False) Returns: Dictionary containing detailed issue information """ # Initialize JIRA client jira = initialize_jira() # Get the issue issue = jira.issue(issue_key) # Check if issue exists if not issue: raise ValueError(f"Issue {issue_key} not found") # Build basic issue details details = { 'key': issue.key, 'summary': getattr(issue.fields, 'summary', 'No summary'), 'description': getattr(issue.fields, 'description', 'No description'), 'status': getattr(issue.fields.status, 'name', 'Unknown'), 'issue_type': getattr(issue.fields.issuetype, 'name', 'Unknown'), 'project': { 'key': getattr(issue.fields.project, 'key', 'Unknown'), 'name': getattr(issue.fields.project, 'name', 'Unknown') }, 'created': str(issue.fields.created), 'updated': str(issue.fields.updated), 'creator': getattr(issue.fields.creator, 'displayName', 'Unknown'), 'reporter': getattr(issue.fields.reporter, 'displayName', 'Unknown'), 'assignee': getattr(issue.fields.assignee, 'displayName', 'Unassigned') if issue.fields.assignee else 'Unassigned', 'priority': getattr(issue.fields.priority, 'name', 'None') if hasattr(issue.fields, 'priority') else 'None', 'labels': getattr(issue.fields, 'labels', []), 'url': f"{jira._options['server']}/browse/{issue.key}" } # Add comments if requested if include_comments: comments = [] for comment in issue.fields.comment.comments: comments.append({ 'id': comment.id, 'body': comment.body, 'author': getattr(comment.author, 'displayName', 'Unknown'), 'created': str(comment.created), 'updated': str(comment.updated) }) details['comments'] = comments # Get available transitions transitions = jira.transitions(issue) details['available_transitions'] = [t['name'] for t in transitions] return { 'status': 'success', 'message': f'Retrieved details for issue {issue_key}', 'details': details } def search_users( query: str, max_results: Optional[int] = 10, include_active_users: bool = True, include_inactive_users: bool = False ) -> Dict[str, Any]: """ Search for JIRA users by display name or email. For JIRA Cloud instances with GDPR strict mode enabled (which is the default for newer instances), this searches user display names and email addresses only. Username matching is not supported. Args: query: Search string to find users by display name or email max_results: Maximum number of results to return (default: 10) include_active_users: Whether to include active users in results (default: True) include_inactive_users: Whether to include inactive users in results (default: False) Returns: Dictionary containing the list of matching users Raises: ValueError: If the search query is empty or if neither active nor inactive users are included """ # Initialize JIRA client jira = initialize_jira() # Validate input if not query: raise ValueError("Search query cannot be empty") if not include_active_users and not include_inactive_users: raise ValueError("At least one of include_active_users or include_inactive_users must be True") try: # Use the GDPR-compliant search endpoint users = jira._get_json('user/search', params={ 'query': query, 'maxResults': max_results, 'includeActive': include_active_users, 'includeInactive': include_inactive_users }) # Format user data formatted_users = [] for user in users: user_data = { 'account_id': user.get('accountId', 'Unknown'), 'display_name': user.get('displayName', 'Unknown'), 'email': user.get('emailAddress', 'Unknown'), 'active': user.get('active', True), 'time_zone': user.get('timeZone', 'Unknown'), 'locale': user.get('locale', 'Unknown'), 'avatar_url': user.get('avatarUrls', {}).get('48x48') if 'avatarUrls' in user else None } formatted_users.append(user_data) return { 'status': 'success', 'message': f'Found {len(formatted_users)} users matching "{query}"', 'details': { 'query': query, 'total': len(formatted_users), 'users': formatted_users, 'search_criteria': { 'include_active': include_active_users, 'include_inactive': include_inactive_users, 'max_results': max_results } } } except Exception as e: # Handle API errors gracefully error_message = str(e) if 'GDPR' in error_message: error_message += "\nThis JIRA instance is in GDPR strict mode, which affects how user searches work." return { 'status': 'error', 'message': f'Failed to search users: {error_message}', 'details': { 'query': query, 'error': str(e) } }