import asyncio
import json
import logging
import os
import sys
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Union
# --- Setup a dedicated file logger ---
log_file_path = Path(__file__).parent / "jira_mcp_debug.log"
logger = logging.getLogger("JiraMCPLogger")
logger.setLevel(logging.DEBUG) # Capture all levels of logs
# Create a file handler to write logs to a file
# Use 'w' to overwrite the file on each run, ensuring a clean log
handler = logging.FileHandler(log_file_path, mode="w")
handler.setLevel(logging.DEBUG)
# Create a formatter to make the logs readable
formatter = logging.Formatter(
"%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s"
)
handler.setFormatter(formatter)
# Add the handler to the logger
if not logger.handlers:
logger.addHandler(handler)
logger.info("Logger initialized. All subsequent logs will go to jira_mcp_debug.log")
# --- End of logger setup ---
try:
from jira import JIRA
except ImportError:
from .jira import JIRA
from pydantic import BaseModel
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import EmbeddedResource, ImageContent, TextContent, Tool
from .jira_v3_api import JiraV3APIClient
try:
from dotenv import load_dotenv
# Try to load from .env file if it exists
env_path = Path(__file__).parent.parent.parent.parent / ".env"
if env_path.exists():
load_dotenv(dotenv_path=env_path)
except ImportError:
# dotenv is optional
pass
class JiraTools(str, Enum):
GET_PROJECTS = "get_jira_projects"
GET_ISSUE = "get_jira_issue"
SEARCH_ISSUES = "search_jira_issues"
CREATE_ISSUE = "create_jira_issue"
CREATE_ISSUES = "create_jira_issues"
ADD_COMMENT = "add_jira_comment"
GET_TRANSITIONS = "get_jira_transitions"
TRANSITION_ISSUE = "transition_jira_issue"
CREATE_PROJECT = "create_jira_project"
GET_PROJECT_ISSUE_TYPES = "get_jira_project_issue_types"
class JiraIssueField(BaseModel):
name: str
value: str
class JiraIssueResult(BaseModel):
key: str
summary: str
description: Optional[str] = None
status: Optional[str] = None
assignee: Optional[str] = None
reporter: Optional[str] = None
created: Optional[str] = None
updated: Optional[str] = None
fields: Optional[Dict[str, Any]] = None
comments: Optional[List[Dict[str, Any]]] = None
watchers: Optional[Dict[str, Any]] = None
attachments: Optional[List[Dict[str, Any]]] = None
subtasks: Optional[List[Dict[str, Any]]] = None
project: Optional[Dict[str, Any]] = None
issue_links: Optional[List[Dict[str, Any]]] = None
worklog: Optional[List[Dict[str, Any]]] = None
timetracking: Optional[Dict[str, Any]] = None
class JiraProjectResult(BaseModel):
key: str
name: str
id: str
lead: Optional[str] = None
class JiraTransitionResult(BaseModel):
id: str
name: str
class JiraServer:
def __init__(
self,
server_url: str = None,
auth_method: str = None,
username: str = None,
password: str = None,
token: str = None,
):
self.server_url = server_url
self.auth_method = auth_method
self.username = username
self.password = password
self.token = token
self._v3_api_client = JiraV3APIClient(
server_url=self.server_url,
username=self.username,
token=self.token,
password=password,
)
self.client = None
def connect(self):
"""Connect to Jira server using provided authentication details"""
if not self.server_url:
print("Error: Jira server URL not provided")
return False
error_messages = []
# Try multiple auth methods if possible
try:
# First, try the specified auth method
if self.auth_method == "basic_auth":
# Basic auth - either username/password or username/token
if self.username and self.password:
try:
print(f"Trying basic_auth with username and password")
self.client = JIRA(
server=self.server_url,
basic_auth=(self.username, self.password),
)
print("Connection successful with username/password")
return True
except Exception as e:
error_msg = f"Failed basic_auth with username/password: {type(e).__name__}: {str(e)}"
print(error_msg)
error_messages.append(error_msg)
if self.username and self.token:
try:
print(f"Trying basic_auth with username and API token")
self.client = JIRA(
server=self.server_url,
basic_auth=(self.username, self.token),
)
print("Connection successful with username/token")
return True
except Exception as e:
error_msg = f"Failed basic_auth with username/token: {type(e).__name__}: {str(e)}"
print(error_msg)
error_messages.append(error_msg)
print("Error: Username and password/token required for basic auth")
error_messages.append(
"Username and password/token required for basic auth"
)
elif self.auth_method == "token_auth":
# Token auth - just need the token
if self.token:
try:
print(f"Trying token_auth with token")
self.client = JIRA(
server=self.server_url, token_auth=self.token
)
print("Connection successful with token_auth")
return True
except Exception as e:
error_msg = f"Failed token_auth: {type(e).__name__}: {str(e)}"
print(error_msg)
error_messages.append(error_msg)
else:
print("Error: Token required for token auth")
error_messages.append("Token required for token auth")
# If we're here and have a token, try using it with basic_auth for Jira Cloud
# (even if auth_method wasn't basic_auth)
if self.token and self.username and not self.client:
try:
print(f"Trying fallback to basic_auth with username and token")
self.client = JIRA(
server=self.server_url, basic_auth=(self.username, self.token)
)
print("Connection successful with fallback basic_auth")
return True
except Exception as e:
error_msg = (
f"Failed fallback to basic_auth: {type(e).__name__}: {str(e)}"
)
print(error_msg)
error_messages.append(error_msg)
# If we're here and have a token, try using token_auth as a fallback
# (even if auth_method wasn't token_auth)
if self.token and not self.client:
try:
print(f"Trying fallback to token_auth")
self.client = JIRA(server=self.server_url, token_auth=self.token)
print("Connection successful with fallback token_auth")
return True
except Exception as e:
error_msg = (
f"Failed fallback to token_auth: {type(e).__name__}: {str(e)}"
)
print(error_msg)
error_messages.append(error_msg)
# Last resort: try anonymous access
try:
print(f"Trying anonymous access as last resort")
self.client = JIRA(server=self.server_url)
print("Connection successful with anonymous access")
return True
except Exception as e:
error_msg = f"Failed anonymous access: {type(e).__name__}: {str(e)}"
print(error_msg)
error_messages.append(error_msg)
# If we got here, all connection attempts failed
print(f"All connection attempts failed: {', '.join(error_messages)}")
return False
except Exception as e:
error_msg = f"Unexpected error in connect(): {type(e).__name__}: {str(e)}"
print(error_msg)
error_messages.append(error_msg)
return False
def _get_v3_api_client(self) -> JiraV3APIClient:
"""Get or create a v3 API client instance"""
if not self._v3_api_client:
self._v3_api_client = JiraV3APIClient(
server_url=self.server_url,
username=self.username,
password=self.password,
token=self.token,
)
return self._v3_api_client
async def get_jira_projects(self) -> List[JiraProjectResult]:
"""Get all accessible Jira projects using v3 REST API"""
logger.info("Starting get_jira_projects...")
all_projects_data = []
start_at = 0
max_results = 50
page_count = 0
while True:
page_count += 1
logger.info(
f"Pagination loop, page {page_count}: startAt={start_at}, maxResults={max_results}"
)
try:
response = await self._v3_api_client.get_projects(
start_at=start_at, max_results=max_results
)
projects = response.get("values", [])
if not projects:
logger.info("No more projects returned. Breaking pagination loop.")
break
all_projects_data.extend(projects)
if response.get("isLast", False):
logger.info("'isLast' is True. Breaking pagination loop.")
break
start_at += len(projects)
# Yield control to the event loop to prevent deadlocks in the MCP framework.
await asyncio.sleep(0)
except Exception as e:
logger.error(
"Error inside get_jira_projects pagination loop", exc_info=True
)
raise
logger.info(
f"Finished get_jira_projects. Total projects found: {len(all_projects_data)}"
)
results = []
for p in all_projects_data:
results.append(
JiraProjectResult(
key=p.get("key"),
name=p.get("name"),
id=str(p.get("id")),
lead=(p.get("lead") or {}).get("displayName"),
)
)
logger.info(f"Added project {p.get('key')} to results")
logger.info(f"Returning {len(results)} projects")
sys.stdout.flush() # Flush stdout to ensure it's sent to MCP, otherwise hang occurs
return results
def get_jira_issue(self, issue_key: str) -> JiraIssueResult:
"""Get details for a specific issue by key"""
if not self.client:
if not self.connect():
# Connection failed - provide clear error message
raise ValueError(
f"Failed to connect to Jira server at {self.server_url}. Check your authentication credentials."
)
try:
issue = self.client.issue(issue_key)
# Extract comments if available
comments = []
if hasattr(issue.fields, "comment") and hasattr(
issue.fields.comment, "comments"
):
for comment in issue.fields.comment.comments:
comments.append(
{
"author": (
getattr(
comment.author, "displayName", str(comment.author)
)
if hasattr(comment, "author")
else "Unknown"
),
"body": comment.body,
"created": comment.created,
}
)
# Create a fields dictionary with custom fields
fields = {}
for field_name in dir(issue.fields):
if not field_name.startswith("_") and field_name not in [
"comment",
"attachment",
"summary",
"description",
"status",
"assignee",
"reporter",
"created",
"updated",
]:
value = getattr(issue.fields, field_name)
if value is not None:
# Handle special field types
if hasattr(value, "name"):
fields[field_name] = value.name
elif hasattr(value, "value"):
fields[field_name] = value.value
elif isinstance(value, list):
if len(value) > 0:
if hasattr(value[0], "name"):
fields[field_name] = [item.name for item in value]
else:
fields[field_name] = value
else:
fields[field_name] = str(value)
return JiraIssueResult(
key=issue.key,
summary=issue.fields.summary,
description=issue.fields.description,
status=(
issue.fields.status.name
if hasattr(issue.fields, "status")
else None
),
assignee=(
issue.fields.assignee.displayName
if hasattr(issue.fields, "assignee") and issue.fields.assignee
else None
),
reporter=(
issue.fields.reporter.displayName
if hasattr(issue.fields, "reporter") and issue.fields.reporter
else None
),
created=(
issue.fields.created if hasattr(issue.fields, "created") else None
),
updated=(
issue.fields.updated if hasattr(issue.fields, "updated") else None
),
fields=fields,
comments=comments,
)
except Exception as e:
print(f"Failed to get issue {issue_key}: {type(e).__name__}: {str(e)}")
raise ValueError(
f"Failed to get issue {issue_key}: {type(e).__name__}: {str(e)}"
)
async def search_jira_issues(
self, jql: str, max_results: int = 10
) -> List[JiraIssueResult]:
"""Search for issues using JQL via v3 REST API with pagination support"""
logger.info("Starting search_jira_issues...")
try:
# Use v3 API client
v3_client = self._get_v3_api_client()
# Collect all issues from all pages
all_issues = []
start_at = 0
page_size = min(max_results, 100) # Jira typically limits to 100 per page
while True:
logger.debug(f"Fetching page starting at {start_at} with page size {page_size}")
response_data = await v3_client.search_issues(
jql=jql,
start_at=start_at,
max_results=page_size
)
# Extract issues from current page
page_issues = response_data.get("issues", [])
all_issues.extend(page_issues)
logger.debug(f"Retrieved {len(page_issues)} issues from current page. Total so far: {len(all_issues)}")
# Check if we've reached the user's max_results limit
if len(all_issues) >= max_results:
# Trim to exact max_results if we exceeded it
all_issues = all_issues[:max_results]
logger.debug(f"Reached max_results limit of {max_results}, stopping pagination")
break
# Check if this is the last page according to API
is_last = response_data.get("isLast", True)
if is_last:
logger.debug("API indicates this is the last page, stopping pagination")
break
# If we have more pages, prepare for next iteration
start_at = len(all_issues) # Use actual number of issues retrieved so far
# Adjust page size for next request to not exceed max_results
remaining_needed = max_results - len(all_issues)
page_size = min(remaining_needed, 100)
# Return raw issues list for full JSON data
logger.info(f"Returning raw issues ({len(all_issues)}) for JQL: {jql}")
return all_issues
except Exception as e:
error_msg = f"Failed to search issues: {type(e).__name__}: {str(e)}"
logger.error(error_msg, exc_info=True)
print(error_msg)
raise ValueError(error_msg)
async def create_jira_issue(
self,
project: str,
summary: str,
description: str,
issue_type: str,
fields: Optional[Dict[str, Any]] = None,
) -> JiraIssueResult:
"""Create a new Jira issue using v3 REST API
Args:
project: Project key (e.g., 'PROJ')
summary: Issue summary/title
description: Issue description
issue_type: Issue type - common values include 'Bug', 'Task', 'Story', 'Epic', 'New Feature', 'Improvement'
Note: Available issue types vary by Jira instance and project
fields: Optional additional fields dictionary
Returns:
JiraIssueResult object with the created issue details
Example:
# Create a bug
await create_jira_issue(
project='PROJ',
summary='Login button not working',
description='The login button on the homepage is not responding to clicks',
issue_type='Bug'
)
# Create a task with custom fields
await create_jira_issue(
project='PROJ',
summary='Update documentation',
description='Update API documentation with new endpoints',
issue_type='Task',
fields={
'assignee': 'jsmith',
'labels': ['documentation', 'api'],
'priority': {'name': 'High'}
}
)
"""
logger.info("Starting create_jira_issue...")
try:
# Create a properly formatted issue dictionary
issue_dict = {}
# Process required fields first
# Project field - required
if isinstance(project, str):
issue_dict["project"] = {"key": project}
else:
issue_dict["project"] = project
# Summary - required
issue_dict["summary"] = summary
# Description
if description:
issue_dict["description"] = description
# Issue type - required, with validation for common issue types
logger.info(
f"Processing issue_type: '{issue_type}' (type: {type(issue_type)})"
)
common_types = [
"bug",
"task",
"story",
"epic",
"improvement",
"newfeature",
"new feature",
]
if isinstance(issue_type, str):
# Check for common issue type variants and fix case-sensitivity issues
issue_type_lower = issue_type.lower()
if issue_type_lower in common_types:
# Convert first letter to uppercase for standard Jira types
issue_type_proper = issue_type_lower.capitalize()
if (
issue_type_lower == "new feature"
or issue_type_lower == "newfeature"
):
issue_type_proper = "New Feature"
logger.info(
f"Note: Converting issue type from '{issue_type}' to '{issue_type_proper}'"
)
issue_dict["issuetype"] = {"name": issue_type_proper}
else:
# Use the type as provided - some Jira instances have custom types
issue_dict["issuetype"] = {"name": issue_type}
else:
issue_dict["issuetype"] = issue_type
# Add any additional fields with proper type handling
if fields:
for key, value in fields.items():
# Skip fields we've already processed
if key in [
"project",
"summary",
"description",
"issuetype",
"issue_type",
]:
continue
# Handle special fields that require specific formats
if key == "assignees" or key == "assignee":
# Convert string to array for assignees or proper format for assignee
if isinstance(value, str):
if key == "assignees":
issue_dict[key] = [value] if value else []
else: # assignee
issue_dict[key] = {"name": value} if value else None
elif isinstance(value, list) and key == "assignee" and value:
# If assignee is a list but should be a dict with name
issue_dict[key] = {"name": value[0]}
else:
issue_dict[key] = value
elif key == "labels":
# Convert string to array for labels
if isinstance(value, str):
issue_dict[key] = [value] if value else []
else:
issue_dict[key] = value
elif key == "milestone":
# Convert string to number for milestone
if isinstance(value, str) and value.isdigit():
issue_dict[key] = int(value)
else:
issue_dict[key] = value
else:
issue_dict[key] = value
# Use v3 API client
v3_client = self._get_v3_api_client()
response_data = await v3_client.create_issue(fields=issue_dict)
# Extract issue details from v3 API response
issue_key = response_data.get("key")
issue_id = response_data.get("id")
logger.info(f"Successfully created issue {issue_key} (ID: {issue_id})")
# Return JiraIssueResult with the created issue details
# For v3 API, we return what we have from the create response
return JiraIssueResult(
key=issue_key,
summary=summary, # Use the summary we provided
description=description, # Use the description we provided
status="Open", # Default status for new issues
)
except Exception as e:
error_msg = f"Failed to create issue: {type(e).__name__}: {str(e)}"
logger.error(error_msg, exc_info=True)
# Enhanced error handling for issue type errors
if "issuetype" in str(e).lower() or "issue type" in str(e).lower():
logger.info(
"Issue type error detected, trying to provide helpful suggestions..."
)
try:
project_key = (
project if isinstance(project, str) else project.get("key")
)
if project_key:
issue_types = await self.get_jira_project_issue_types(
project_key
)
type_names = [t.get("name") for t in issue_types]
logger.info(
f"Available issue types for project {project_key}: {', '.join(type_names)}"
)
# Try to find the closest match
attempted_type = issue_type
closest = None
attempted_lower = attempted_type.lower()
for t in type_names:
if (
attempted_lower in t.lower()
or t.lower() in attempted_lower
):
closest = t
break
if closest:
logger.info(
f"The closest match to '{attempted_type}' is '{closest}'"
)
error_msg += f" Available types: {', '.join(type_names)}. Closest match: '{closest}'"
else:
error_msg += f" Available types: {', '.join(type_names)}"
except Exception as fetch_error:
logger.error(f"Could not fetch issue types: {str(fetch_error)}")
raise ValueError(error_msg)
# Re-raise the exception with more details
if "issuetype" in error_message.lower():
raise ValueError(
f"Invalid issue type '{issue_dict.get('issuetype', {}).get('name', 'Unknown')}'. "
+ "Use get_jira_project_issue_types(project_key) to get valid types."
)
raise
return JiraIssueResult(
key=new_issue.key,
summary=new_issue.fields.summary,
description=new_issue.fields.description,
status=(
new_issue.fields.status.name
if hasattr(new_issue.fields, "status")
else None
),
)
except Exception as e:
print(f"Failed to create issue: {type(e).__name__}: {str(e)}")
raise ValueError(f"Failed to create issue: {type(e).__name__}: {str(e)}")
async def create_jira_issues(
self, field_list: List[Dict[str, Any]], prefetch: bool = True
) -> List[Dict[str, Any]]:
"""Bulk create new Jira issues using v3 REST API.
Parameters:
field_list (List[Dict[str, Any]]): a list of dicts each containing field names and the values to use.
Each dict is an individual issue to create.
prefetch (bool): True reloads the created issue Resource so all of its data is present in the value returned (Default: True)
Returns:
List[Dict[str, Any]]: List of created issues with their details
Issue Types:
Common issue types include: 'Bug', 'Task', 'Story', 'Epic', 'New Feature', 'Improvement'
Note: Available issue types vary by Jira instance and project
Example:
# Create multiple issues in bulk
await create_jira_issues([
{
'project': 'PROJ',
'summary': 'Implement user authentication',
'description': 'Add login and registration functionality',
'issue_type': 'Story' # Note: case-sensitive, match to your Jira instance types
},
{
'project': 'PROJ',
'summary': 'Fix navigation bar display on mobile',
'description': 'Navigation bar is not displaying correctly on mobile devices',
'issue_type': 'Bug',
'priority': {'name': 'High'},
'labels': ['mobile', 'ui']
}
])
"""
logger.info("Starting create_jira_issues...")
try:
# Process each field dict to ensure proper formatting for v3 API
processed_field_list = []
for fields in field_list:
# Create a properly formatted issue dictionary
issue_dict = {}
# Process required fields first to ensure they exist
# Project field - required
if "project" not in fields:
raise ValueError("Each issue must have a 'project' field")
project_value = fields["project"]
if isinstance(project_value, str):
issue_dict["project"] = {"key": project_value}
else:
issue_dict["project"] = project_value
# Summary field - required
if "summary" not in fields:
raise ValueError("Each issue must have a 'summary' field")
issue_dict["summary"] = fields["summary"]
# Description field - convert to ADF format for v3 API if it's a simple string
if "description" in fields:
description = fields["description"]
if isinstance(description, str):
# Convert simple string to Atlassian Document Format
issue_dict["description"] = {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": description
}
]
}
]
}
else:
# Assume it's already in ADF format
issue_dict["description"] = description
# Issue type field - required, handle both 'issuetype' and 'issue_type'
issue_type = None
if "issuetype" in fields:
issue_type = fields["issuetype"]
elif "issue_type" in fields:
issue_type = fields["issue_type"]
else:
raise ValueError(
"Each issue must have an 'issuetype' or 'issue_type' field"
)
# Check for common issue type variants and fix case-sensitivity issues
logger.debug(
f"Processing bulk issue_type: '{issue_type}' (type: {type(issue_type)})"
)
common_types = [
"bug",
"task",
"story",
"epic",
"improvement",
"newfeature",
"new feature",
]
if isinstance(issue_type, str):
issue_type_lower = issue_type.lower()
if issue_type_lower in common_types:
# Convert first letter to uppercase for standard Jira types
issue_type_proper = issue_type_lower.capitalize()
if (
issue_type_lower == "new feature"
or issue_type_lower == "newfeature"
):
issue_type_proper = "New Feature"
logger.debug(
f"Converting issue type from '{issue_type}' to '{issue_type_proper}'"
)
issue_dict["issuetype"] = {"name": issue_type_proper}
else:
# Use the type as provided - some Jira instances have custom types
issue_dict["issuetype"] = {"name": issue_type}
else:
issue_dict["issuetype"] = issue_type
# Process other fields
for key, value in fields.items():
if key in [
"project",
"summary",
"description",
"issuetype",
"issue_type",
]:
# Skip fields we've already processed
continue
# Handle special fields that require specific formats
if key == "assignees" or key == "assignee":
# Convert string to array for assignees or proper format for assignee
if isinstance(value, str):
if key == "assignees":
issue_dict[key] = [value] if value else []
else: # assignee
issue_dict[key] = {"name": value} if value else None
elif isinstance(value, list) and key == "assignee" and value:
# If assignee is a list but should be a dict with name
issue_dict[key] = {"name": value[0]}
else:
issue_dict[key] = value
elif key == "labels":
# Convert string to array for labels
if isinstance(value, str):
issue_dict[key] = [value] if value else []
else:
issue_dict[key] = value
elif key == "milestone":
# Convert string to number for milestone
if isinstance(value, str) and value.isdigit():
issue_dict[key] = int(value)
else:
issue_dict[key] = value
else:
issue_dict[key] = value
# Add to the field list in v3 API format
processed_field_list.append({"fields": issue_dict})
logger.debug(f"Processed field list: {json.dumps(processed_field_list, indent=2)}")
# Use v3 API client
v3_client = self._get_v3_api_client()
# Call the bulk create API
response_data = await v3_client.bulk_create_issues(processed_field_list)
# Process the results to maintain compatibility with existing interface
processed_results = []
# Handle successful issues
if "issues" in response_data:
for issue in response_data["issues"]:
processed_results.append({
"key": issue.get("key"),
"id": issue.get("id"),
"self": issue.get("self"),
"success": True,
})
# Handle errors
if "errors" in response_data:
for error in response_data["errors"]:
processed_results.append({
"error": error,
"success": False,
})
logger.info(f"Successfully processed {len(processed_results)} issue creations")
return processed_results
except Exception as e:
error_msg = f"Failed to create issues in bulk: {type(e).__name__}: {str(e)}"
logger.error(error_msg, exc_info=True)
print(error_msg)
raise ValueError(error_msg)
async def add_jira_comment(self, issue_key: str, comment: str) -> Dict[str, Any]:
"""Add a comment to an issue using v3 REST API"""
logger.info("Starting add_jira_comment...")
try:
# Use v3 API client
v3_client = self._get_v3_api_client()
comment_result = await v3_client.add_comment(
issue_id_or_key=issue_key,
comment=comment,
)
# Extract useful information from the v3 API response
response_data = {
"id": comment_result.get("id"),
"body": comment_result.get("body", {}),
"created": comment_result.get("created"),
"updated": comment_result.get("updated"),
}
# Extract author information if available
if "author" in comment_result:
author = comment_result["author"]
response_data["author"] = author.get("displayName", "Unknown")
else:
response_data["author"] = "Unknown"
logger.info(f"Successfully added comment to issue {issue_key}")
return response_data
except Exception as e:
error_msg = (
f"Failed to add comment to {issue_key}: {type(e).__name__}: {str(e)}"
)
logger.error(error_msg, exc_info=True)
print(error_msg)
raise ValueError(error_msg)
async def get_jira_transitions(self, issue_key: str) -> List[JiraTransitionResult]:
"""Get available transitions for an issue using v3 REST API"""
logger.info("Starting get_jira_transitions...")
try:
# Use v3 API client
v3_client = self._get_v3_api_client()
response_data = await v3_client.get_transitions(issue_id_or_key=issue_key)
# Extract transitions from response
transitions = response_data.get("transitions", [])
# Convert to JiraTransitionResult objects maintaining compatibility
results = [
JiraTransitionResult(id=transition["id"], name=transition["name"])
for transition in transitions
]
logger.info(f"Found {len(results)} transitions for issue {issue_key}")
return results
except Exception as e:
error_msg = f"Failed to get transitions for {issue_key}: {type(e).__name__}: {str(e)}"
logger.error(error_msg, exc_info=True)
print(error_msg)
raise ValueError(error_msg)
async def transition_jira_issue(
self,
issue_key: str,
transition_id: str,
comment: Optional[str] = None,
fields: Optional[Dict[str, Any]] = None,
) -> bool:
"""Transition an issue to a new state using v3 REST API"""
logger.info("Starting transition_jira_issue...")
try:
# Use v3 API client
v3_client = self._get_v3_api_client()
await v3_client.transition_issue(
issue_id_or_key=issue_key,
transition_id=transition_id,
fields=fields,
comment=comment,
)
logger.info(
f"Successfully transitioned issue {issue_key} to transition {transition_id}"
)
return True
except Exception as e:
error_msg = (
f"Failed to transition {issue_key}: {type(e).__name__}: {str(e)}"
)
logger.error(error_msg, exc_info=True)
print(error_msg)
raise ValueError(error_msg)
async def get_jira_project_issue_types(
self, project_key: str
) -> List[Dict[str, Any]]:
"""Get all available issue types for a specific project using v3 REST API
Args:
project_key: The project key (e.g., 'PROJ') - kept for backward compatibility,
but the new API returns all issue types for the user
Returns:
List of issue type dictionaries with name, id, and description
Example:
get_jira_project_issue_types('PROJ') # Returns all issue types accessible to user
"""
logger.info("Starting get_jira_project_issue_types...")
try:
# Use v3 API client to get all issue types
v3_client = self._get_v3_api_client()
response_data = await v3_client.get_issue_types()
# The new API returns the issue types directly as a list, not wrapped in an object
issue_types_data = (
response_data
if isinstance(response_data, list)
else response_data.get("issueTypes", [])
)
# Convert to the expected format maintaining compatibility
issue_types = []
for issuetype in issue_types_data:
issue_types.append(
{
"id": issuetype.get("id"),
"name": issuetype.get("name"),
"description": issuetype.get("description"),
}
)
logger.info(
f"Found {len(issue_types)} issue types (project_key: {project_key})"
)
return issue_types
except Exception as e:
error_msg = f"Failed to get issue types: {type(e).__name__}: {str(e)}"
logger.error(error_msg, exc_info=True)
print(error_msg)
raise ValueError(error_msg)
async def create_jira_project(
self,
key: str,
name: Optional[str] = None,
assignee: Optional[str] = None,
ptype: str = "software",
template_name: Optional[str] = None,
avatarId: Optional[int] = None,
issueSecurityScheme: Optional[int] = None,
permissionScheme: Optional[int] = None,
projectCategory: Optional[int] = None,
notificationScheme: Optional[int] = None,
categoryId: Optional[int] = None,
url: str = "",
) -> JiraProjectResult:
"""Create a project using Jira's v3 REST API
Args:
key: Project key (required) - must match Jira project key requirements
name: Project name (defaults to key if not provided)
assignee: Lead account ID or username
ptype: Project type key ('software', 'business', 'service_desk')
template_name: Project template key for creating from templates
avatarId: ID of the avatar to use for the project
issueSecurityScheme: ID of the issue security scheme
permissionScheme: ID of the permission scheme
projectCategory: ID of the project category
notificationScheme: ID of the notification scheme
categoryId: Same as projectCategory (alternative parameter)
url: URL for project information/documentation
Returns:
JiraProjectResult with the created project details
Note:
This method uses Jira's v3 REST API endpoint: POST /rest/api/3/project
Example:
# Create a basic software project
create_jira_project(
key='PROJ',
name='My Project',
ptype='software'
)
# Create with template
create_jira_project(
key='BUSI',
name='Business Project',
ptype='business',
template_name='com.atlassian.jira-core-project-templates:jira-core-simplified-task-tracking'
)
"""
if not key:
raise ValueError("Project key is required")
try:
# Get the v3 API client
v3_client = self._get_v3_api_client()
# Create project using v3 API
response_data = await v3_client.create_project(
key=key,
name=name,
assignee=assignee,
ptype=ptype,
template_name=template_name,
avatarId=avatarId,
issueSecurityScheme=issueSecurityScheme,
permissionScheme=permissionScheme,
projectCategory=projectCategory,
notificationScheme=notificationScheme,
categoryId=categoryId,
url=url,
)
# Extract project details from response
project_id = response_data.get("id", "0")
project_key = response_data.get("key", key)
# For lead information, we would need to make another API call
# For now, return None for lead as it's optional in our result model
lead = None
return JiraProjectResult(
key=project_key, name=name or key, id=str(project_id), lead=lead
)
except Exception as e:
error_msg = str(e)
print(f"Error creating project with v3 API: {error_msg}")
raise ValueError(f"Error creating project: {error_msg}")
async def serve(
server_url: Optional[str] = None,
auth_method: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
token: Optional[str] = None,
) -> None:
server = Server("mcp-jira")
jira_server = JiraServer(
server_url=server_url,
auth_method=auth_method,
username=username,
password=password,
token=token,
)
@server.list_tools()
async def list_tools() -> list[Tool]:
"""List available Jira tools."""
return [
Tool(
name=JiraTools.GET_PROJECTS.value,
description="Get all accessible Jira projects",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name=JiraTools.GET_ISSUE.value,
description="Get details for a specific Jira issue by key",
inputSchema={
"type": "object",
"properties": {
"issue_key": {
"type": "string",
"description": "The issue key (e.g., PROJECT-123)",
}
},
"required": ["issue_key"],
},
),
Tool(
name=JiraTools.SEARCH_ISSUES.value,
description="Search for Jira issues using JQL (Jira Query Language)",
inputSchema={
"type": "object",
"properties": {
"jql": {
"type": "string",
"description": "JQL query string (e.g., 'project = MYPROJ AND status = \"In Progress\"')",
},
"max_results": {
"type": "integer",
"description": "Maximum number of results to return (default: 10)",
},
},
"required": ["jql"],
},
),
Tool(
name=JiraTools.CREATE_ISSUE.value,
description="Create a new Jira issue. Common issue types include 'Bug', 'Task', 'Story', 'Epic' (capitalization handled automatically)",
inputSchema={
"type": "object",
"properties": {
"project": {
"type": "string",
"description": "Project key (e.g., 'MYPROJ')",
},
"summary": {
"type": "string",
"description": "Issue summary/title",
},
"description": {
"type": "string",
"description": "Issue description",
},
"issue_type": {
"type": "string",
"description": "Issue type (e.g., 'Bug', 'Task', 'Story', 'Epic', 'New Feature', 'Improvement'). IMPORTANT: Types are case-sensitive and vary by Jira instance.",
},
"fields": {
"type": "object",
"description": "Additional fields for the issue (optional)",
},
},
"required": ["project", "summary", "description", "issue_type"],
},
),
Tool(
name=JiraTools.CREATE_ISSUES.value,
description="Bulk create new Jira issues. IMPORTANT: For 'issue_type', use the exact case-sensitive types in your Jira instance (common: 'Bug', 'Task', 'Story', 'Epic')",
inputSchema={
"type": "object",
"properties": {
"field_list": {
"type": "array",
"description": "A list of field dictionaries, each representing an issue to create",
"items": {
"type": "object",
"description": "Field dictionary for a single issue",
},
},
"prefetch": {
"type": "boolean",
"description": "Whether to reload created issues (default: true)",
},
},
"required": ["field_list"],
},
),
Tool(
name=JiraTools.ADD_COMMENT.value,
description="Add a comment to a Jira issue",
inputSchema={
"type": "object",
"properties": {
"issue_key": {
"type": "string",
"description": "The issue key (e.g., PROJECT-123)",
},
"comment": {
"type": "string",
"description": "The comment text",
},
},
"required": ["issue_key", "comment"],
},
),
Tool(
name=JiraTools.GET_TRANSITIONS.value,
description="Get available workflow transitions for a Jira issue",
inputSchema={
"type": "object",
"properties": {
"issue_key": {
"type": "string",
"description": "The issue key (e.g., PROJECT-123)",
}
},
"required": ["issue_key"],
},
),
Tool(
name=JiraTools.TRANSITION_ISSUE.value,
description="Transition a Jira issue to a new status",
inputSchema={
"type": "object",
"properties": {
"issue_key": {
"type": "string",
"description": "The issue key (e.g., PROJECT-123)",
},
"transition_id": {
"type": "string",
"description": "ID of the transition to perform (get IDs using get_transitions)",
},
"comment": {
"type": "string",
"description": "Comment to add during transition (optional)",
},
"fields": {
"type": "object",
"description": "Additional fields to update during transition (optional)",
},
},
"required": ["issue_key", "transition_id"],
},
),
Tool(
name=JiraTools.GET_PROJECT_ISSUE_TYPES.value,
description="Get all available issue types for a specific Jira project",
inputSchema={
"type": "object",
"properties": {
"project_key": {
"type": "string",
"description": "The project key (e.g., 'MYPROJ')",
}
},
"required": ["project_key"],
},
),
Tool(
name=JiraTools.CREATE_PROJECT.value,
description="Create a new Jira project using v3 REST API",
inputSchema={
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "Mandatory. Must match Jira project key requirements, usually only 2-10 uppercase characters.",
},
"name": {
"type": "string",
"description": "If not specified it will use the key value.",
},
"assignee": {
"type": "string",
"description": "Lead account ID or username (mapped to leadAccountId in v3 API).",
},
"ptype": {
"type": "string",
"description": "Project type key: 'software', 'business', or 'service_desk'. Defaults to 'software'.",
},
"template_name": {
"type": "string",
"description": "Project template key for creating from templates (mapped to projectTemplateKey in v3 API).",
},
"avatarId": {
"type": ["integer", "string"],
"description": "ID of the avatar to use for the project.",
},
"issueSecurityScheme": {
"type": ["integer", "string"],
"description": "Determines the security scheme to use.",
},
"permissionScheme": {
"type": ["integer", "string"],
"description": "Determines the permission scheme to use.",
},
"projectCategory": {
"type": ["integer", "string"],
"description": "Determines the category the project belongs to.",
},
"notificationScheme": {
"type": ["integer", "string"],
"description": "Determines the notification scheme to use. Default is None.",
},
"categoryId": {
"type": ["integer", "string"],
"description": "Same as projectCategory. Can be used interchangeably.",
},
"url": {
"type": "string",
"description": "A link to information about the project, such as documentation.",
},
},
"required": ["key"],
},
),
]
@server.call_tool()
async def call_tool(
name: str, arguments: dict
) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
"""Handle tool calls for Jira operations."""
logger.info(f"call_tool invoked. Tool: '{name}', Arguments: {arguments}")
try:
result: Any
match name:
case JiraTools.GET_PROJECTS.value:
logger.info("About to AWAIT jira_server.get_jira_projects...")
result = await jira_server.get_jira_projects()
logger.info(
f"COMPLETED await jira_server.get_jira_projects. Result has {len(result)} items."
)
case JiraTools.GET_ISSUE.value:
logger.info("Calling synchronous tool get_jira_issue...")
issue_key = arguments.get("issue_key")
if not issue_key:
raise ValueError("Missing required argument: issue_key")
result = jira_server.get_jira_issue(issue_key)
logger.info("Synchronous tool get_jira_issue completed.")
case JiraTools.SEARCH_ISSUES.value:
logger.info("Calling async tool search_jira_issues...")
jql = arguments.get("jql")
if not jql:
raise ValueError("Missing required argument: jql")
max_results = arguments.get("max_results", 10)
result = await jira_server.search_jira_issues(jql, max_results)
logger.info("Async tool search_jira_issues completed.")
case JiraTools.CREATE_ISSUE.value:
logger.info("About to AWAIT jira_server.create_jira_issue...")
required_args = ["project", "summary", "description", "issue_type"]
if not all(arg in arguments for arg in required_args):
missing = [arg for arg in required_args if arg not in arguments]
raise ValueError(
f"Missing required arguments: {', '.join(missing)}"
)
result = await jira_server.create_jira_issue(
arguments["project"],
arguments["summary"],
arguments["description"],
arguments["issue_type"],
arguments.get("fields", {}),
)
logger.info("COMPLETED await jira_server.create_jira_issue.")
case JiraTools.CREATE_ISSUES.value:
logger.info("Calling async tool create_jira_issues...")
field_list = arguments.get("field_list")
if not field_list:
raise ValueError("Missing required argument: field_list")
prefetch = arguments.get("prefetch", True)
result = await jira_server.create_jira_issues(field_list, prefetch)
logger.info("Async tool create_jira_issues completed.")
case JiraTools.ADD_COMMENT.value:
logger.info("About to AWAIT jira_server.add_jira_comment...")
issue_key = arguments.get("issue_key")
comment_text = arguments.get("comment") or arguments.get("body")
if not issue_key or not comment_text:
raise ValueError(
"Missing required arguments: issue_key and comment (or body)"
)
result = await jira_server.add_jira_comment(issue_key, comment_text)
logger.info("COMPLETED await jira_server.add_jira_comment.")
case JiraTools.GET_TRANSITIONS.value:
logger.info("About to AWAIT jira_server.get_jira_transitions...")
issue_key = arguments.get("issue_key")
if not issue_key:
raise ValueError("Missing required argument: issue_key")
result = await jira_server.get_jira_transitions(issue_key)
logger.info("COMPLETED await jira_server.get_jira_transitions.")
case JiraTools.TRANSITION_ISSUE.value:
logger.info("Calling async tool transition_jira_issue...")
issue_key = arguments.get("issue_key")
transition_id = arguments.get("transition_id")
if not issue_key or not transition_id:
raise ValueError(
"Missing required arguments: issue_key and transition_id"
)
comment = arguments.get("comment")
fields = arguments.get("fields")
result = await jira_server.transition_jira_issue(
issue_key, transition_id, comment, fields
)
logger.info("Async tool transition_jira_issue completed.")
case JiraTools.GET_PROJECT_ISSUE_TYPES.value:
logger.info(
"Calling asynchronous tool get_jira_project_issue_types..."
)
project_key = arguments.get("project_key")
if not project_key:
raise ValueError("Missing required argument: project_key")
result = await jira_server.get_jira_project_issue_types(project_key)
logger.info(
"Asynchronous tool get_jira_project_issue_types completed."
)
case JiraTools.CREATE_PROJECT.value:
logger.info("About to AWAIT jira_server.create_jira_project...")
key = arguments.get("key")
if not key:
raise ValueError("Missing required argument: key")
# Type conversion logic from original code
for int_key in [
"avatarId",
"issueSecurityScheme",
"permissionScheme",
"projectCategory",
"notificationScheme",
"categoryId",
]:
if (
int_key in arguments
and isinstance(arguments[int_key], str)
and arguments[int_key].isdigit()
):
arguments[int_key] = int(arguments[int_key])
result = await jira_server.create_jira_project(**arguments)
logger.info("COMPLETED await jira_server.create_jira_project.")
case _:
raise ValueError(f"Unknown tool: {name}")
logger.debug("Serializing result to JSON...")
# Handle serialization properly for different result types
if isinstance(result, list):
# If it's a list, check each item individually
serialized_result = []
for item in result:
if hasattr(item, "model_dump"):
serialized_result.append(item.model_dump())
else:
# It's already a dict or basic type
serialized_result.append(item)
else:
# Single item result
if hasattr(result, "model_dump"):
serialized_result = result.model_dump()
else:
# It's already a dict or basic type
serialized_result = result
json_result = json.dumps(serialized_result, indent=2)
return [TextContent(type="text", text=json_result)]
except Exception as e:
logger.critical(
f"FATAL error in call_tool for tool '{name}'", exc_info=True
)
return [
TextContent(
type="text",
text=json.dumps(
{
"error": f"Error in tool '{name}': {type(e).__name__}: {str(e)}"
}
),
)
]
options = server.create_initialization_options()
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, options)