This is page 12 of 13. Use http://codebase.md/sooperset/mcp-atlassian?format=md&lines=true&page={x} to view the full context.

# Directory Structure

```
├── .devcontainer
│   ├── devcontainer.json
│   ├── Dockerfile
│   ├── post-create.sh
│   └── post-start.sh
├── .dockerignore
├── .env.example
├── .github
│   ├── ISSUE_TEMPLATE
│   │   ├── bug_report.yml
│   │   ├── config.yml
│   │   └── feature_request.yml
│   ├── pull_request_template.md
│   └── workflows
│       ├── docker-publish.yml
│       ├── lint.yml
│       ├── publish.yml
│       ├── stale.yml
│       └── tests.yml
├── .gitignore
├── .pre-commit-config.yaml
├── AGENTS.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── pyproject.toml
├── README.md
├── scripts
│   ├── oauth_authorize.py
│   └── test_with_real_data.sh
├── SECURITY.md
├── smithery.yaml
├── src
│   └── mcp_atlassian
│       ├── __init__.py
│       ├── confluence
│       │   ├── __init__.py
│       │   ├── client.py
│       │   ├── comments.py
│       │   ├── config.py
│       │   ├── constants.py
│       │   ├── labels.py
│       │   ├── pages.py
│       │   ├── search.py
│       │   ├── spaces.py
│       │   ├── users.py
│       │   ├── utils.py
│       │   └── v2_adapter.py
│       ├── exceptions.py
│       ├── jira
│       │   ├── __init__.py
│       │   ├── attachments.py
│       │   ├── boards.py
│       │   ├── client.py
│       │   ├── comments.py
│       │   ├── config.py
│       │   ├── constants.py
│       │   ├── epics.py
│       │   ├── fields.py
│       │   ├── formatting.py
│       │   ├── issues.py
│       │   ├── links.py
│       │   ├── projects.py
│       │   ├── protocols.py
│       │   ├── search.py
│       │   ├── sprints.py
│       │   ├── transitions.py
│       │   ├── users.py
│       │   └── worklog.py
│       ├── models
│       │   ├── __init__.py
│       │   ├── base.py
│       │   ├── confluence
│       │   │   ├── __init__.py
│       │   │   ├── comment.py
│       │   │   ├── common.py
│       │   │   ├── label.py
│       │   │   ├── page.py
│       │   │   ├── search.py
│       │   │   ├── space.py
│       │   │   └── user_search.py
│       │   ├── constants.py
│       │   └── jira
│       │       ├── __init__.py
│       │       ├── agile.py
│       │       ├── comment.py
│       │       ├── common.py
│       │       ├── issue.py
│       │       ├── link.py
│       │       ├── project.py
│       │       ├── search.py
│       │       ├── version.py
│       │       ├── workflow.py
│       │       └── worklog.py
│       ├── preprocessing
│       │   ├── __init__.py
│       │   ├── base.py
│       │   ├── confluence.py
│       │   └── jira.py
│       ├── servers
│       │   ├── __init__.py
│       │   ├── confluence.py
│       │   ├── context.py
│       │   ├── dependencies.py
│       │   ├── jira.py
│       │   └── main.py
│       └── utils
│           ├── __init__.py
│           ├── date.py
│           ├── decorators.py
│           ├── env.py
│           ├── environment.py
│           ├── io.py
│           ├── lifecycle.py
│           ├── logging.py
│           ├── oauth_setup.py
│           ├── oauth.py
│           ├── ssl.py
│           ├── tools.py
│           └── urls.py
├── tests
│   ├── __init__.py
│   ├── conftest.py
│   ├── fixtures
│   │   ├── __init__.py
│   │   ├── confluence_mocks.py
│   │   └── jira_mocks.py
│   ├── integration
│   │   ├── conftest.py
│   │   ├── README.md
│   │   ├── test_authentication.py
│   │   ├── test_content_processing.py
│   │   ├── test_cross_service.py
│   │   ├── test_mcp_protocol.py
│   │   ├── test_proxy.py
│   │   ├── test_real_api.py
│   │   ├── test_ssl_verification.py
│   │   ├── test_stdin_monitoring_fix.py
│   │   └── test_transport_lifecycle.py
│   ├── README.md
│   ├── test_preprocessing.py
│   ├── test_real_api_validation.py
│   ├── unit
│   │   ├── confluence
│   │   │   ├── __init__.py
│   │   │   ├── conftest.py
│   │   │   ├── test_client_oauth.py
│   │   │   ├── test_client.py
│   │   │   ├── test_comments.py
│   │   │   ├── test_config.py
│   │   │   ├── test_constants.py
│   │   │   ├── test_custom_headers.py
│   │   │   ├── test_labels.py
│   │   │   ├── test_pages.py
│   │   │   ├── test_search.py
│   │   │   ├── test_spaces.py
│   │   │   ├── test_users.py
│   │   │   ├── test_utils.py
│   │   │   └── test_v2_adapter.py
│   │   ├── jira
│   │   │   ├── conftest.py
│   │   │   ├── test_attachments.py
│   │   │   ├── test_boards.py
│   │   │   ├── test_client_oauth.py
│   │   │   ├── test_client.py
│   │   │   ├── test_comments.py
│   │   │   ├── test_config.py
│   │   │   ├── test_constants.py
│   │   │   ├── test_custom_headers.py
│   │   │   ├── test_epics.py
│   │   │   ├── test_fields.py
│   │   │   ├── test_formatting.py
│   │   │   ├── test_issues_markdown.py
│   │   │   ├── test_issues.py
│   │   │   ├── test_links.py
│   │   │   ├── test_projects.py
│   │   │   ├── test_protocols.py
│   │   │   ├── test_search.py
│   │   │   ├── test_sprints.py
│   │   │   ├── test_transitions.py
│   │   │   ├── test_users.py
│   │   │   └── test_worklog.py
│   │   ├── models
│   │   │   ├── __init__.py
│   │   │   ├── conftest.py
│   │   │   ├── test_base_models.py
│   │   │   ├── test_confluence_models.py
│   │   │   ├── test_constants.py
│   │   │   └── test_jira_models.py
│   │   ├── servers
│   │   │   ├── __init__.py
│   │   │   ├── test_confluence_server.py
│   │   │   ├── test_context.py
│   │   │   ├── test_dependencies.py
│   │   │   ├── test_jira_server.py
│   │   │   └── test_main_server.py
│   │   ├── test_exceptions.py
│   │   ├── test_main_transport_selection.py
│   │   └── utils
│   │       ├── __init__.py
│   │       ├── test_custom_headers.py
│   │       ├── test_date.py
│   │       ├── test_decorators.py
│   │       ├── test_env.py
│   │       ├── test_environment.py
│   │       ├── test_io.py
│   │       ├── test_lifecycle.py
│   │       ├── test_logging.py
│   │       ├── test_masking.py
│   │       ├── test_oauth_setup.py
│   │       ├── test_oauth.py
│   │       ├── test_ssl.py
│   │       ├── test_tools.py
│   │       └── test_urls.py
│   └── utils
│       ├── __init__.py
│       ├── assertions.py
│       ├── base.py
│       ├── factories.py
│       └── mocks.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/src/mcp_atlassian/servers/jira.py:
--------------------------------------------------------------------------------

```python
   1 | """Jira FastMCP server instance and tool definitions."""
   2 | 
   3 | import json
   4 | import logging
   5 | from typing import Annotated, Any
   6 | 
   7 | from fastmcp import Context, FastMCP
   8 | from pydantic import Field
   9 | from requests.exceptions import HTTPError
  10 | 
  11 | from mcp_atlassian.exceptions import MCPAtlassianAuthenticationError
  12 | from mcp_atlassian.jira.constants import DEFAULT_READ_JIRA_FIELDS
  13 | from mcp_atlassian.models.jira.common import JiraUser
  14 | from mcp_atlassian.servers.dependencies import get_jira_fetcher
  15 | from mcp_atlassian.utils.decorators import check_write_access
  16 | 
  17 | logger = logging.getLogger(__name__)
  18 | 
  19 | jira_mcp = FastMCP(
  20 |     name="Jira MCP Service",
  21 |     description="Provides tools for interacting with Atlassian Jira.",
  22 | )
  23 | 
  24 | 
  25 | @jira_mcp.tool(tags={"jira", "read"})
  26 | async def get_user_profile(
  27 |     ctx: Context,
  28 |     user_identifier: Annotated[
  29 |         str,
  30 |         Field(
  31 |             description="Identifier for the user (e.g., email address 'user@example.com', username 'johndoe', account ID 'accountid:...', or key for Server/DC)."
  32 |         ),
  33 |     ],
  34 | ) -> str:
  35 |     """
  36 |     Retrieve profile information for a specific Jira user.
  37 | 
  38 |     Args:
  39 |         ctx: The FastMCP context.
  40 |         user_identifier: User identifier (email, username, key, or account ID).
  41 | 
  42 |     Returns:
  43 |         JSON string representing the Jira user profile object, or an error object if not found.
  44 | 
  45 |     Raises:
  46 |         ValueError: If the Jira client is not configured or available.
  47 |     """
  48 |     jira = await get_jira_fetcher(ctx)
  49 |     try:
  50 |         user: JiraUser = jira.get_user_profile_by_identifier(user_identifier)
  51 |         result = user.to_simplified_dict()
  52 |         response_data = {"success": True, "user": result}
  53 |     except Exception as e:
  54 |         error_message = ""
  55 |         log_level = logging.ERROR
  56 |         if isinstance(e, ValueError) and "not found" in str(e).lower():
  57 |             log_level = logging.WARNING
  58 |             error_message = str(e)
  59 |         elif isinstance(e, MCPAtlassianAuthenticationError):
  60 |             error_message = f"Authentication/Permission Error: {str(e)}"
  61 |         elif isinstance(e, OSError | HTTPError):
  62 |             error_message = f"Network or API Error: {str(e)}"
  63 |         else:
  64 |             error_message = (
  65 |                 "An unexpected error occurred while fetching the user profile."
  66 |             )
  67 |             logger.exception(
  68 |                 f"Unexpected error in get_user_profile for '{user_identifier}':"
  69 |             )
  70 |         error_result = {
  71 |             "success": False,
  72 |             "error": str(e),
  73 |             "user_identifier": user_identifier,
  74 |         }
  75 |         logger.log(
  76 |             log_level,
  77 |             f"get_user_profile failed for '{user_identifier}': {error_message}",
  78 |         )
  79 |         response_data = error_result
  80 |     return json.dumps(response_data, indent=2, ensure_ascii=False)
  81 | 
  82 | 
  83 | @jira_mcp.tool(tags={"jira", "read"})
  84 | async def get_issue(
  85 |     ctx: Context,
  86 |     issue_key: Annotated[str, Field(description="Jira issue key (e.g., 'PROJ-123')")],
  87 |     fields: Annotated[
  88 |         str,
  89 |         Field(
  90 |             description=(
  91 |                 "(Optional) Comma-separated list of fields to return (e.g., 'summary,status,customfield_10010'). "
  92 |                 "You may also provide a single field as a string (e.g., 'duedate'). "
  93 |                 "Use '*all' for all fields (including custom fields), or omit for essential fields only."
  94 |             ),
  95 |             default=",".join(DEFAULT_READ_JIRA_FIELDS),
  96 |         ),
  97 |     ] = ",".join(DEFAULT_READ_JIRA_FIELDS),
  98 |     expand: Annotated[
  99 |         str | None,
 100 |         Field(
 101 |             description=(
 102 |                 "(Optional) Fields to expand. Examples: 'renderedFields' (for rendered content), "
 103 |                 "'transitions' (for available status transitions), 'changelog' (for history)"
 104 |             ),
 105 |             default=None,
 106 |         ),
 107 |     ] = None,
 108 |     comment_limit: Annotated[
 109 |         int,
 110 |         Field(
 111 |             description="Maximum number of comments to include (0 or null for no comments)",
 112 |             default=10,
 113 |             ge=0,
 114 |             le=100,
 115 |         ),
 116 |     ] = 10,
 117 |     properties: Annotated[
 118 |         str | None,
 119 |         Field(
 120 |             description="(Optional) A comma-separated list of issue properties to return",
 121 |             default=None,
 122 |         ),
 123 |     ] = None,
 124 |     update_history: Annotated[
 125 |         bool,
 126 |         Field(
 127 |             description="Whether to update the issue view history for the requesting user",
 128 |             default=True,
 129 |         ),
 130 |     ] = True,
 131 | ) -> str:
 132 |     """Get details of a specific Jira issue including its Epic links and relationship information.
 133 | 
 134 |     Args:
 135 |         ctx: The FastMCP context.
 136 |         issue_key: Jira issue key.
 137 |         fields: Comma-separated list of fields to return (e.g., 'summary,status,customfield_10010'), a single field as a string (e.g., 'duedate'), '*all' for all fields, or omitted for essentials.
 138 |         expand: Optional fields to expand.
 139 |         comment_limit: Maximum number of comments.
 140 |         properties: Issue properties to return.
 141 |         update_history: Whether to update issue view history.
 142 | 
 143 |     Returns:
 144 |         JSON string representing the Jira issue object.
 145 | 
 146 |     Raises:
 147 |         ValueError: If the Jira client is not configured or available.
 148 |     """
 149 |     jira = await get_jira_fetcher(ctx)
 150 |     fields_list: str | list[str] | None = fields
 151 |     if fields and fields != "*all":
 152 |         fields_list = [f.strip() for f in fields.split(",")]
 153 | 
 154 |     issue = jira.get_issue(
 155 |         issue_key=issue_key,
 156 |         fields=fields_list,
 157 |         expand=expand,
 158 |         comment_limit=comment_limit,
 159 |         properties=properties.split(",") if properties else None,
 160 |         update_history=update_history,
 161 |     )
 162 |     result = issue.to_simplified_dict()
 163 |     return json.dumps(result, indent=2, ensure_ascii=False)
 164 | 
 165 | 
 166 | @jira_mcp.tool(tags={"jira", "read"})
 167 | async def search(
 168 |     ctx: Context,
 169 |     jql: Annotated[
 170 |         str,
 171 |         Field(
 172 |             description=(
 173 |                 "JQL query string (Jira Query Language). Examples:\n"
 174 |                 '- Find Epics: "issuetype = Epic AND project = PROJ"\n'
 175 |                 '- Find issues in Epic: "parent = PROJ-123"\n'
 176 |                 "- Find by status: \"status = 'In Progress' AND project = PROJ\"\n"
 177 |                 '- Find by assignee: "assignee = currentUser()"\n'
 178 |                 '- Find recently updated: "updated >= -7d AND project = PROJ"\n'
 179 |                 '- Find by label: "labels = frontend AND project = PROJ"\n'
 180 |                 '- Find by priority: "priority = High AND project = PROJ"'
 181 |             )
 182 |         ),
 183 |     ],
 184 |     fields: Annotated[
 185 |         str,
 186 |         Field(
 187 |             description=(
 188 |                 "(Optional) Comma-separated fields to return in the results. "
 189 |                 "Use '*all' for all fields, or specify individual fields like 'summary,status,assignee,priority'"
 190 |             ),
 191 |             default=",".join(DEFAULT_READ_JIRA_FIELDS),
 192 |         ),
 193 |     ] = ",".join(DEFAULT_READ_JIRA_FIELDS),
 194 |     limit: Annotated[
 195 |         int,
 196 |         Field(description="Maximum number of results (1-50)", default=10, ge=1),
 197 |     ] = 10,
 198 |     start_at: Annotated[
 199 |         int,
 200 |         Field(description="Starting index for pagination (0-based)", default=0, ge=0),
 201 |     ] = 0,
 202 |     projects_filter: Annotated[
 203 |         str | None,
 204 |         Field(
 205 |             description=(
 206 |                 "(Optional) Comma-separated list of project keys to filter results by. "
 207 |                 "Overrides the environment variable JIRA_PROJECTS_FILTER if provided."
 208 |             ),
 209 |             default=None,
 210 |         ),
 211 |     ] = None,
 212 |     expand: Annotated[
 213 |         str | None,
 214 |         Field(
 215 |             description=(
 216 |                 "(Optional) fields to expand. Examples: 'renderedFields', 'transitions', 'changelog'"
 217 |             ),
 218 |             default=None,
 219 |         ),
 220 |     ] = None,
 221 | ) -> str:
 222 |     """Search Jira issues using JQL (Jira Query Language).
 223 | 
 224 |     Args:
 225 |         ctx: The FastMCP context.
 226 |         jql: JQL query string.
 227 |         fields: Comma-separated fields to return.
 228 |         limit: Maximum number of results.
 229 |         start_at: Starting index for pagination.
 230 |         projects_filter: Comma-separated list of project keys to filter by.
 231 |         expand: Optional fields to expand.
 232 | 
 233 |     Returns:
 234 |         JSON string representing the search results including pagination info.
 235 |     """
 236 |     jira = await get_jira_fetcher(ctx)
 237 |     fields_list: str | list[str] | None = fields
 238 |     if fields and fields != "*all":
 239 |         fields_list = [f.strip() for f in fields.split(",")]
 240 | 
 241 |     search_result = jira.search_issues(
 242 |         jql=jql,
 243 |         fields=fields_list,
 244 |         limit=limit,
 245 |         start=start_at,
 246 |         expand=expand,
 247 |         projects_filter=projects_filter,
 248 |     )
 249 |     result = search_result.to_simplified_dict()
 250 |     return json.dumps(result, indent=2, ensure_ascii=False)
 251 | 
 252 | 
 253 | @jira_mcp.tool(tags={"jira", "read"})
 254 | async def search_fields(
 255 |     ctx: Context,
 256 |     keyword: Annotated[
 257 |         str,
 258 |         Field(
 259 |             description="Keyword for fuzzy search. If left empty, lists the first 'limit' available fields in their default order.",
 260 |             default="",
 261 |         ),
 262 |     ] = "",
 263 |     limit: Annotated[
 264 |         int, Field(description="Maximum number of results", default=10, ge=1)
 265 |     ] = 10,
 266 |     refresh: Annotated[
 267 |         bool,
 268 |         Field(description="Whether to force refresh the field list", default=False),
 269 |     ] = False,
 270 | ) -> str:
 271 |     """Search Jira fields by keyword with fuzzy match.
 272 | 
 273 |     Args:
 274 |         ctx: The FastMCP context.
 275 |         keyword: Keyword for fuzzy search.
 276 |         limit: Maximum number of results.
 277 |         refresh: Whether to force refresh the field list.
 278 | 
 279 |     Returns:
 280 |         JSON string representing a list of matching field definitions.
 281 |     """
 282 |     jira = await get_jira_fetcher(ctx)
 283 |     result = jira.search_fields(keyword, limit=limit, refresh=refresh)
 284 |     return json.dumps(result, indent=2, ensure_ascii=False)
 285 | 
 286 | 
 287 | @jira_mcp.tool(tags={"jira", "read"})
 288 | async def get_project_issues(
 289 |     ctx: Context,
 290 |     project_key: Annotated[str, Field(description="The project key")],
 291 |     limit: Annotated[
 292 |         int,
 293 |         Field(description="Maximum number of results (1-50)", default=10, ge=1, le=50),
 294 |     ] = 10,
 295 |     start_at: Annotated[
 296 |         int,
 297 |         Field(description="Starting index for pagination (0-based)", default=0, ge=0),
 298 |     ] = 0,
 299 | ) -> str:
 300 |     """Get all issues for a specific Jira project.
 301 | 
 302 |     Args:
 303 |         ctx: The FastMCP context.
 304 |         project_key: The project key.
 305 |         limit: Maximum number of results.
 306 |         start_at: Starting index for pagination.
 307 | 
 308 |     Returns:
 309 |         JSON string representing the search results including pagination info.
 310 |     """
 311 |     jira = await get_jira_fetcher(ctx)
 312 |     search_result = jira.get_project_issues(
 313 |         project_key=project_key, start=start_at, limit=limit
 314 |     )
 315 |     result = search_result.to_simplified_dict()
 316 |     return json.dumps(result, indent=2, ensure_ascii=False)
 317 | 
 318 | 
 319 | @jira_mcp.tool(tags={"jira", "read"})
 320 | async def get_transitions(
 321 |     ctx: Context,
 322 |     issue_key: Annotated[str, Field(description="Jira issue key (e.g., 'PROJ-123')")],
 323 | ) -> str:
 324 |     """Get available status transitions for a Jira issue.
 325 | 
 326 |     Args:
 327 |         ctx: The FastMCP context.
 328 |         issue_key: Jira issue key.
 329 | 
 330 |     Returns:
 331 |         JSON string representing a list of available transitions.
 332 |     """
 333 |     jira = await get_jira_fetcher(ctx)
 334 |     # Underlying method returns list[dict] in the desired format
 335 |     transitions = jira.get_available_transitions(issue_key)
 336 |     return json.dumps(transitions, indent=2, ensure_ascii=False)
 337 | 
 338 | 
 339 | @jira_mcp.tool(tags={"jira", "read"})
 340 | async def get_worklog(
 341 |     ctx: Context,
 342 |     issue_key: Annotated[str, Field(description="Jira issue key (e.g., 'PROJ-123')")],
 343 | ) -> str:
 344 |     """Get worklog entries for a Jira issue.
 345 | 
 346 |     Args:
 347 |         ctx: The FastMCP context.
 348 |         issue_key: Jira issue key.
 349 | 
 350 |     Returns:
 351 |         JSON string representing the worklog entries.
 352 |     """
 353 |     jira = await get_jira_fetcher(ctx)
 354 |     worklogs = jira.get_worklogs(issue_key)
 355 |     result = {"worklogs": worklogs}
 356 |     return json.dumps(result, indent=2, ensure_ascii=False)
 357 | 
 358 | 
 359 | @jira_mcp.tool(tags={"jira", "read"})
 360 | async def download_attachments(
 361 |     ctx: Context,
 362 |     issue_key: Annotated[str, Field(description="Jira issue key (e.g., 'PROJ-123')")],
 363 |     target_dir: Annotated[
 364 |         str, Field(description="Directory where attachments should be saved")
 365 |     ],
 366 | ) -> str:
 367 |     """Download attachments from a Jira issue.
 368 | 
 369 |     Args:
 370 |         ctx: The FastMCP context.
 371 |         issue_key: Jira issue key.
 372 |         target_dir: Directory to save attachments.
 373 | 
 374 |     Returns:
 375 |         JSON string indicating the result of the download operation.
 376 |     """
 377 |     jira = await get_jira_fetcher(ctx)
 378 |     result = jira.download_issue_attachments(issue_key=issue_key, target_dir=target_dir)
 379 |     return json.dumps(result, indent=2, ensure_ascii=False)
 380 | 
 381 | 
 382 | @jira_mcp.tool(tags={"jira", "read"})
 383 | async def get_agile_boards(
 384 |     ctx: Context,
 385 |     board_name: Annotated[
 386 |         str | None,
 387 |         Field(description="(Optional) The name of board, support fuzzy search"),
 388 |     ] = None,
 389 |     project_key: Annotated[
 390 |         str | None, Field(description="(Optional) Jira project key (e.g., 'PROJ-123')")
 391 |     ] = None,
 392 |     board_type: Annotated[
 393 |         str | None,
 394 |         Field(
 395 |             description="(Optional) The type of jira board (e.g., 'scrum', 'kanban')"
 396 |         ),
 397 |     ] = None,
 398 |     start_at: Annotated[
 399 |         int,
 400 |         Field(description="Starting index for pagination (0-based)", default=0, ge=0),
 401 |     ] = 0,
 402 |     limit: Annotated[
 403 |         int,
 404 |         Field(description="Maximum number of results (1-50)", default=10, ge=1, le=50),
 405 |     ] = 10,
 406 | ) -> str:
 407 |     """Get jira agile boards by name, project key, or type.
 408 | 
 409 |     Args:
 410 |         ctx: The FastMCP context.
 411 |         board_name: Name of the board (fuzzy search).
 412 |         project_key: Project key.
 413 |         board_type: Board type ('scrum' or 'kanban').
 414 |         start_at: Starting index.
 415 |         limit: Maximum results.
 416 | 
 417 |     Returns:
 418 |         JSON string representing a list of board objects.
 419 |     """
 420 |     jira = await get_jira_fetcher(ctx)
 421 |     boards = jira.get_all_agile_boards_model(
 422 |         board_name=board_name,
 423 |         project_key=project_key,
 424 |         board_type=board_type,
 425 |         start=start_at,
 426 |         limit=limit,
 427 |     )
 428 |     result = [board.to_simplified_dict() for board in boards]
 429 |     return json.dumps(result, indent=2, ensure_ascii=False)
 430 | 
 431 | 
 432 | @jira_mcp.tool(tags={"jira", "read"})
 433 | async def get_board_issues(
 434 |     ctx: Context,
 435 |     board_id: Annotated[str, Field(description="The id of the board (e.g., '1001')")],
 436 |     jql: Annotated[
 437 |         str,
 438 |         Field(
 439 |             description=(
 440 |                 "JQL query string (Jira Query Language). Examples:\n"
 441 |                 '- Find Epics: "issuetype = Epic AND project = PROJ"\n'
 442 |                 '- Find issues in Epic: "parent = PROJ-123"\n'
 443 |                 "- Find by status: \"status = 'In Progress' AND project = PROJ\"\n"
 444 |                 '- Find by assignee: "assignee = currentUser()"\n'
 445 |                 '- Find recently updated: "updated >= -7d AND project = PROJ"\n'
 446 |                 '- Find by label: "labels = frontend AND project = PROJ"\n'
 447 |                 '- Find by priority: "priority = High AND project = PROJ"'
 448 |             )
 449 |         ),
 450 |     ],
 451 |     fields: Annotated[
 452 |         str,
 453 |         Field(
 454 |             description=(
 455 |                 "Comma-separated fields to return in the results. "
 456 |                 "Use '*all' for all fields, or specify individual "
 457 |                 "fields like 'summary,status,assignee,priority'"
 458 |             ),
 459 |             default=",".join(DEFAULT_READ_JIRA_FIELDS),
 460 |         ),
 461 |     ] = ",".join(DEFAULT_READ_JIRA_FIELDS),
 462 |     start_at: Annotated[
 463 |         int,
 464 |         Field(description="Starting index for pagination (0-based)", default=0, ge=0),
 465 |     ] = 0,
 466 |     limit: Annotated[
 467 |         int,
 468 |         Field(description="Maximum number of results (1-50)", default=10, ge=1, le=50),
 469 |     ] = 10,
 470 |     expand: Annotated[
 471 |         str,
 472 |         Field(
 473 |             description="Optional fields to expand in the response (e.g., 'changelog').",
 474 |             default="version",
 475 |         ),
 476 |     ] = "version",
 477 | ) -> str:
 478 |     """Get all issues linked to a specific board filtered by JQL.
 479 | 
 480 |     Args:
 481 |         ctx: The FastMCP context.
 482 |         board_id: The ID of the board.
 483 |         jql: JQL query string to filter issues.
 484 |         fields: Comma-separated fields to return.
 485 |         start_at: Starting index for pagination.
 486 |         limit: Maximum number of results.
 487 |         expand: Optional fields to expand.
 488 | 
 489 |     Returns:
 490 |         JSON string representing the search results including pagination info.
 491 |     """
 492 |     jira = await get_jira_fetcher(ctx)
 493 |     fields_list: str | list[str] | None = fields
 494 |     if fields and fields != "*all":
 495 |         fields_list = [f.strip() for f in fields.split(",")]
 496 | 
 497 |     search_result = jira.get_board_issues(
 498 |         board_id=board_id,
 499 |         jql=jql,
 500 |         fields=fields_list,
 501 |         start=start_at,
 502 |         limit=limit,
 503 |         expand=expand,
 504 |     )
 505 |     result = search_result.to_simplified_dict()
 506 |     return json.dumps(result, indent=2, ensure_ascii=False)
 507 | 
 508 | 
 509 | @jira_mcp.tool(tags={"jira", "read"})
 510 | async def get_sprints_from_board(
 511 |     ctx: Context,
 512 |     board_id: Annotated[str, Field(description="The id of board (e.g., '1000')")],
 513 |     state: Annotated[
 514 |         str | None,
 515 |         Field(description="Sprint state (e.g., 'active', 'future', 'closed')"),
 516 |     ] = None,
 517 |     start_at: Annotated[
 518 |         int,
 519 |         Field(description="Starting index for pagination (0-based)", default=0, ge=0),
 520 |     ] = 0,
 521 |     limit: Annotated[
 522 |         int,
 523 |         Field(description="Maximum number of results (1-50)", default=10, ge=1, le=50),
 524 |     ] = 10,
 525 | ) -> str:
 526 |     """Get jira sprints from board by state.
 527 | 
 528 |     Args:
 529 |         ctx: The FastMCP context.
 530 |         board_id: The ID of the board.
 531 |         state: Sprint state ('active', 'future', 'closed'). If None, returns all sprints.
 532 |         start_at: Starting index.
 533 |         limit: Maximum results.
 534 | 
 535 |     Returns:
 536 |         JSON string representing a list of sprint objects.
 537 |     """
 538 |     jira = await get_jira_fetcher(ctx)
 539 |     sprints = jira.get_all_sprints_from_board_model(
 540 |         board_id=board_id, state=state, start=start_at, limit=limit
 541 |     )
 542 |     result = [sprint.to_simplified_dict() for sprint in sprints]
 543 |     return json.dumps(result, indent=2, ensure_ascii=False)
 544 | 
 545 | 
 546 | @jira_mcp.tool(tags={"jira", "read"})
 547 | async def get_sprint_issues(
 548 |     ctx: Context,
 549 |     sprint_id: Annotated[str, Field(description="The id of sprint (e.g., '10001')")],
 550 |     fields: Annotated[
 551 |         str,
 552 |         Field(
 553 |             description=(
 554 |                 "Comma-separated fields to return in the results. "
 555 |                 "Use '*all' for all fields, or specify individual "
 556 |                 "fields like 'summary,status,assignee,priority'"
 557 |             ),
 558 |             default=",".join(DEFAULT_READ_JIRA_FIELDS),
 559 |         ),
 560 |     ] = ",".join(DEFAULT_READ_JIRA_FIELDS),
 561 |     start_at: Annotated[
 562 |         int,
 563 |         Field(description="Starting index for pagination (0-based)", default=0, ge=0),
 564 |     ] = 0,
 565 |     limit: Annotated[
 566 |         int,
 567 |         Field(description="Maximum number of results (1-50)", default=10, ge=1, le=50),
 568 |     ] = 10,
 569 | ) -> str:
 570 |     """Get jira issues from sprint.
 571 | 
 572 |     Args:
 573 |         ctx: The FastMCP context.
 574 |         sprint_id: The ID of the sprint.
 575 |         fields: Comma-separated fields to return.
 576 |         start_at: Starting index.
 577 |         limit: Maximum results.
 578 | 
 579 |     Returns:
 580 |         JSON string representing the search results including pagination info.
 581 |     """
 582 |     jira = await get_jira_fetcher(ctx)
 583 |     fields_list: str | list[str] | None = fields
 584 |     if fields and fields != "*all":
 585 |         fields_list = [f.strip() for f in fields.split(",")]
 586 | 
 587 |     search_result = jira.get_sprint_issues(
 588 |         sprint_id=sprint_id, fields=fields_list, start=start_at, limit=limit
 589 |     )
 590 |     result = search_result.to_simplified_dict()
 591 |     return json.dumps(result, indent=2, ensure_ascii=False)
 592 | 
 593 | 
 594 | @jira_mcp.tool(tags={"jira", "read"})
 595 | async def get_link_types(ctx: Context) -> str:
 596 |     """Get all available issue link types.
 597 | 
 598 |     Args:
 599 |         ctx: The FastMCP context.
 600 | 
 601 |     Returns:
 602 |         JSON string representing a list of issue link type objects.
 603 |     """
 604 |     jira = await get_jira_fetcher(ctx)
 605 |     link_types = jira.get_issue_link_types()
 606 |     formatted_link_types = [link_type.to_simplified_dict() for link_type in link_types]
 607 |     return json.dumps(formatted_link_types, indent=2, ensure_ascii=False)
 608 | 
 609 | 
 610 | @jira_mcp.tool(tags={"jira", "write"})
 611 | @check_write_access
 612 | async def create_issue(
 613 |     ctx: Context,
 614 |     project_key: Annotated[
 615 |         str,
 616 |         Field(
 617 |             description=(
 618 |                 "The JIRA project key (e.g. 'PROJ', 'DEV', 'SUPPORT'). "
 619 |                 "This is the prefix of issue keys in your project. "
 620 |                 "Never assume what it might be, always ask the user."
 621 |             )
 622 |         ),
 623 |     ],
 624 |     summary: Annotated[str, Field(description="Summary/title of the issue")],
 625 |     issue_type: Annotated[
 626 |         str,
 627 |         Field(
 628 |             description=(
 629 |                 "Issue type (e.g. 'Task', 'Bug', 'Story', 'Epic', 'Subtask'). "
 630 |                 "The available types depend on your project configuration. "
 631 |                 "For subtasks, use 'Subtask' (not 'Sub-task') and include parent in additional_fields."
 632 |             ),
 633 |         ),
 634 |     ],
 635 |     assignee: Annotated[
 636 |         str | None,
 637 |         Field(
 638 |             description="(Optional) Assignee's user identifier (string): Email, display name, or account ID (e.g., 'user@example.com', 'John Doe', 'accountid:...')",
 639 |             default=None,
 640 |         ),
 641 |     ] = None,
 642 |     description: Annotated[
 643 |         str | None, Field(description="Issue description", default=None)
 644 |     ] = None,
 645 |     components: Annotated[
 646 |         str | None,
 647 |         Field(
 648 |             description="(Optional) Comma-separated list of component names to assign (e.g., 'Frontend,API')",
 649 |             default=None,
 650 |         ),
 651 |     ] = None,
 652 |     additional_fields: Annotated[
 653 |         dict[str, Any] | None,
 654 |         Field(
 655 |             description=(
 656 |                 "(Optional) Dictionary of additional fields to set. Examples:\n"
 657 |                 "- Set priority: {'priority': {'name': 'High'}}\n"
 658 |                 "- Add labels: {'labels': ['frontend', 'urgent']}\n"
 659 |                 "- Link to parent (for any issue type): {'parent': 'PROJ-123'}\n"
 660 |                 "- Set Fix Version/s: {'fixVersions': [{'id': '10020'}]}\n"
 661 |                 "- Custom fields: {'customfield_10010': 'value'}"
 662 |             ),
 663 |             default=None,
 664 |         ),
 665 |     ] = None,
 666 | ) -> str:
 667 |     """Create a new Jira issue with optional Epic link or parent for subtasks.
 668 | 
 669 |     Args:
 670 |         ctx: The FastMCP context.
 671 |         project_key: The JIRA project key.
 672 |         summary: Summary/title of the issue.
 673 |         issue_type: Issue type (e.g., 'Task', 'Bug', 'Story', 'Epic', 'Subtask').
 674 |         assignee: Assignee's user identifier (string): Email, display name, or account ID (e.g., 'user@example.com', 'John Doe', 'accountid:...').
 675 |         description: Issue description.
 676 |         components: Comma-separated list of component names.
 677 |         additional_fields: Dictionary of additional fields.
 678 | 
 679 |     Returns:
 680 |         JSON string representing the created issue object.
 681 | 
 682 |     Raises:
 683 |         ValueError: If in read-only mode or Jira client is unavailable.
 684 |     """
 685 |     jira = await get_jira_fetcher(ctx)
 686 |     # Parse components from comma-separated string to list
 687 |     components_list = None
 688 |     if components and isinstance(components, str):
 689 |         components_list = [
 690 |             comp.strip() for comp in components.split(",") if comp.strip()
 691 |         ]
 692 | 
 693 |     # Use additional_fields directly as dict
 694 |     extra_fields = additional_fields or {}
 695 |     if not isinstance(extra_fields, dict):
 696 |         raise ValueError("additional_fields must be a dictionary.")
 697 | 
 698 |     issue = jira.create_issue(
 699 |         project_key=project_key,
 700 |         summary=summary,
 701 |         issue_type=issue_type,
 702 |         description=description,
 703 |         assignee=assignee,
 704 |         components=components_list,
 705 |         **extra_fields,
 706 |     )
 707 |     result = issue.to_simplified_dict()
 708 |     return json.dumps(
 709 |         {"message": "Issue created successfully", "issue": result},
 710 |         indent=2,
 711 |         ensure_ascii=False,
 712 |     )
 713 | 
 714 | 
 715 | @jira_mcp.tool(tags={"jira", "write"})
 716 | @check_write_access
 717 | async def batch_create_issues(
 718 |     ctx: Context,
 719 |     issues: Annotated[
 720 |         str,
 721 |         Field(
 722 |             description=(
 723 |                 "JSON array of issue objects. Each object should contain:\n"
 724 |                 "- project_key (required): The project key (e.g., 'PROJ')\n"
 725 |                 "- summary (required): Issue summary/title\n"
 726 |                 "- issue_type (required): Type of issue (e.g., 'Task', 'Bug')\n"
 727 |                 "- description (optional): Issue description\n"
 728 |                 "- assignee (optional): Assignee username or email\n"
 729 |                 "- components (optional): Array of component names\n"
 730 |                 "Example: [\n"
 731 |                 '  {"project_key": "PROJ", "summary": "Issue 1", "issue_type": "Task"},\n'
 732 |                 '  {"project_key": "PROJ", "summary": "Issue 2", "issue_type": "Bug", "components": ["Frontend"]}\n'
 733 |                 "]"
 734 |             )
 735 |         ),
 736 |     ],
 737 |     validate_only: Annotated[
 738 |         bool,
 739 |         Field(
 740 |             description="If true, only validates the issues without creating them",
 741 |             default=False,
 742 |         ),
 743 |     ] = False,
 744 | ) -> str:
 745 |     """Create multiple Jira issues in a batch.
 746 | 
 747 |     Args:
 748 |         ctx: The FastMCP context.
 749 |         issues: JSON array string of issue objects.
 750 |         validate_only: If true, only validates without creating.
 751 | 
 752 |     Returns:
 753 |         JSON string indicating success and listing created issues (or validation result).
 754 | 
 755 |     Raises:
 756 |         ValueError: If in read-only mode, Jira client unavailable, or invalid JSON.
 757 |     """
 758 |     jira = await get_jira_fetcher(ctx)
 759 |     # Parse issues from JSON string
 760 |     try:
 761 |         issues_list = json.loads(issues)
 762 |         if not isinstance(issues_list, list):
 763 |             raise ValueError("Input 'issues' must be a JSON array string.")
 764 |     except json.JSONDecodeError:
 765 |         raise ValueError("Invalid JSON in issues")
 766 |     except Exception as e:
 767 |         raise ValueError(f"Invalid input for issues: {e}") from e
 768 | 
 769 |     # Create issues in batch
 770 |     created_issues = jira.batch_create_issues(issues_list, validate_only=validate_only)
 771 | 
 772 |     message = (
 773 |         "Issues validated successfully"
 774 |         if validate_only
 775 |         else "Issues created successfully"
 776 |     )
 777 |     result = {
 778 |         "message": message,
 779 |         "issues": [issue.to_simplified_dict() for issue in created_issues],
 780 |     }
 781 |     return json.dumps(result, indent=2, ensure_ascii=False)
 782 | 
 783 | 
 784 | @jira_mcp.tool(tags={"jira", "read"})
 785 | async def batch_get_changelogs(
 786 |     ctx: Context,
 787 |     issue_ids_or_keys: Annotated[
 788 |         list[str],
 789 |         Field(
 790 |             description="List of Jira issue IDs or keys, e.g. ['PROJ-123', 'PROJ-124']"
 791 |         ),
 792 |     ],
 793 |     fields: Annotated[
 794 |         list[str] | None,
 795 |         Field(
 796 |             description="(Optional) Filter the changelogs by fields, e.g. ['status', 'assignee']. Default to None for all fields.",
 797 |             default=None,
 798 |         ),
 799 |     ] = None,
 800 |     limit: Annotated[
 801 |         int,
 802 |         Field(
 803 |             description=(
 804 |                 "Maximum number of changelogs to return in result for each issue. "
 805 |                 "Default to -1 for all changelogs. "
 806 |                 "Notice that it only limits the results in the response, "
 807 |                 "the function will still fetch all the data."
 808 |             ),
 809 |             default=-1,
 810 |         ),
 811 |     ] = -1,
 812 | ) -> str:
 813 |     """Get changelogs for multiple Jira issues (Cloud only).
 814 | 
 815 |     Args:
 816 |         ctx: The FastMCP context.
 817 |         issue_ids_or_keys: List of issue IDs or keys.
 818 |         fields: List of fields to filter changelogs by. None for all fields.
 819 |         limit: Maximum changelogs per issue (-1 for all).
 820 | 
 821 |     Returns:
 822 |         JSON string representing a list of issues with their changelogs.
 823 | 
 824 |     Raises:
 825 |         NotImplementedError: If run on Jira Server/Data Center.
 826 |         ValueError: If Jira client is unavailable.
 827 |     """
 828 |     jira = await get_jira_fetcher(ctx)
 829 |     # Ensure this runs only on Cloud, as per original function docstring
 830 |     if not jira.config.is_cloud:
 831 |         raise NotImplementedError(
 832 |             "Batch get issue changelogs is only available on Jira Cloud."
 833 |         )
 834 | 
 835 |     # Call the underlying method
 836 |     issues_with_changelogs = jira.batch_get_changelogs(
 837 |         issue_ids_or_keys=issue_ids_or_keys, fields=fields
 838 |     )
 839 | 
 840 |     # Format the response
 841 |     results = []
 842 |     limit_val = None if limit == -1 else limit
 843 |     for issue in issues_with_changelogs:
 844 |         results.append(
 845 |             {
 846 |                 "issue_id": issue.id,
 847 |                 "changelogs": [
 848 |                     changelog.to_simplified_dict()
 849 |                     for changelog in issue.changelogs[:limit_val]
 850 |                 ],
 851 |             }
 852 |         )
 853 |     return json.dumps(results, indent=2, ensure_ascii=False)
 854 | 
 855 | 
 856 | @jira_mcp.tool(tags={"jira", "write"})
 857 | @check_write_access
 858 | async def update_issue(
 859 |     ctx: Context,
 860 |     issue_key: Annotated[str, Field(description="Jira issue key (e.g., 'PROJ-123')")],
 861 |     fields: Annotated[
 862 |         dict[str, Any],
 863 |         Field(
 864 |             description=(
 865 |                 "Dictionary of fields to update. For 'assignee', provide a string identifier (email, name, or accountId). "
 866 |                 "Example: `{'assignee': 'user@example.com', 'summary': 'New Summary'}`"
 867 |             )
 868 |         ),
 869 |     ],
 870 |     additional_fields: Annotated[
 871 |         dict[str, Any] | None,
 872 |         Field(
 873 |             description="(Optional) Dictionary of additional fields to update. Use this for custom fields or more complex updates.",
 874 |             default=None,
 875 |         ),
 876 |     ] = None,
 877 |     attachments: Annotated[
 878 |         str | None,
 879 |         Field(
 880 |             description=(
 881 |                 "(Optional) JSON string array or comma-separated list of file paths to attach to the issue. "
 882 |                 "Example: '/path/to/file1.txt,/path/to/file2.txt' or ['/path/to/file1.txt','/path/to/file2.txt']"
 883 |             ),
 884 |             default=None,
 885 |         ),
 886 |     ] = None,
 887 | ) -> str:
 888 |     """Update an existing Jira issue including changing status, adding Epic links, updating fields, etc.
 889 | 
 890 |     Args:
 891 |         ctx: The FastMCP context.
 892 |         issue_key: Jira issue key.
 893 |         fields: Dictionary of fields to update.
 894 |         additional_fields: Optional dictionary of additional fields.
 895 |         attachments: Optional JSON array string or comma-separated list of file paths.
 896 | 
 897 |     Returns:
 898 |         JSON string representing the updated issue object and attachment results.
 899 | 
 900 |     Raises:
 901 |         ValueError: If in read-only mode or Jira client unavailable, or invalid input.
 902 |     """
 903 |     jira = await get_jira_fetcher(ctx)
 904 |     # Use fields directly as dict
 905 |     if not isinstance(fields, dict):
 906 |         raise ValueError("fields must be a dictionary.")
 907 |     update_fields = fields
 908 | 
 909 |     # Use additional_fields directly as dict
 910 |     extra_fields = additional_fields or {}
 911 |     if not isinstance(extra_fields, dict):
 912 |         raise ValueError("additional_fields must be a dictionary.")
 913 | 
 914 |     # Parse attachments
 915 |     attachment_paths = []
 916 |     if attachments:
 917 |         if isinstance(attachments, str):
 918 |             try:
 919 |                 parsed = json.loads(attachments)
 920 |                 if isinstance(parsed, list):
 921 |                     attachment_paths = [str(p) for p in parsed]
 922 |                 else:
 923 |                     raise ValueError("attachments JSON string must be an array.")
 924 |             except json.JSONDecodeError:
 925 |                 # Assume comma-separated if not valid JSON array
 926 |                 attachment_paths = [
 927 |                     p.strip() for p in attachments.split(",") if p.strip()
 928 |                 ]
 929 |         else:
 930 |             raise ValueError(
 931 |                 "attachments must be a JSON array string or comma-separated string."
 932 |             )
 933 | 
 934 |     # Combine fields and additional_fields
 935 |     all_updates = {**update_fields, **extra_fields}
 936 |     if attachment_paths:
 937 |         all_updates["attachments"] = attachment_paths
 938 | 
 939 |     try:
 940 |         issue = jira.update_issue(issue_key=issue_key, **all_updates)
 941 |         result = issue.to_simplified_dict()
 942 |         if (
 943 |             hasattr(issue, "custom_fields")
 944 |             and "attachment_results" in issue.custom_fields
 945 |         ):
 946 |             result["attachment_results"] = issue.custom_fields["attachment_results"]
 947 |         return json.dumps(
 948 |             {"message": "Issue updated successfully", "issue": result},
 949 |             indent=2,
 950 |             ensure_ascii=False,
 951 |         )
 952 |     except Exception as e:
 953 |         logger.error(f"Error updating issue {issue_key}: {str(e)}", exc_info=True)
 954 |         raise ValueError(f"Failed to update issue {issue_key}: {str(e)}")
 955 | 
 956 | 
 957 | @jira_mcp.tool(tags={"jira", "write"})
 958 | @check_write_access
 959 | async def delete_issue(
 960 |     ctx: Context,
 961 |     issue_key: Annotated[str, Field(description="Jira issue key (e.g. PROJ-123)")],
 962 | ) -> str:
 963 |     """Delete an existing Jira issue.
 964 | 
 965 |     Args:
 966 |         ctx: The FastMCP context.
 967 |         issue_key: Jira issue key.
 968 | 
 969 |     Returns:
 970 |         JSON string indicating success.
 971 | 
 972 |     Raises:
 973 |         ValueError: If in read-only mode or Jira client unavailable.
 974 |     """
 975 |     jira = await get_jira_fetcher(ctx)
 976 |     deleted = jira.delete_issue(issue_key)
 977 |     result = {"message": f"Issue {issue_key} has been deleted successfully."}
 978 |     # The underlying method raises on failure, so if we reach here, it's success.
 979 |     return json.dumps(result, indent=2, ensure_ascii=False)
 980 | 
 981 | 
 982 | @jira_mcp.tool(tags={"jira", "write"})
 983 | @check_write_access
 984 | async def add_comment(
 985 |     ctx: Context,
 986 |     issue_key: Annotated[str, Field(description="Jira issue key (e.g., 'PROJ-123')")],
 987 |     comment: Annotated[str, Field(description="Comment text in Markdown format")],
 988 | ) -> str:
 989 |     """Add a comment to a Jira issue.
 990 | 
 991 |     Args:
 992 |         ctx: The FastMCP context.
 993 |         issue_key: Jira issue key.
 994 |         comment: Comment text in Markdown.
 995 | 
 996 |     Returns:
 997 |         JSON string representing the added comment object.
 998 | 
 999 |     Raises:
1000 |         ValueError: If in read-only mode or Jira client unavailable.
1001 |     """
1002 |     jira = await get_jira_fetcher(ctx)
1003 |     # add_comment returns dict
1004 |     result = jira.add_comment(issue_key, comment)
1005 |     return json.dumps(result, indent=2, ensure_ascii=False)
1006 | 
1007 | 
1008 | @jira_mcp.tool(tags={"jira", "write"})
1009 | @check_write_access
1010 | async def add_worklog(
1011 |     ctx: Context,
1012 |     issue_key: Annotated[str, Field(description="Jira issue key (e.g., 'PROJ-123')")],
1013 |     time_spent: Annotated[
1014 |         str,
1015 |         Field(
1016 |             description=(
1017 |                 "Time spent in Jira format. Examples: "
1018 |                 "'1h 30m' (1 hour and 30 minutes), '1d' (1 day), '30m' (30 minutes), '4h' (4 hours)"
1019 |             )
1020 |         ),
1021 |     ],
1022 |     comment: Annotated[
1023 |         str | None,
1024 |         Field(description="(Optional) Comment for the worklog in Markdown format"),
1025 |     ] = None,
1026 |     started: Annotated[
1027 |         str | None,
1028 |         Field(
1029 |             description=(
1030 |                 "(Optional) Start time in ISO format. If not provided, the current time will be used. "
1031 |                 "Example: '2023-08-01T12:00:00.000+0000'"
1032 |             )
1033 |         ),
1034 |     ] = None,
1035 |     # Add original_estimate and remaining_estimate as per original tool
1036 |     original_estimate: Annotated[
1037 |         str | None, Field(description="(Optional) New value for the original estimate")
1038 |     ] = None,
1039 |     remaining_estimate: Annotated[
1040 |         str | None, Field(description="(Optional) New value for the remaining estimate")
1041 |     ] = None,
1042 | ) -> str:
1043 |     """Add a worklog entry to a Jira issue.
1044 | 
1045 |     Args:
1046 |         ctx: The FastMCP context.
1047 |         issue_key: Jira issue key.
1048 |         time_spent: Time spent in Jira format.
1049 |         comment: Optional comment in Markdown.
1050 |         started: Optional start time in ISO format.
1051 |         original_estimate: Optional new original estimate.
1052 |         remaining_estimate: Optional new remaining estimate.
1053 | 
1054 | 
1055 |     Returns:
1056 |         JSON string representing the added worklog object.
1057 | 
1058 |     Raises:
1059 |         ValueError: If in read-only mode or Jira client unavailable.
1060 |     """
1061 |     jira = await get_jira_fetcher(ctx)
1062 |     # add_worklog returns dict
1063 |     worklog_result = jira.add_worklog(
1064 |         issue_key=issue_key,
1065 |         time_spent=time_spent,
1066 |         comment=comment,
1067 |         started=started,
1068 |         original_estimate=original_estimate,
1069 |         remaining_estimate=remaining_estimate,
1070 |     )
1071 |     result = {"message": "Worklog added successfully", "worklog": worklog_result}
1072 |     return json.dumps(result, indent=2, ensure_ascii=False)
1073 | 
1074 | 
1075 | @jira_mcp.tool(tags={"jira", "write"})
1076 | @check_write_access
1077 | async def link_to_epic(
1078 |     ctx: Context,
1079 |     issue_key: Annotated[
1080 |         str, Field(description="The key of the issue to link (e.g., 'PROJ-123')")
1081 |     ],
1082 |     epic_key: Annotated[
1083 |         str, Field(description="The key of the epic to link to (e.g., 'PROJ-456')")
1084 |     ],
1085 | ) -> str:
1086 |     """Link an existing issue to an epic.
1087 | 
1088 |     Args:
1089 |         ctx: The FastMCP context.
1090 |         issue_key: The key of the issue to link.
1091 |         epic_key: The key of the epic to link to.
1092 | 
1093 |     Returns:
1094 |         JSON string representing the updated issue object.
1095 | 
1096 |     Raises:
1097 |         ValueError: If in read-only mode or Jira client unavailable.
1098 |     """
1099 |     jira = await get_jira_fetcher(ctx)
1100 |     issue = jira.link_issue_to_epic(issue_key, epic_key)
1101 |     result = {
1102 |         "message": f"Issue {issue_key} has been linked to epic {epic_key}.",
1103 |         "issue": issue.to_simplified_dict(),
1104 |     }
1105 |     return json.dumps(result, indent=2, ensure_ascii=False)
1106 | 
1107 | 
1108 | @jira_mcp.tool(tags={"jira", "write"})
1109 | @check_write_access
1110 | async def create_issue_link(
1111 |     ctx: Context,
1112 |     link_type: Annotated[
1113 |         str,
1114 |         Field(
1115 |             description="The type of link to create (e.g., 'Duplicate', 'Blocks', 'Relates to')"
1116 |         ),
1117 |     ],
1118 |     inward_issue_key: Annotated[
1119 |         str, Field(description="The key of the inward issue (e.g., 'PROJ-123')")
1120 |     ],
1121 |     outward_issue_key: Annotated[
1122 |         str, Field(description="The key of the outward issue (e.g., 'PROJ-456')")
1123 |     ],
1124 |     comment: Annotated[
1125 |         str | None, Field(description="(Optional) Comment to add to the link")
1126 |     ] = None,
1127 |     comment_visibility: Annotated[
1128 |         dict[str, str] | None,
1129 |         Field(
1130 |             description="(Optional) Visibility settings for the comment (e.g., {'type': 'group', 'value': 'jira-users'})",
1131 |             default=None,
1132 |         ),
1133 |     ] = None,
1134 | ) -> str:
1135 |     """Create a link between two Jira issues.
1136 | 
1137 |     Args:
1138 |         ctx: The FastMCP context.
1139 |         link_type: The type of link (e.g., 'Blocks').
1140 |         inward_issue_key: The key of the source issue.
1141 |         outward_issue_key: The key of the target issue.
1142 |         comment: Optional comment text.
1143 |         comment_visibility: Optional dictionary for comment visibility.
1144 | 
1145 |     Returns:
1146 |         JSON string indicating success or failure.
1147 | 
1148 |     Raises:
1149 |         ValueError: If required fields are missing, invalid input, in read-only mode, or Jira client unavailable.
1150 |     """
1151 |     jira = await get_jira_fetcher(ctx)
1152 |     if not all([link_type, inward_issue_key, outward_issue_key]):
1153 |         raise ValueError(
1154 |             "link_type, inward_issue_key, and outward_issue_key are required."
1155 |         )
1156 | 
1157 |     link_data = {
1158 |         "type": {"name": link_type},
1159 |         "inwardIssue": {"key": inward_issue_key},
1160 |         "outwardIssue": {"key": outward_issue_key},
1161 |     }
1162 | 
1163 |     if comment:
1164 |         comment_obj = {"body": comment}
1165 |         if comment_visibility and isinstance(comment_visibility, dict):
1166 |             if "type" in comment_visibility and "value" in comment_visibility:
1167 |                 comment_obj["visibility"] = comment_visibility
1168 |             else:
1169 |                 logger.warning("Invalid comment_visibility dictionary structure.")
1170 |         link_data["comment"] = comment_obj
1171 | 
1172 |     result = jira.create_issue_link(link_data)
1173 |     return json.dumps(result, indent=2, ensure_ascii=False)
1174 | 
1175 | 
1176 | @jira_mcp.tool(tags={"jira", "write"})
1177 | @check_write_access
1178 | async def create_remote_issue_link(
1179 |     ctx: Context,
1180 |     issue_key: Annotated[
1181 |         str,
1182 |         Field(description="The key of the issue to add the link to (e.g., 'PROJ-123')"),
1183 |     ],
1184 |     url: Annotated[
1185 |         str,
1186 |         Field(
1187 |             description="The URL to link to (e.g., 'https://example.com/page' or Confluence page URL)"
1188 |         ),
1189 |     ],
1190 |     title: Annotated[
1191 |         str,
1192 |         Field(
1193 |             description="The title/name of the link (e.g., 'Documentation Page', 'Confluence Page')"
1194 |         ),
1195 |     ],
1196 |     summary: Annotated[
1197 |         str | None, Field(description="(Optional) Description of the link")
1198 |     ] = None,
1199 |     relationship: Annotated[
1200 |         str | None,
1201 |         Field(
1202 |             description="(Optional) Relationship description (e.g., 'causes', 'relates to', 'documentation')"
1203 |         ),
1204 |     ] = None,
1205 |     icon_url: Annotated[
1206 |         str | None, Field(description="(Optional) URL to a 16x16 icon for the link")
1207 |     ] = None,
1208 | ) -> str:
1209 |     """Create a remote issue link (web link or Confluence link) for a Jira issue.
1210 | 
1211 |     This tool allows you to add web links and Confluence links to Jira issues.
1212 |     The links will appear in the issue's "Links" section and can be clicked to navigate to external resources.
1213 | 
1214 |     Args:
1215 |         ctx: The FastMCP context.
1216 |         issue_key: The key of the issue to add the link to.
1217 |         url: The URL to link to (can be any web page or Confluence page).
1218 |         title: The title/name that will be displayed for the link.
1219 |         summary: Optional description of what the link is for.
1220 |         relationship: Optional relationship description.
1221 |         icon_url: Optional URL to a 16x16 icon for the link.
1222 | 
1223 |     Returns:
1224 |         JSON string indicating success or failure.
1225 | 
1226 |     Raises:
1227 |         ValueError: If required fields are missing, invalid input, in read-only mode, or Jira client unavailable.
1228 |     """
1229 |     jira = await get_jira_fetcher(ctx)
1230 |     if not issue_key:
1231 |         raise ValueError("issue_key is required.")
1232 |     if not url:
1233 |         raise ValueError("url is required.")
1234 |     if not title:
1235 |         raise ValueError("title is required.")
1236 | 
1237 |     # Build the remote link data structure
1238 |     link_object = {
1239 |         "url": url,
1240 |         "title": title,
1241 |     }
1242 | 
1243 |     if summary:
1244 |         link_object["summary"] = summary
1245 | 
1246 |     if icon_url:
1247 |         link_object["icon"] = {"url16x16": icon_url, "title": title}
1248 | 
1249 |     link_data = {"object": link_object}
1250 | 
1251 |     if relationship:
1252 |         link_data["relationship"] = relationship
1253 | 
1254 |     result = jira.create_remote_issue_link(issue_key, link_data)
1255 |     return json.dumps(result, indent=2, ensure_ascii=False)
1256 | 
1257 | 
1258 | @jira_mcp.tool(tags={"jira", "write"})
1259 | @check_write_access
1260 | async def remove_issue_link(
1261 |     ctx: Context,
1262 |     link_id: Annotated[str, Field(description="The ID of the link to remove")],
1263 | ) -> str:
1264 |     """Remove a link between two Jira issues.
1265 | 
1266 |     Args:
1267 |         ctx: The FastMCP context.
1268 |         link_id: The ID of the link to remove.
1269 | 
1270 |     Returns:
1271 |         JSON string indicating success.
1272 | 
1273 |     Raises:
1274 |         ValueError: If link_id is missing, in read-only mode, or Jira client unavailable.
1275 |     """
1276 |     jira = await get_jira_fetcher(ctx)
1277 |     if not link_id:
1278 |         raise ValueError("link_id is required")
1279 | 
1280 |     result = jira.remove_issue_link(link_id)  # Returns dict on success
1281 |     return json.dumps(result, indent=2, ensure_ascii=False)
1282 | 
1283 | 
1284 | @jira_mcp.tool(tags={"jira", "write"})
1285 | @check_write_access
1286 | async def transition_issue(
1287 |     ctx: Context,
1288 |     issue_key: Annotated[str, Field(description="Jira issue key (e.g., 'PROJ-123')")],
1289 |     transition_id: Annotated[
1290 |         str,
1291 |         Field(
1292 |             description=(
1293 |                 "ID of the transition to perform. Use the jira_get_transitions tool first "
1294 |                 "to get the available transition IDs for the issue. Example values: '11', '21', '31'"
1295 |             )
1296 |         ),
1297 |     ],
1298 |     fields: Annotated[
1299 |         dict[str, Any] | None,
1300 |         Field(
1301 |             description=(
1302 |                 "(Optional) Dictionary of fields to update during the transition. "
1303 |                 "Some transitions require specific fields to be set (e.g., resolution). "
1304 |                 "Example: {'resolution': {'name': 'Fixed'}}"
1305 |             ),
1306 |             default=None,
1307 |         ),
1308 |     ] = None,
1309 |     comment: Annotated[
1310 |         str | None,
1311 |         Field(
1312 |             description=(
1313 |                 "(Optional) Comment to add during the transition. "
1314 |                 "This will be visible in the issue history."
1315 |             ),
1316 |         ),
1317 |     ] = None,
1318 | ) -> str:
1319 |     """Transition a Jira issue to a new status.
1320 | 
1321 |     Args:
1322 |         ctx: The FastMCP context.
1323 |         issue_key: Jira issue key.
1324 |         transition_id: ID of the transition.
1325 |         fields: Optional dictionary of fields to update during transition.
1326 |         comment: Optional comment for the transition.
1327 | 
1328 |     Returns:
1329 |         JSON string representing the updated issue object.
1330 | 
1331 |     Raises:
1332 |         ValueError: If required fields missing, invalid input, in read-only mode, or Jira client unavailable.
1333 |     """
1334 |     jira = await get_jira_fetcher(ctx)
1335 |     if not issue_key or not transition_id:
1336 |         raise ValueError("issue_key and transition_id are required.")
1337 | 
1338 |     # Use fields directly as dict
1339 |     update_fields = fields or {}
1340 |     if not isinstance(update_fields, dict):
1341 |         raise ValueError("fields must be a dictionary.")
1342 | 
1343 |     issue = jira.transition_issue(
1344 |         issue_key=issue_key,
1345 |         transition_id=transition_id,
1346 |         fields=update_fields,
1347 |         comment=comment,
1348 |     )
1349 | 
1350 |     result = {
1351 |         "message": f"Issue {issue_key} transitioned successfully",
1352 |         "issue": issue.to_simplified_dict() if issue else None,
1353 |     }
1354 |     return json.dumps(result, indent=2, ensure_ascii=False)
1355 | 
1356 | 
1357 | @jira_mcp.tool(tags={"jira", "write"})
1358 | @check_write_access
1359 | async def create_sprint(
1360 |     ctx: Context,
1361 |     board_id: Annotated[str, Field(description="The id of board (e.g., '1000')")],
1362 |     sprint_name: Annotated[
1363 |         str, Field(description="Name of the sprint (e.g., 'Sprint 1')")
1364 |     ],
1365 |     start_date: Annotated[
1366 |         str, Field(description="Start time for sprint (ISO 8601 format)")
1367 |     ],
1368 |     end_date: Annotated[
1369 |         str, Field(description="End time for sprint (ISO 8601 format)")
1370 |     ],
1371 |     goal: Annotated[
1372 |         str | None, Field(description="(Optional) Goal of the sprint")
1373 |     ] = None,
1374 | ) -> str:
1375 |     """Create Jira sprint for a board.
1376 | 
1377 |     Args:
1378 |         ctx: The FastMCP context.
1379 |         board_id: Board ID.
1380 |         sprint_name: Sprint name.
1381 |         start_date: Start date (ISO format).
1382 |         end_date: End date (ISO format).
1383 |         goal: Optional sprint goal.
1384 | 
1385 |     Returns:
1386 |         JSON string representing the created sprint object.
1387 | 
1388 |     Raises:
1389 |         ValueError: If in read-only mode or Jira client unavailable.
1390 |     """
1391 |     jira = await get_jira_fetcher(ctx)
1392 |     sprint = jira.create_sprint(
1393 |         board_id=board_id,
1394 |         sprint_name=sprint_name,
1395 |         start_date=start_date,
1396 |         end_date=end_date,
1397 |         goal=goal,
1398 |     )
1399 |     return json.dumps(sprint.to_simplified_dict(), indent=2, ensure_ascii=False)
1400 | 
1401 | 
1402 | @jira_mcp.tool(tags={"jira", "write"})
1403 | @check_write_access
1404 | async def update_sprint(
1405 |     ctx: Context,
1406 |     sprint_id: Annotated[str, Field(description="The id of sprint (e.g., '10001')")],
1407 |     sprint_name: Annotated[
1408 |         str | None, Field(description="(Optional) New name for the sprint")
1409 |     ] = None,
1410 |     state: Annotated[
1411 |         str | None,
1412 |         Field(description="(Optional) New state for the sprint (future|active|closed)"),
1413 |     ] = None,
1414 |     start_date: Annotated[
1415 |         str | None, Field(description="(Optional) New start date for the sprint")
1416 |     ] = None,
1417 |     end_date: Annotated[
1418 |         str | None, Field(description="(Optional) New end date for the sprint")
1419 |     ] = None,
1420 |     goal: Annotated[
1421 |         str | None, Field(description="(Optional) New goal for the sprint")
1422 |     ] = None,
1423 | ) -> str:
1424 |     """Update jira sprint.
1425 | 
1426 |     Args:
1427 |         ctx: The FastMCP context.
1428 |         sprint_id: The ID of the sprint.
1429 |         sprint_name: Optional new name.
1430 |         state: Optional new state (future|active|closed).
1431 |         start_date: Optional new start date.
1432 |         end_date: Optional new end date.
1433 |         goal: Optional new goal.
1434 | 
1435 |     Returns:
1436 |         JSON string representing the updated sprint object or an error message.
1437 | 
1438 |     Raises:
1439 |         ValueError: If in read-only mode or Jira client unavailable.
1440 |     """
1441 |     jira = await get_jira_fetcher(ctx)
1442 |     sprint = jira.update_sprint(
1443 |         sprint_id=sprint_id,
1444 |         sprint_name=sprint_name,
1445 |         state=state,
1446 |         start_date=start_date,
1447 |         end_date=end_date,
1448 |         goal=goal,
1449 |     )
1450 | 
1451 |     if sprint is None:
1452 |         error_payload = {
1453 |             "error": f"Failed to update sprint {sprint_id}. Check logs for details."
1454 |         }
1455 |         return json.dumps(error_payload, indent=2, ensure_ascii=False)
1456 |     else:
1457 |         return json.dumps(sprint.to_simplified_dict(), indent=2, ensure_ascii=False)
1458 | 
1459 | 
1460 | @jira_mcp.tool(tags={"jira", "read"})
1461 | async def get_project_versions(
1462 |     ctx: Context,
1463 |     project_key: Annotated[str, Field(description="Jira project key (e.g., 'PROJ')")],
1464 | ) -> str:
1465 |     """Get all fix versions for a specific Jira project."""
1466 |     jira = await get_jira_fetcher(ctx)
1467 |     versions = jira.get_project_versions(project_key)
1468 |     return json.dumps(versions, indent=2, ensure_ascii=False)
1469 | 
1470 | 
1471 | @jira_mcp.tool(tags={"jira", "read"})
1472 | async def get_all_projects(
1473 |     ctx: Context,
1474 |     include_archived: Annotated[
1475 |         bool,
1476 |         Field(
1477 |             description="Whether to include archived projects in the results",
1478 |             default=False,
1479 |         ),
1480 |     ] = False,
1481 | ) -> str:
1482 |     """Get all Jira projects accessible to the current user.
1483 | 
1484 |     Args:
1485 |         ctx: The FastMCP context.
1486 |         include_archived: Whether to include archived projects.
1487 | 
1488 |     Returns:
1489 |         JSON string representing a list of project objects accessible to the user.
1490 |         Project keys are always returned in uppercase.
1491 |         If JIRA_PROJECTS_FILTER is configured, only returns projects matching those keys.
1492 | 
1493 |     Raises:
1494 |         ValueError: If the Jira client is not configured or available.
1495 |     """
1496 |     try:
1497 |         jira = await get_jira_fetcher(ctx)
1498 |         projects = jira.get_all_projects(include_archived=include_archived)
1499 |     except (MCPAtlassianAuthenticationError, HTTPError, OSError, ValueError) as e:
1500 |         error_message = ""
1501 |         log_level = logging.ERROR
1502 |         if isinstance(e, MCPAtlassianAuthenticationError):
1503 |             error_message = f"Authentication/Permission Error: {str(e)}"
1504 |         elif isinstance(e, OSError | HTTPError):
1505 |             error_message = f"Network or API Error: {str(e)}"
1506 |         elif isinstance(e, ValueError):
1507 |             error_message = f"Configuration Error: {str(e)}"
1508 | 
1509 |         error_result = {
1510 |             "success": False,
1511 |             "error": error_message,
1512 |         }
1513 |         logger.log(log_level, f"get_all_projects failed: {error_message}")
1514 |         return json.dumps(error_result, indent=2, ensure_ascii=False)
1515 | 
1516 |     # Ensure all project keys are uppercase
1517 |     for project in projects:
1518 |         if "key" in project:
1519 |             project["key"] = project["key"].upper()
1520 | 
1521 |     # Apply project filter if configured
1522 |     if jira.config.projects_filter:
1523 |         # Split projects filter by commas and handle possible whitespace
1524 |         allowed_project_keys = {
1525 |             p.strip().upper() for p in jira.config.projects_filter.split(",")
1526 |         }
1527 |         projects = [
1528 |             project
1529 |             for project in projects
1530 |             if project.get("key") in allowed_project_keys
1531 |         ]
1532 | 
1533 |     return json.dumps(projects, indent=2, ensure_ascii=False)
1534 | 
1535 | 
1536 | @jira_mcp.tool(tags={"jira", "write"})
1537 | @check_write_access
1538 | async def create_version(
1539 |     ctx: Context,
1540 |     project_key: Annotated[str, Field(description="Jira project key (e.g., 'PROJ')")],
1541 |     name: Annotated[str, Field(description="Name of the version")],
1542 |     start_date: Annotated[
1543 |         str | None, Field(description="Start date (YYYY-MM-DD)", default=None)
1544 |     ] = None,
1545 |     release_date: Annotated[
1546 |         str | None, Field(description="Release date (YYYY-MM-DD)", default=None)
1547 |     ] = None,
1548 |     description: Annotated[
1549 |         str | None, Field(description="Description of the version", default=None)
1550 |     ] = None,
1551 | ) -> str:
1552 |     """Create a new fix version in a Jira project.
1553 | 
1554 |     Args:
1555 |         ctx: The FastMCP context.
1556 |         project_key: The project key.
1557 |         name: Name of the version.
1558 |         start_date: Start date (optional).
1559 |         release_date: Release date (optional).
1560 |         description: Description (optional).
1561 | 
1562 |     Returns:
1563 |         JSON string of the created version object.
1564 |     """
1565 |     jira = await get_jira_fetcher(ctx)
1566 |     try:
1567 |         version = jira.create_project_version(
1568 |             project_key=project_key,
1569 |             name=name,
1570 |             start_date=start_date,
1571 |             release_date=release_date,
1572 |             description=description,
1573 |         )
1574 |         return json.dumps(version, indent=2, ensure_ascii=False)
1575 |     except Exception as e:
1576 |         logger.error(
1577 |             f"Error creating version in project {project_key}: {str(e)}", exc_info=True
1578 |         )
1579 |         return json.dumps(
1580 |             {"success": False, "error": str(e)}, indent=2, ensure_ascii=False
1581 |         )
1582 | 
1583 | 
1584 | @jira_mcp.tool(name="batch_create_versions", tags={"jira", "write"})
1585 | @check_write_access
1586 | async def batch_create_versions(
1587 |     ctx: Context,
1588 |     project_key: Annotated[str, Field(description="Jira project key (e.g., 'PROJ')")],
1589 |     versions: Annotated[
1590 |         str,
1591 |         Field(
1592 |             description=(
1593 |                 "JSON array of version objects. Each object should contain:\n"
1594 |                 "- name (required): Name of the version\n"
1595 |                 "- startDate (optional): Start date (YYYY-MM-DD)\n"
1596 |                 "- releaseDate (optional): Release date (YYYY-MM-DD)\n"
1597 |                 "- description (optional): Description of the version\n"
1598 |                 "Example: [\n"
1599 |                 '  {"name": "v1.0", "startDate": "2025-01-01", "releaseDate": "2025-02-01", "description": "First release"},\n'
1600 |                 '  {"name": "v2.0"}\n'
1601 |                 "]"
1602 |             )
1603 |         ),
1604 |     ],
1605 | ) -> str:
1606 |     """Batch create multiple versions in a Jira project.
1607 | 
1608 |     Args:
1609 |         ctx: The FastMCP context.
1610 |         project_key: The project key.
1611 |         versions: JSON array string of version objects.
1612 | 
1613 |     Returns:
1614 |         JSON array of results, each with success flag, version or error.
1615 |     """
1616 |     jira = await get_jira_fetcher(ctx)
1617 |     try:
1618 |         version_list = json.loads(versions)
1619 |         if not isinstance(version_list, list):
1620 |             raise ValueError("Input 'versions' must be a JSON array string.")
1621 |     except json.JSONDecodeError:
1622 |         raise ValueError("Invalid JSON in versions")
1623 |     except Exception as e:
1624 |         raise ValueError(f"Invalid input for versions: {e}") from e
1625 | 
1626 |     results = []
1627 |     if not version_list:
1628 |         return json.dumps(results, indent=2, ensure_ascii=False)
1629 | 
1630 |     for idx, v in enumerate(version_list):
1631 |         # Defensive: ensure v is a dict and has a name
1632 |         if not isinstance(v, dict) or not v.get("name"):
1633 |             results.append(
1634 |                 {
1635 |                     "success": False,
1636 |                     "error": f"Item {idx}: Each version must be an object with at least a 'name' field.",
1637 |                 }
1638 |             )
1639 |             continue
1640 |         try:
1641 |             version = jira.create_project_version(
1642 |                 project_key=project_key,
1643 |                 name=v["name"],
1644 |                 start_date=v.get("startDate"),
1645 |                 release_date=v.get("releaseDate"),
1646 |                 description=v.get("description"),
1647 |             )
1648 |             results.append({"success": True, "version": version})
1649 |         except Exception as e:
1650 |             logger.error(
1651 |                 f"Error creating version in batch for project {project_key}: {str(e)}",
1652 |                 exc_info=True,
1653 |             )
1654 |             results.append({"success": False, "error": str(e), "input": v})
1655 |     return json.dumps(results, indent=2, ensure_ascii=False)
1656 | 
```

--------------------------------------------------------------------------------
/src/mcp_atlassian/jira/issues.py:
--------------------------------------------------------------------------------

```python
   1 | """Module for Jira issue operations."""
   2 | 
   3 | import logging
   4 | from collections import defaultdict
   5 | from typing import Any
   6 | 
   7 | from requests.exceptions import HTTPError
   8 | 
   9 | from ..exceptions import MCPAtlassianAuthenticationError
  10 | from ..models.jira import JiraIssue
  11 | from ..models.jira.common import JiraChangelog
  12 | from ..utils import parse_date
  13 | from .client import JiraClient
  14 | from .constants import DEFAULT_READ_JIRA_FIELDS
  15 | from .protocols import (
  16 |     AttachmentsOperationsProto,
  17 |     EpicOperationsProto,
  18 |     FieldsOperationsProto,
  19 |     IssueOperationsProto,
  20 |     ProjectsOperationsProto,
  21 |     UsersOperationsProto,
  22 | )
  23 | 
  24 | logger = logging.getLogger("mcp-jira")
  25 | 
  26 | 
  27 | class IssuesMixin(
  28 |     JiraClient,
  29 |     AttachmentsOperationsProto,
  30 |     EpicOperationsProto,
  31 |     FieldsOperationsProto,
  32 |     IssueOperationsProto,
  33 |     ProjectsOperationsProto,
  34 |     UsersOperationsProto,
  35 | ):
  36 |     """Mixin for Jira issue operations."""
  37 | 
  38 |     def get_issue(
  39 |         self,
  40 |         issue_key: str,
  41 |         expand: str | None = None,
  42 |         comment_limit: int | str | None = 10,
  43 |         fields: str | list[str] | tuple[str, ...] | set[str] | None = None,
  44 |         properties: str | list[str] | None = None,
  45 |         update_history: bool = True,
  46 |     ) -> JiraIssue:
  47 |         """
  48 |         Get a Jira issue by key.
  49 | 
  50 |         Args:
  51 |             issue_key: The issue key (e.g., PROJECT-123)
  52 |             expand: Fields to expand in the response
  53 |             comment_limit: Maximum number of comments to include, or "all"
  54 |             fields: Fields to return (comma-separated string, list, tuple, set, or "*all")
  55 |             properties: Issue properties to return (comma-separated string or list)
  56 |             update_history: Whether to update the issue view history
  57 | 
  58 |         Returns:
  59 |             JiraIssue model with issue data and metadata
  60 | 
  61 |         Raises:
  62 |             MCPAtlassianAuthenticationError: If authentication fails with the Jira API (401/403)
  63 |             Exception: If there is an error retrieving the issue
  64 |         """
  65 |         try:
  66 |             # Obtain the projects filter from the config.
  67 |             # These should NOT be overridden by the request.
  68 |             filter_to_use = self.config.projects_filter
  69 | 
  70 |             # Apply projects filter if present
  71 |             if filter_to_use:
  72 |                 # Split projects filter by commas and handle possible whitespace
  73 |                 projects = [p.strip() for p in filter_to_use.split(",")]
  74 | 
  75 |                 # Obtain the project key from issue_key
  76 |                 issue_key_project = issue_key.split("-")[0]
  77 | 
  78 |                 if issue_key_project not in projects:
  79 |                     # If the project key not in the filter, return an empty issue
  80 |                     msg = (
  81 |                         "Issue with project prefix "
  82 |                         f"'{issue_key_project}' are restricted by configuration"
  83 |                     )
  84 |                     raise ValueError(msg)
  85 | 
  86 |             # Determine fields_param: use provided fields or default from constant
  87 |             fields_param = fields
  88 |             if fields_param is None:
  89 |                 fields_param = ",".join(DEFAULT_READ_JIRA_FIELDS)
  90 |             elif isinstance(fields_param, list | tuple | set):
  91 |                 fields_param = ",".join(fields_param)
  92 | 
  93 |             # Ensure necessary fields are included based on special parameters
  94 |             if (
  95 |                 fields_param == ",".join(DEFAULT_READ_JIRA_FIELDS)
  96 |                 or fields_param == "*all"
  97 |             ):
  98 |                 # Default fields are being used - preserve the order
  99 |                 default_fields_list = (
 100 |                     fields_param.split(",")
 101 |                     if fields_param != "*all"
 102 |                     else list(DEFAULT_READ_JIRA_FIELDS)
 103 |                 )
 104 |                 additional_fields = []
 105 | 
 106 |                 # Add appropriate fields based on expand parameter
 107 |                 if expand:
 108 |                     expand_params = expand.split(",")
 109 |                     if (
 110 |                         "changelog" in expand_params
 111 |                         and "changelog" not in default_fields_list
 112 |                         and "changelog" not in additional_fields
 113 |                     ):
 114 |                         additional_fields.append("changelog")
 115 |                     if (
 116 |                         "renderedFields" in expand_params
 117 |                         and "rendered" not in default_fields_list
 118 |                         and "rendered" not in additional_fields
 119 |                     ):
 120 |                         additional_fields.append("rendered")
 121 | 
 122 |                 # Add appropriate fields based on properties parameter
 123 |                 if (
 124 |                     properties
 125 |                     and "properties" not in default_fields_list
 126 |                     and "properties" not in additional_fields
 127 |                 ):
 128 |                     additional_fields.append("properties")
 129 | 
 130 |                 # Combine default fields with additional fields, preserving order
 131 |                 if additional_fields:
 132 |                     fields_param = ",".join(default_fields_list + additional_fields)
 133 |             # Handle non-default fields string
 134 | 
 135 |             # Build expand parameter if provided
 136 |             expand_param = expand
 137 | 
 138 |             # Convert properties to proper format if it's a list
 139 |             properties_param = properties
 140 |             if properties and isinstance(properties, list | tuple | set):
 141 |                 properties_param = ",".join(properties)
 142 | 
 143 |             # Get the issue data with all parameters
 144 |             issue = self.jira.get_issue(
 145 |                 issue_key,
 146 |                 expand=expand_param,
 147 |                 fields=fields_param,
 148 |                 properties=properties_param,
 149 |                 update_history=update_history,
 150 |             )
 151 |             if not issue:
 152 |                 msg = f"Issue {issue_key} not found"
 153 |                 raise ValueError(msg)
 154 |             if not isinstance(issue, dict):
 155 |                 msg = (
 156 |                     f"Unexpected return value type from `jira.get_issue`: {type(issue)}"
 157 |                 )
 158 |                 logger.error(msg)
 159 |                 raise TypeError(msg)
 160 | 
 161 |             # Extract fields data, safely handling None
 162 |             fields_data = issue.get("fields", {}) or {}
 163 | 
 164 |             # Get comments if needed
 165 |             if "comment" in fields_data:
 166 |                 comment_limit_int = self._normalize_comment_limit(comment_limit)
 167 |                 comments = self._get_issue_comments_if_needed(
 168 |                     issue_key, comment_limit_int
 169 |                 )
 170 |                 # Add comments to the issue data for processing by the model
 171 |                 fields_data["comment"]["comments"] = comments
 172 | 
 173 |             # Extract epic information
 174 |             try:
 175 |                 epic_info = self._extract_epic_information(issue)
 176 |             except Exception as e:
 177 |                 logger.warning(f"Error extracting epic information: {str(e)}")
 178 |                 epic_info = {"epic_key": None, "epic_name": None}
 179 | 
 180 |             # If this is linked to an epic, add the epic information to the fields
 181 |             if epic_info.get("epic_key"):
 182 |                 try:
 183 |                     # Get field IDs for epic fields
 184 |                     field_ids = self.get_field_ids_to_epic()
 185 | 
 186 |                     # Add epic link field if it doesn't exist
 187 |                     if (
 188 |                         "epic_link" in field_ids
 189 |                         and field_ids["epic_link"] not in fields_data
 190 |                     ):
 191 |                         fields_data[field_ids["epic_link"]] = epic_info["epic_key"]
 192 | 
 193 |                     # Add epic name field if it doesn't exist
 194 |                     if (
 195 |                         epic_info.get("epic_name")
 196 |                         and "epic_name" in field_ids
 197 |                         and field_ids["epic_name"] not in fields_data
 198 |                     ):
 199 |                         fields_data[field_ids["epic_name"]] = epic_info["epic_name"]
 200 |                 except Exception as e:
 201 |                     logger.warning(f"Error setting epic fields: {str(e)}")
 202 | 
 203 |             # Update the issue data with the fields
 204 |             issue["fields"] = fields_data
 205 | 
 206 |             # Create and return the JiraIssue model, passing requested_fields
 207 |             return JiraIssue.from_api_response(
 208 |                 issue,
 209 |                 base_url=self.config.url if hasattr(self, "config") else None,
 210 |                 requested_fields=fields,
 211 |             )
 212 |         except HTTPError as http_err:
 213 |             if http_err.response is not None and http_err.response.status_code in [
 214 |                 401,
 215 |                 403,
 216 |             ]:
 217 |                 error_msg = (
 218 |                     f"Authentication failed for Jira API ({http_err.response.status_code}). "
 219 |                     "Token may be expired or invalid. Please verify credentials."
 220 |                 )
 221 |                 logger.error(error_msg)
 222 |                 raise MCPAtlassianAuthenticationError(error_msg) from http_err
 223 |             else:
 224 |                 logger.error(f"HTTP error during API call: {http_err}", exc_info=False)
 225 |                 raise
 226 |         except Exception as e:
 227 |             error_msg = str(e)
 228 |             logger.error(f"Error retrieving issue {issue_key}: {error_msg}")
 229 |             raise Exception(f"Error retrieving issue {issue_key}: {error_msg}") from e
 230 | 
 231 |     def _normalize_comment_limit(self, comment_limit: int | str | None) -> int | None:
 232 |         """
 233 |         Normalize the comment limit to an integer or None.
 234 | 
 235 |         Args:
 236 |             comment_limit: The comment limit as int, string, or None
 237 | 
 238 |         Returns:
 239 |             Normalized comment limit as int or None
 240 |         """
 241 |         if comment_limit is None:
 242 |             return None
 243 | 
 244 |         if isinstance(comment_limit, int):
 245 |             return comment_limit
 246 | 
 247 |         if comment_limit == "all":
 248 |             return None  # No limit
 249 | 
 250 |         # Try to convert to int
 251 |         try:
 252 |             return int(comment_limit)
 253 |         except ValueError:
 254 |             # If conversion fails, default to 10
 255 |             return 10
 256 | 
 257 |     def _get_issue_comments_if_needed(
 258 |         self, issue_key: str, comment_limit: int | None
 259 |     ) -> list[dict]:
 260 |         """
 261 |         Get comments for an issue if needed.
 262 | 
 263 |         Args:
 264 |             issue_key: The issue key
 265 |             comment_limit: Maximum number of comments to include
 266 | 
 267 |         Returns:
 268 |             List of comments
 269 |         """
 270 |         if comment_limit is None or comment_limit > 0:
 271 |             try:
 272 |                 response = self.jira.issue_get_comments(issue_key)
 273 |                 if not isinstance(response, dict):
 274 |                     msg = f"Unexpected return value type from `jira.issue_get_comments`: {type(response)}"
 275 |                     logger.error(msg)
 276 |                     raise TypeError(msg)
 277 | 
 278 |                 comments = response["comments"]
 279 | 
 280 |                 # Limit comments if needed
 281 |                 if comment_limit is not None:
 282 |                     comments = comments[:comment_limit]
 283 | 
 284 |                 return comments
 285 |             except Exception as e:
 286 |                 logger.warning(f"Error getting comments for {issue_key}: {str(e)}")
 287 |                 return []
 288 |         return []
 289 | 
 290 |     def _extract_epic_information(self, issue: dict) -> dict[str, str | None]:
 291 |         """
 292 |         Extract epic information from an issue.
 293 | 
 294 |         Args:
 295 |             issue: The issue data
 296 | 
 297 |         Returns:
 298 |             Dictionary with epic information
 299 |         """
 300 |         # Initialize with default values
 301 |         epic_info = {
 302 |             "epic_key": None,
 303 |             "epic_name": None,
 304 |             "epic_summary": None,
 305 |             "is_epic": False,
 306 |         }
 307 | 
 308 |         try:
 309 |             fields = issue.get("fields", {}) or {}
 310 |             issue_type = fields.get("issuetype", {}).get("name", "").lower()
 311 | 
 312 |             # Get field IDs for epic fields
 313 |             try:
 314 |                 field_ids = self.get_field_ids_to_epic()
 315 |             except Exception as e:
 316 |                 logger.warning(f"Error getting Jira fields: {str(e)}")
 317 |                 field_ids = {}
 318 | 
 319 |             # Check if this is an epic
 320 |             if issue_type == "epic":
 321 |                 epic_info["is_epic"] = True
 322 | 
 323 |                 # Use the discovered field ID for epic name
 324 |                 if "epic_name" in field_ids and field_ids["epic_name"] in fields:
 325 |                     epic_info["epic_name"] = fields.get(field_ids["epic_name"], "")
 326 | 
 327 |             # If not an epic, check for epic link
 328 |             elif "epic_link" in field_ids:
 329 |                 epic_link_field = field_ids["epic_link"]
 330 | 
 331 |                 if epic_link_field in fields and fields[epic_link_field]:
 332 |                     epic_key = fields[epic_link_field]
 333 |                     epic_info["epic_key"] = epic_key
 334 | 
 335 |                     # Try to get epic details
 336 |                     try:
 337 |                         epic = self.jira.get_issue(
 338 |                             epic_key,
 339 |                             expand=None,
 340 |                             fields=None,
 341 |                             properties=None,
 342 |                             update_history=True,
 343 |                         )
 344 |                         if not isinstance(epic, dict):
 345 |                             msg = f"Unexpected return value type from `jira.get_issue`: {type(epic)}"
 346 |                             logger.error(msg)
 347 |                             raise TypeError(msg)
 348 | 
 349 |                         epic_fields = epic.get("fields", {}) or {}
 350 | 
 351 |                         # Get epic name using the discovered field ID
 352 |                         if "epic_name" in field_ids:
 353 |                             epic_info["epic_name"] = epic_fields.get(
 354 |                                 field_ids["epic_name"], ""
 355 |                             )
 356 | 
 357 |                         epic_info["epic_summary"] = epic_fields.get("summary", "")
 358 |                     except Exception as e:
 359 |                         logger.warning(
 360 |                             f"Error getting epic details for {epic_key}: {str(e)}"
 361 |                         )
 362 |         except Exception as e:
 363 |             logger.warning(f"Error extracting epic information: {str(e)}")
 364 | 
 365 |         return epic_info
 366 | 
 367 |     def _format_issue_content(
 368 |         self,
 369 |         issue_key: str,
 370 |         issue: dict,
 371 |         description: str,
 372 |         comments: list[dict],
 373 |         created_date: str,
 374 |         epic_info: dict[str, str | None],
 375 |     ) -> str:
 376 |         """
 377 |         Format issue content for display.
 378 | 
 379 |         Args:
 380 |             issue_key: The issue key
 381 |             issue: The issue data
 382 |             description: The issue description
 383 |             comments: The issue comments
 384 |             created_date: The formatted creation date
 385 |             epic_info: Epic information
 386 | 
 387 |         Returns:
 388 |             Formatted issue content
 389 |         """
 390 |         fields = issue.get("fields", {})
 391 | 
 392 |         # Basic issue information
 393 |         summary = fields.get("summary", "")
 394 |         status = fields.get("status", {}).get("name", "")
 395 |         issue_type = fields.get("issuetype", {}).get("name", "")
 396 | 
 397 |         # Format content
 398 |         content = [f"# {issue_key}: {summary}"]
 399 |         content.append(f"**Type**: {issue_type}")
 400 |         content.append(f"**Status**: {status}")
 401 |         content.append(f"**Created**: {created_date}")
 402 | 
 403 |         # Add reporter
 404 |         reporter = fields.get("reporter", {})
 405 |         reporter_name = reporter.get("displayName", "") or reporter.get("name", "")
 406 |         if reporter_name:
 407 |             content.append(f"**Reporter**: {reporter_name}")
 408 | 
 409 |         # Add assignee
 410 |         assignee = fields.get("assignee", {})
 411 |         assignee_name = assignee.get("displayName", "") or assignee.get("name", "")
 412 |         if assignee_name:
 413 |             content.append(f"**Assignee**: {assignee_name}")
 414 | 
 415 |         # Add epic information
 416 |         if epic_info["is_epic"]:
 417 |             content.append(f"**Epic Name**: {epic_info['epic_name']}")
 418 |         elif epic_info["epic_key"]:
 419 |             content.append(
 420 |                 f"**Epic**: [{epic_info['epic_key']}] {epic_info['epic_summary']}"
 421 |             )
 422 | 
 423 |         # Add description
 424 |         if description:
 425 |             content.append("\n## Description\n")
 426 |             content.append(description)
 427 | 
 428 |         # Add comments
 429 |         if comments:
 430 |             content.append("\n## Comments\n")
 431 |             for comment in comments:
 432 |                 author = comment.get("author", {})
 433 |                 author_name = author.get("displayName", "") or author.get("name", "")
 434 |                 comment_body = self._clean_text(comment.get("body", ""))
 435 | 
 436 |                 if author_name and comment_body:
 437 |                     comment_date = comment.get("created", "")
 438 |                     if comment_date:
 439 |                         comment_date = parse_date(comment_date)
 440 |                         content.append(f"**{author_name}** ({comment_date}):")
 441 |                     else:
 442 |                         content.append(f"**{author_name}**:")
 443 | 
 444 |                     content.append(f"{comment_body}\n")
 445 | 
 446 |         return "\n".join(content)
 447 | 
 448 |     def _create_issue_metadata(
 449 |         self,
 450 |         issue_key: str,
 451 |         issue: dict,
 452 |         comments: list[dict],
 453 |         created_date: str,
 454 |         epic_info: dict[str, str | None],
 455 |     ) -> dict[str, Any]:
 456 |         """
 457 |         Create metadata for a Jira issue.
 458 | 
 459 |         Args:
 460 |             issue_key: The issue key
 461 |             issue: The issue data
 462 |             comments: The issue comments
 463 |             created_date: The formatted creation date
 464 |             epic_info: Epic information
 465 | 
 466 |         Returns:
 467 |             Metadata dictionary
 468 |         """
 469 |         fields = issue.get("fields", {})
 470 | 
 471 |         # Initialize metadata
 472 |         metadata = {
 473 |             "key": issue_key,
 474 |             "title": fields.get("summary", ""),
 475 |             "status": fields.get("status", {}).get("name", ""),
 476 |             "type": fields.get("issuetype", {}).get("name", ""),
 477 |             "created": created_date,
 478 |             "url": f"{self.config.url}/browse/{issue_key}",
 479 |         }
 480 | 
 481 |         # Add assignee if available
 482 |         assignee = fields.get("assignee", {})
 483 |         if assignee:
 484 |             metadata["assignee"] = assignee.get("displayName", "") or assignee.get(
 485 |                 "name", ""
 486 |             )
 487 | 
 488 |         # Add epic information
 489 |         if epic_info["is_epic"]:
 490 |             metadata["is_epic"] = True
 491 |             metadata["epic_name"] = epic_info["epic_name"]
 492 |         elif epic_info["epic_key"]:
 493 |             metadata["epic_key"] = epic_info["epic_key"]
 494 |             metadata["epic_name"] = epic_info["epic_name"]
 495 |             metadata["epic_summary"] = epic_info["epic_summary"]
 496 | 
 497 |         # Add comment count
 498 |         metadata["comment_count"] = len(comments)
 499 | 
 500 |         return metadata
 501 | 
 502 |     def create_issue(
 503 |         self,
 504 |         project_key: str,
 505 |         summary: str,
 506 |         issue_type: str,
 507 |         description: str = "",
 508 |         assignee: str | None = None,
 509 |         components: list[str] | None = None,
 510 |         **kwargs: Any,  # noqa: ANN401 - Dynamic field types are necessary for Jira API
 511 |     ) -> JiraIssue:
 512 |         """
 513 |         Create a new Jira issue.
 514 | 
 515 |         Args:
 516 |             project_key: The key of the project
 517 |             summary: The issue summary
 518 |             issue_type: The type of issue to create
 519 |             description: The issue description
 520 |             assignee: The username or account ID of the assignee
 521 |             components: List of component names to assign (e.g., ["Frontend", "API"])
 522 |             **kwargs: Additional fields to set on the issue
 523 | 
 524 |         Returns:
 525 |             JiraIssue model representing the created issue
 526 | 
 527 |         Raises:
 528 |             Exception: If there is an error creating the issue
 529 |         """
 530 |         try:
 531 |             # Validate required fields
 532 |             if not project_key:
 533 |                 raise ValueError("Project key is required")
 534 |             if not summary:
 535 |                 raise ValueError("Summary is required")
 536 |             if not issue_type:
 537 |                 raise ValueError("Issue type is required")
 538 | 
 539 |             # Handle Epic and Subtask issue type names across different languages
 540 |             actual_issue_type = issue_type
 541 |             if self._is_epic_issue_type(issue_type) and issue_type.lower() == "epic":
 542 |                 # If the user provided "Epic" but we need to find the localized name
 543 |                 epic_type_name = self._find_epic_issue_type_name(project_key)
 544 |                 if epic_type_name:
 545 |                     actual_issue_type = epic_type_name
 546 |                     logger.info(
 547 |                         f"Using localized Epic issue type name: {actual_issue_type}"
 548 |                     )
 549 |             elif issue_type.lower() in ["subtask", "sub-task"]:
 550 |                 # If the user provided "Subtask" but we need to find the localized name
 551 |                 subtask_type_name = self._find_subtask_issue_type_name(project_key)
 552 |                 if subtask_type_name:
 553 |                     actual_issue_type = subtask_type_name
 554 |                     logger.info(
 555 |                         f"Using localized Subtask issue type name: {actual_issue_type}"
 556 |                     )
 557 | 
 558 |             # Prepare fields
 559 |             fields: dict[str, Any] = {
 560 |                 "project": {"key": project_key},
 561 |                 "summary": summary,
 562 |                 "issuetype": {"name": actual_issue_type},
 563 |             }
 564 | 
 565 |             # Add description if provided (convert from Markdown to Jira format)
 566 |             if description:
 567 |                 fields["description"] = self._markdown_to_jira(description)
 568 | 
 569 |             # Add assignee if provided
 570 |             if assignee:
 571 |                 try:
 572 |                     # _get_account_id now returns the correct identifier (accountId for cloud, name for server)
 573 |                     assignee_identifier = self._get_account_id(assignee)
 574 |                     self._add_assignee_to_fields(fields, assignee_identifier)
 575 |                 except ValueError as e:
 576 |                     logger.warning(f"Could not assign issue: {str(e)}")
 577 | 
 578 |             # Add components if provided
 579 |             if components:
 580 |                 if isinstance(components, list):
 581 |                     # Filter out any None or empty/whitespace-only strings
 582 |                     valid_components = [
 583 |                         comp_name.strip()
 584 |                         for comp_name in components
 585 |                         if isinstance(comp_name, str) and comp_name.strip()
 586 |                     ]
 587 |                     if valid_components:
 588 |                         # Format as list of {"name": ...} dicts for the API
 589 |                         fields["components"] = [
 590 |                             {"name": comp_name} for comp_name in valid_components
 591 |                         ]
 592 | 
 593 |             # Make a copy of kwargs to preserve original values for two-step Epic creation
 594 |             kwargs_copy = kwargs.copy()
 595 | 
 596 |             # Prepare epic fields if this is an epic
 597 |             # This step now stores epic-specific fields in kwargs for post-creation update
 598 |             if self._is_epic_issue_type(issue_type):
 599 |                 self._prepare_epic_fields(fields, summary, kwargs)
 600 | 
 601 |             # Prepare parent field if this is a subtask
 602 |             if issue_type.lower() == "subtask" or issue_type.lower() == "sub-task":
 603 |                 self._prepare_parent_fields(fields, kwargs)
 604 |             # Allow parent field for all issue types when explicitly provided
 605 |             elif "parent" in kwargs:
 606 |                 self._prepare_parent_fields(fields, kwargs)
 607 | 
 608 |             # Process **kwargs using the dynamic field map
 609 |             self._process_additional_fields(fields, kwargs_copy)
 610 | 
 611 |             # Create the issue
 612 |             response = self.jira.create_issue(fields=fields)
 613 |             if not isinstance(response, dict):
 614 |                 msg = f"Unexpected return value type from `jira.create_issue`: {type(response)}"
 615 |                 logger.error(msg)
 616 |                 raise TypeError(msg)
 617 | 
 618 |             # Get the created issue key
 619 |             issue_key = response.get("key")
 620 |             if not issue_key:
 621 |                 error_msg = "No issue key in response"
 622 |                 raise ValueError(error_msg)
 623 | 
 624 |             # For Epics, perform the second step: update Epic-specific fields
 625 |             if self._is_epic_issue_type(issue_type):
 626 |                 # Check if we have any stored Epic fields to update
 627 |                 has_epic_fields = any(k.startswith("__epic_") for k in kwargs)
 628 |                 if has_epic_fields:
 629 |                     logger.info(
 630 |                         f"Performing post-creation update for Epic {issue_key} with Epic-specific fields"
 631 |                     )
 632 |                     try:
 633 |                         return self.update_epic_fields(issue_key, kwargs)
 634 |                     except Exception as update_error:
 635 |                         logger.error(
 636 |                             f"Error during post-creation update of Epic {issue_key}: {str(update_error)}"
 637 |                         )
 638 |                         logger.info(
 639 |                             "Continuing with the original Epic that was successfully created"
 640 |                         )
 641 | 
 642 |             # Get the full issue data and convert to JiraIssue model
 643 |             issue_data = self.jira.get_issue(issue_key)
 644 |             if not isinstance(issue_data, dict):
 645 |                 msg = f"Unexpected return value type from `jira.get_issue`: {type(issue_data)}"
 646 |                 logger.error(msg)
 647 |                 raise TypeError(msg)
 648 |             return JiraIssue.from_api_response(issue_data)
 649 | 
 650 |         except Exception as e:
 651 |             self._handle_create_issue_error(e, issue_type)
 652 |             raise  # Re-raise after logging
 653 | 
 654 |     def _is_epic_issue_type(self, issue_type: str) -> bool:
 655 |         """
 656 |         Check if an issue type is an Epic, handling localized names.
 657 | 
 658 |         Args:
 659 |             issue_type: The issue type name to check
 660 | 
 661 |         Returns:
 662 |             True if the issue type is an Epic, False otherwise
 663 |         """
 664 |         # Common Epic names in different languages
 665 |         epic_names = {
 666 |             "epic",  # English
 667 |             "에픽",  # Korean
 668 |             "エピック",  # Japanese
 669 |             "史诗",  # Chinese (Simplified)
 670 |             "史詩",  # Chinese (Traditional)
 671 |             "épica",  # Spanish/Portuguese
 672 |             "épique",  # French
 673 |             "epik",  # Turkish
 674 |             "эпик",  # Russian
 675 |             "епік",  # Ukrainian
 676 |         }
 677 | 
 678 |         return issue_type.lower() in epic_names or "epic" in issue_type.lower()
 679 | 
 680 |     def _find_epic_issue_type_name(self, project_key: str) -> str | None:
 681 |         """
 682 |         Find the actual Epic issue type name for a project.
 683 | 
 684 |         Args:
 685 |             project_key: The project key
 686 | 
 687 |         Returns:
 688 |             The Epic issue type name if found, None otherwise
 689 |         """
 690 |         try:
 691 |             issue_types = self.get_project_issue_types(project_key)
 692 |             for issue_type in issue_types:
 693 |                 type_name = issue_type.get("name", "")
 694 |                 if self._is_epic_issue_type(type_name):
 695 |                     return type_name
 696 |             return None
 697 |         except Exception as e:
 698 |             logger.warning(f"Could not get issue types for project {project_key}: {e}")
 699 |             return None
 700 | 
 701 |     def _find_subtask_issue_type_name(self, project_key: str) -> str | None:
 702 |         """
 703 |         Find the actual Subtask issue type name for a project.
 704 | 
 705 |         Args:
 706 |             project_key: The project key
 707 | 
 708 |         Returns:
 709 |             The Subtask issue type name if found, None otherwise
 710 |         """
 711 |         try:
 712 |             issue_types = self.get_project_issue_types(project_key)
 713 |             for issue_type in issue_types:
 714 |                 # Check the subtask field - this is the most reliable way
 715 |                 if issue_type.get("subtask", False):
 716 |                     return issue_type.get("name")
 717 |             return None
 718 |         except Exception as e:
 719 |             logger.warning(f"Could not get issue types for project {project_key}: {e}")
 720 |             return None
 721 | 
 722 |     def _prepare_epic_fields(
 723 |         self, fields: dict[str, Any], summary: str, kwargs: dict[str, Any]
 724 |     ) -> None:
 725 |         """
 726 |         Prepare fields for epic creation.
 727 | 
 728 |         This method delegates to the prepare_epic_fields method in EpicsMixin.
 729 | 
 730 |         Args:
 731 |             fields: The fields dictionary to update
 732 |             summary: The epic summary
 733 |             kwargs: Additional fields from the user
 734 |         """
 735 |         # Extract project_key from fields if available
 736 |         project_key = None
 737 |         if "project" in fields:
 738 |             if isinstance(fields["project"], dict):
 739 |                 project_key = fields["project"].get("key")
 740 |             elif isinstance(fields["project"], str):
 741 |                 project_key = fields["project"]
 742 | 
 743 |         # Delegate to EpicsMixin.prepare_epic_fields with project_key
 744 |         # Since JiraFetcher inherits from both IssuesMixin and EpicsMixin,
 745 |         # this will correctly use the prepare_epic_fields method from EpicsMixin
 746 |         # which implements the two-step Epic creation approach
 747 |         self.prepare_epic_fields(fields, summary, kwargs, project_key)
 748 | 
 749 |     def _prepare_parent_fields(
 750 |         self, fields: dict[str, Any], kwargs: dict[str, Any]
 751 |     ) -> None:
 752 |         """
 753 |         Prepare fields for parent relationship.
 754 | 
 755 |         Args:
 756 |             fields: The fields dictionary to update
 757 |             kwargs: Additional fields from the user
 758 | 
 759 |         Raises:
 760 |             ValueError: If parent issue key is not specified for a subtask
 761 |         """
 762 |         if "parent" in kwargs:
 763 |             parent_key = kwargs.get("parent")
 764 |             if parent_key:
 765 |                 fields["parent"] = {"key": parent_key}
 766 |             # Remove parent from kwargs to avoid double processing
 767 |             kwargs.pop("parent", None)
 768 |         elif "issuetype" in fields and fields["issuetype"]["name"].lower() in (
 769 |             "subtask",
 770 |             "sub-task",
 771 |         ):
 772 |             # Only raise error if issue type is subtask and parent is missing
 773 |             raise ValueError(
 774 |                 "Issue type is a sub-task but parent issue key or id not specified. Please provide a 'parent' parameter with the parent issue key."
 775 |             )
 776 | 
 777 |     def _add_assignee_to_fields(self, fields: dict[str, Any], assignee: str) -> None:
 778 |         """
 779 |         Add assignee to issue fields.
 780 | 
 781 |         Args:
 782 |             fields: The fields dictionary to update
 783 |             assignee: The assignee account ID
 784 |         """
 785 |         # Cloud instance uses accountId
 786 |         if self.config.is_cloud:
 787 |             fields["assignee"] = {"accountId": assignee}
 788 |         else:
 789 |             # Server/DC might use name instead of accountId
 790 |             fields["assignee"] = {"name": assignee}
 791 | 
 792 |     def _process_additional_fields(
 793 |         self, fields: dict[str, Any], kwargs: dict[str, Any]
 794 |     ) -> None:
 795 |         """
 796 |         Processes keyword arguments to add standard or custom fields to the issue fields dictionary.
 797 |         Uses the dynamic field map from FieldsMixin to identify field IDs.
 798 | 
 799 |         Args:
 800 |             fields: The fields dictionary to update
 801 |             kwargs: Additional fields provided via **kwargs
 802 |         """
 803 |         # Ensure field map is loaded/cached
 804 |         field_map = (
 805 |             self._generate_field_map()
 806 |         )  # Ensure map is ready (method from FieldsMixin)
 807 |         if not field_map:
 808 |             logger.error(
 809 |                 "Could not generate field map. Cannot process additional fields."
 810 |             )
 811 |             return
 812 | 
 813 |         # Process each kwarg
 814 |         # Iterate over a copy to allow modification of the original kwargs if needed elsewhere
 815 |         for key, value in kwargs.copy().items():
 816 |             # Skip keys used internally for epic/parent handling or explicitly handled args like assignee/components
 817 |             if key.startswith("__epic_") or key in ("parent", "assignee", "components"):
 818 |                 continue
 819 | 
 820 |             normalized_key = key.lower()
 821 |             api_field_id = None
 822 | 
 823 |             # 1. Check if key is a known field name in the map
 824 |             if normalized_key in field_map:
 825 |                 api_field_id = field_map[normalized_key]
 826 |                 logger.debug(
 827 |                     f"Identified field '{key}' as '{api_field_id}' via name map."
 828 |                 )
 829 | 
 830 |             # 2. Check if key is a direct custom field ID
 831 |             elif key.startswith("customfield_"):
 832 |                 api_field_id = key
 833 |                 logger.debug(f"Identified field '{key}' as direct custom field ID.")
 834 | 
 835 |             # 3. Check if key is a standard system field ID (like 'summary', 'priority')
 836 |             elif key in field_map:  # Check original case for system fields
 837 |                 api_field_id = field_map[key]
 838 |                 logger.debug(f"Identified field '{key}' as standard system field ID.")
 839 | 
 840 |             if api_field_id:
 841 |                 # Get the full field definition for formatting context if needed
 842 |                 field_definition = self.get_field_by_id(
 843 |                     api_field_id
 844 |                 )  # From FieldsMixin
 845 |                 formatted_value = self._format_field_value_for_write(
 846 |                     api_field_id, value, field_definition
 847 |                 )
 848 |                 if formatted_value is not None:  # Only add if formatting didn't fail
 849 |                     fields[api_field_id] = formatted_value
 850 |                     logger.debug(
 851 |                         f"Added field '{api_field_id}' from kwarg '{key}': {formatted_value}"
 852 |                     )
 853 |                 else:
 854 |                     logger.warning(
 855 |                         f"Skipping field '{key}' due to formatting error or invalid value."
 856 |                     )
 857 |             else:
 858 |                 # 4. Unrecognized key - log a warning and skip
 859 |                 logger.warning(
 860 |                     f"Ignoring unrecognized field '{key}' passed via kwargs."
 861 |                 )
 862 | 
 863 |     def _format_field_value_for_write(
 864 |         self, field_id: str, value: Any, field_definition: dict | None
 865 |     ) -> Any:
 866 |         """Formats field values for the Jira API."""
 867 |         # Get schema type if definition is available
 868 |         schema_type = (
 869 |             field_definition.get("schema", {}).get("type") if field_definition else None
 870 |         )
 871 |         # Prefer name from definition if available, else use ID for logging/lookup
 872 |         field_name_for_format = (
 873 |             field_definition.get("name", field_id) if field_definition else field_id
 874 |         )
 875 | 
 876 |         # Example formatting rules based on standard field names (use lowercase for comparison)
 877 |         normalized_name = field_name_for_format.lower()
 878 | 
 879 |         if normalized_name == "priority":
 880 |             if isinstance(value, str):
 881 |                 return {"name": value}
 882 |             elif isinstance(value, dict) and ("name" in value or "id" in value):
 883 |                 return value  # Assume pre-formatted
 884 |             else:
 885 |                 logger.warning(
 886 |                     f"Invalid format for priority field: {value}. Expected string name or dict."
 887 |                 )
 888 |                 return None  # Or raise error
 889 |         elif normalized_name == "labels":
 890 |             if isinstance(value, list) and all(isinstance(item, str) for item in value):
 891 |                 return value
 892 |             # Allow comma-separated string if passed via additional_fields JSON string
 893 |             elif isinstance(value, str):
 894 |                 return [label.strip() for label in value.split(",") if label.strip()]
 895 |             else:
 896 |                 logger.warning(
 897 |                     f"Invalid format for labels field: {value}. Expected list of strings or comma-separated string."
 898 |                 )
 899 |                 return None
 900 |         elif normalized_name in ["fixversions", "versions", "components"]:
 901 |             # These expect lists of objects, typically {"name": "..."} or {"id": "..."}
 902 |             if isinstance(value, list):
 903 |                 formatted_list = []
 904 |                 for item in value:
 905 |                     if isinstance(item, str):
 906 |                         formatted_list.append({"name": item})  # Convert simple strings
 907 |                     elif isinstance(item, dict) and ("name" in item or "id" in item):
 908 |                         formatted_list.append(item)  # Keep pre-formatted dicts
 909 |                     else:
 910 |                         logger.warning(
 911 |                             f"Invalid item format in {normalized_name} list: {item}"
 912 |                         )
 913 |                 return formatted_list
 914 |             else:
 915 |                 logger.warning(
 916 |                     f"Invalid format for {normalized_name} field: {value}. Expected list."
 917 |                 )
 918 |                 return None
 919 |         elif normalized_name == "reporter":
 920 |             if isinstance(value, str):
 921 |                 try:
 922 |                     reporter_identifier = self._get_account_id(value)
 923 |                     if self.config.is_cloud:
 924 |                         return {"accountId": reporter_identifier}
 925 |                     else:
 926 |                         return {"name": reporter_identifier}
 927 |                 except ValueError as e:
 928 |                     logger.warning(f"Could not format reporter field: {str(e)}")
 929 |                     return None
 930 |             elif isinstance(value, dict) and ("name" in value or "accountId" in value):
 931 |                 return value  # Assume pre-formatted
 932 |             else:
 933 |                 logger.warning(f"Invalid format for reporter field: {value}")
 934 |                 return None
 935 |         # Add more formatting rules for other standard fields based on schema_type or field_id
 936 |         elif normalized_name == "duedate":
 937 |             if isinstance(value, str):  # Basic check, could add date validation
 938 |                 return value
 939 |             else:
 940 |                 logger.warning(
 941 |                     f"Invalid format for duedate field: {value}. Expected YYYY-MM-DD string."
 942 |                 )
 943 |                 return None
 944 |         elif schema_type == "datetime" and isinstance(value, str):
 945 |             # Example: Ensure datetime fields are in ISO format if needed by API
 946 |             try:
 947 |                 dt = parse_date(value)  # Assuming parse_date handles various inputs
 948 |                 return (
 949 |                     dt.isoformat() if dt else value
 950 |                 )  # Return ISO or original if parse fails
 951 |             except Exception:
 952 |                 logger.warning(
 953 |                     f"Could not parse datetime for field {field_id}: {value}"
 954 |                 )
 955 |                 return value  # Return original on error
 956 | 
 957 |         # Default: return value as is if no specific formatting needed/identified
 958 |         return value
 959 | 
 960 |     def _handle_create_issue_error(self, exception: Exception, issue_type: str) -> None:
 961 |         """
 962 |         Handle errors when creating an issue.
 963 | 
 964 |         Args:
 965 |             exception: The exception that occurred
 966 |             issue_type: The type of issue being created
 967 |         """
 968 |         error_msg = str(exception)
 969 | 
 970 |         # Check for specific error types
 971 |         if "epic name" in error_msg.lower() or "epicname" in error_msg.lower():
 972 |             logger.error(
 973 |                 f"Error creating {issue_type}: {error_msg}. "
 974 |                 "Try specifying an epic_name in the additional fields"
 975 |             )
 976 |         elif "customfield" in error_msg.lower():
 977 |             logger.error(
 978 |                 f"Error creating {issue_type}: {error_msg}. "
 979 |                 "This may be due to a required custom field"
 980 |             )
 981 |         else:
 982 |             logger.error(f"Error creating {issue_type}: {error_msg}")
 983 | 
 984 |     def update_issue(
 985 |         self,
 986 |         issue_key: str,
 987 |         fields: dict[str, Any] | None = None,
 988 |         **kwargs: Any,  # noqa: ANN401 - Dynamic field types are necessary for Jira API
 989 |     ) -> JiraIssue:
 990 |         """
 991 |         Update a Jira issue.
 992 | 
 993 |         Args:
 994 |             issue_key: The key of the issue to update
 995 |             fields: Dictionary of fields to update
 996 |             **kwargs: Additional fields to update. Special fields include:
 997 |                 - attachments: List of file paths to upload as attachments
 998 |                 - status: New status for the issue (handled via transitions)
 999 |                 - assignee: New assignee for the issue
1000 | 
1001 |         Returns:
1002 |             JiraIssue model representing the updated issue
1003 | 
1004 |         Raises:
1005 |             Exception: If there is an error updating the issue
1006 |         """
1007 |         try:
1008 |             # Validate required fields
1009 |             if not issue_key:
1010 |                 raise ValueError("Issue key is required")
1011 | 
1012 |             update_fields = fields or {}
1013 |             attachments_result = None
1014 | 
1015 |             # Convert description from Markdown to Jira format if present
1016 |             if "description" in update_fields:
1017 |                 update_fields["description"] = self._markdown_to_jira(
1018 |                     update_fields["description"]
1019 |                 )
1020 | 
1021 |             # Process kwargs
1022 |             for key, value in kwargs.items():
1023 |                 if key == "status":
1024 |                     # Status changes are handled separately via transitions
1025 |                     # Add status to fields so _update_issue_with_status can find it
1026 |                     update_fields["status"] = value
1027 |                     return self._update_issue_with_status(issue_key, update_fields)
1028 | 
1029 |                 elif key == "attachments":
1030 |                     # Handle attachments separately - they're not part of fields update
1031 |                     if value and isinstance(value, list | tuple):
1032 |                         # We'll process attachments after updating fields
1033 |                         pass
1034 |                     else:
1035 |                         logger.warning(f"Invalid attachments value: {value}")
1036 | 
1037 |                 elif key == "assignee":
1038 |                     # Handle assignee updates, allow unassignment with None or empty string
1039 |                     if value is None or value == "":
1040 |                         update_fields["assignee"] = None
1041 |                     else:
1042 |                         try:
1043 |                             account_id = self._get_account_id(value)
1044 |                             self._add_assignee_to_fields(update_fields, account_id)
1045 |                         except ValueError as e:
1046 |                             logger.warning(f"Could not update assignee: {str(e)}")
1047 |                 elif key == "description":
1048 |                     # Handle description with markdown conversion
1049 |                     update_fields["description"] = self._markdown_to_jira(value)
1050 |                 else:
1051 |                     # Process regular fields using _process_additional_fields
1052 |                     # Create a temporary dict with just this field
1053 |                     field_kwargs = {key: value}
1054 |                     self._process_additional_fields(update_fields, field_kwargs)
1055 | 
1056 |             # Update the issue fields
1057 |             if update_fields:
1058 |                 self.jira.update_issue(
1059 |                     issue_key=issue_key, update={"fields": update_fields}
1060 |                 )
1061 | 
1062 |             # Handle attachments if provided
1063 |             if "attachments" in kwargs and kwargs["attachments"]:
1064 |                 try:
1065 |                     attachments_result = self.upload_attachments(
1066 |                         issue_key, kwargs["attachments"]
1067 |                     )
1068 |                     logger.info(
1069 |                         f"Uploaded attachments to {issue_key}: {attachments_result}"
1070 |                     )
1071 |                 except Exception as e:
1072 |                     logger.error(
1073 |                         f"Error uploading attachments to {issue_key}: {str(e)}"
1074 |                     )
1075 |                     # Continue with the update even if attachments fail
1076 | 
1077 |             # Get the updated issue data and convert to JiraIssue model
1078 |             issue_data = self.jira.get_issue(issue_key)
1079 |             if not isinstance(issue_data, dict):
1080 |                 msg = f"Unexpected return value type from `jira.get_issue`: {type(issue_data)}"
1081 |                 logger.error(msg)
1082 |                 raise TypeError(msg)
1083 |             issue = JiraIssue.from_api_response(issue_data)
1084 | 
1085 |             # Add attachment results to the response if available
1086 |             if attachments_result:
1087 |                 issue.custom_fields["attachment_results"] = attachments_result
1088 | 
1089 |             return issue
1090 | 
1091 |         except Exception as e:
1092 |             error_msg = str(e)
1093 |             logger.error(f"Error updating issue {issue_key}: {error_msg}")
1094 |             raise ValueError(f"Failed to update issue {issue_key}: {error_msg}") from e
1095 | 
1096 |     def _update_issue_with_status(
1097 |         self, issue_key: str, fields: dict[str, Any]
1098 |     ) -> JiraIssue:
1099 |         """
1100 |         Update an issue with a status change.
1101 | 
1102 |         Args:
1103 |             issue_key: The key of the issue to update
1104 |             fields: Dictionary of fields to update
1105 | 
1106 |         Returns:
1107 |             JiraIssue model representing the updated issue
1108 | 
1109 |         Raises:
1110 |             Exception: If there is an error updating the issue
1111 |         """
1112 |         # Extract status from fields and remove it for the standard update
1113 |         status = fields.pop("status", None)
1114 | 
1115 |         # First update any fields if needed
1116 |         if fields:
1117 |             self.jira.update_issue(issue_key=issue_key, fields=fields)  # type: ignore[call-arg]
1118 | 
1119 |         # If no status change is requested, return the issue
1120 |         if not status:
1121 |             issue_data = self.jira.get_issue(issue_key)
1122 |             if not isinstance(issue_data, dict):
1123 |                 msg = f"Unexpected return value type from `jira.get_issue`: {type(issue_data)}"
1124 |                 logger.error(msg)
1125 |                 raise TypeError(msg)
1126 |             return JiraIssue.from_api_response(issue_data)
1127 | 
1128 |         # Get available transitions (uses TransitionsMixin's normalized implementation)
1129 |         transitions = self.get_available_transitions(issue_key)  # type: ignore[attr-defined]
1130 | 
1131 |         # Extract status name or ID depending on what we received
1132 |         status_name = None
1133 |         status_id = None
1134 | 
1135 |         # Handle different input formats for status
1136 |         if isinstance(status, dict):
1137 |             # Dictionary format: {"name": "In Progress"} or {"id": "123"}
1138 |             status_name = status.get("name")
1139 |             status_id = status.get("id")
1140 |         elif isinstance(status, str):
1141 |             # String format: could be a name or an ID
1142 |             if status.isdigit():
1143 |                 status_id = status
1144 |             else:
1145 |                 status_name = status
1146 |         elif isinstance(status, int):
1147 |             # Integer format: must be an ID
1148 |             status_id = str(status)
1149 |         else:
1150 |             # Unknown format
1151 |             logger.warning(
1152 |                 f"Unrecognized status format: {status} (type: {type(status)})"
1153 |             )
1154 |             status_name = str(status)
1155 | 
1156 |         # Log what we're searching for
1157 |         if status_name:
1158 |             logger.info(f"Looking for transition to status name: '{status_name}'")
1159 |         if status_id:
1160 |             logger.info(f"Looking for transition with ID: '{status_id}'")
1161 | 
1162 |         # Find the appropriate transition
1163 |         transition_id = None
1164 |         for transition in transitions:
1165 |             # TransitionsMixin returns normalized transitions with 'to_status' field
1166 |             transition_status_name = transition.get("to_status", "")
1167 | 
1168 |             # Match by name (case-insensitive)
1169 |             if (
1170 |                 status_name
1171 |                 and transition_status_name
1172 |                 and transition_status_name.lower() == status_name.lower()
1173 |             ):
1174 |                 transition_id = transition.get("id")
1175 |                 logger.info(
1176 |                     f"Found transition ID {transition_id} matching status name '{status_name}'"
1177 |                 )
1178 |                 break
1179 | 
1180 |             # Direct transition ID match (if status is actually a transition ID)
1181 |             if status_id and str(transition.get("id", "")) == str(status_id):
1182 |                 transition_id = transition.get("id")
1183 |                 logger.info(f"Using direct transition ID {transition_id}")
1184 |                 break
1185 | 
1186 |         if not transition_id:
1187 |             # Build list of available statuses from normalized transitions
1188 |             available_statuses = []
1189 |             for t in transitions:
1190 |                 # Include transition name and target status if available
1191 |                 transition_name = t.get("name", "")
1192 |                 to_status = t.get("to_status", "")
1193 |                 if to_status:
1194 |                     available_statuses.append(f"{transition_name} -> {to_status}")
1195 |                 elif transition_name:
1196 |                     available_statuses.append(transition_name)
1197 | 
1198 |             available_statuses_str = (
1199 |                 ", ".join(available_statuses) if available_statuses else "None found"
1200 |             )
1201 |             error_msg = (
1202 |                 f"Could not find transition to status '{status}'. "
1203 |                 f"Available transitions: {available_statuses_str}"
1204 |             )
1205 |             logger.error(error_msg)
1206 |             raise ValueError(error_msg)
1207 | 
1208 |         # Perform the transition
1209 |         logger.info(f"Performing transition with ID {transition_id}")
1210 |         self.jira.set_issue_status_by_transition_id(
1211 |             issue_key=issue_key,
1212 |             transition_id=(
1213 |                 int(transition_id)
1214 |                 if isinstance(transition_id, str) and transition_id.isdigit()
1215 |                 else transition_id
1216 |             ),
1217 |         )
1218 | 
1219 |         # Get the updated issue data
1220 |         issue_data = self.jira.get_issue(issue_key)
1221 |         if not isinstance(issue_data, dict):
1222 |             msg = f"Unexpected return value type from `jira.get_issue`: {type(issue_data)}"
1223 |             logger.error(msg)
1224 |             raise TypeError(msg)
1225 |         return JiraIssue.from_api_response(issue_data)
1226 | 
1227 |     def delete_issue(self, issue_key: str) -> bool:
1228 |         """
1229 |         Delete a Jira issue.
1230 | 
1231 |         Args:
1232 |             issue_key: The key of the issue to delete
1233 | 
1234 |         Returns:
1235 |             True if the issue was deleted successfully
1236 | 
1237 |         Raises:
1238 |             Exception: If there is an error deleting the issue
1239 |         """
1240 |         try:
1241 |             self.jira.delete_issue(issue_key)
1242 |             return True
1243 |         except Exception as e:
1244 |             msg = f"Error deleting issue {issue_key}: {str(e)}"
1245 |             logger.error(msg)
1246 |             raise Exception(msg) from e
1247 | 
1248 |     def _log_available_fields(self, fields: list[dict]) -> None:
1249 |         """
1250 |         Log available fields for debugging.
1251 | 
1252 |         Args:
1253 |             fields: List of field definitions
1254 |         """
1255 |         logger.debug("Available Jira fields:")
1256 |         for field in fields:
1257 |             logger.debug(
1258 |                 f"{field.get('id')}: {field.get('name')} ({field.get('schema', {}).get('type')})"
1259 |             )
1260 | 
1261 |     def _process_field_for_epic_data(
1262 |         self, field: dict, field_ids: dict[str, str]
1263 |     ) -> None:
1264 |         """
1265 |         Process a field for epic-related data.
1266 | 
1267 |         Args:
1268 |             field: The field data to process
1269 |             field_ids: Dictionary of field IDs to update
1270 |         """
1271 |         try:
1272 |             field_id = field.get("id")
1273 |             if not field_id:
1274 |                 return
1275 | 
1276 |             # Skip non-custom fields
1277 |             if not field_id.startswith("customfield_"):
1278 |                 return
1279 | 
1280 |             name = field.get("name", "").lower()
1281 | 
1282 |             # Look for field names related to epics
1283 |             if "epic" in name:
1284 |                 if "link" in name:
1285 |                     field_ids["epic_link"] = field_id
1286 |                     field_ids["Epic Link"] = field_id
1287 |                 elif "name" in name:
1288 |                     field_ids["epic_name"] = field_id
1289 |                     field_ids["Epic Name"] = field_id
1290 |         except Exception as e:
1291 |             logger.warning(f"Error processing field for epic data: {str(e)}")
1292 | 
1293 |     def _get_raw_transitions(self, issue_key: str) -> list[dict]:
1294 |         """
1295 |         Get raw transition data from the Jira API.
1296 | 
1297 |         This is an internal method that returns unprocessed transition data.
1298 |         For normalized transitions with proper structure, use get_available_transitions()
1299 |         from TransitionsMixin instead.
1300 | 
1301 |         Args:
1302 |             issue_key: The key of the issue
1303 | 
1304 |         Returns:
1305 |             List of raw transition data from the API
1306 | 
1307 |         Raises:
1308 |             Exception: If there is an error getting transitions
1309 |         """
1310 |         try:
1311 |             transitions = self.jira.get_issue_transitions(issue_key)
1312 |             return transitions
1313 |         except Exception as e:
1314 |             logger.error(f"Error getting transitions for issue {issue_key}: {str(e)}")
1315 |             raise Exception(
1316 |                 f"Error getting transitions for issue {issue_key}: {str(e)}"
1317 |             ) from e
1318 | 
1319 |     def transition_issue(self, issue_key: str, transition_id: str) -> JiraIssue:
1320 |         """
1321 |         Transition an issue to a new status.
1322 | 
1323 |         Args:
1324 |             issue_key: The key of the issue
1325 |             transition_id: The ID of the transition to perform
1326 | 
1327 |         Returns:
1328 |             JiraIssue model with the updated issue data
1329 | 
1330 |         Raises:
1331 |             Exception: If there is an error transitioning the issue
1332 |         """
1333 |         try:
1334 |             self.jira.set_issue_status(
1335 |                 issue_key=issue_key, status_name=transition_id, fields=None, update=None
1336 |             )
1337 |             return self.get_issue(issue_key)
1338 |         except Exception as e:
1339 |             logger.error(f"Error transitioning issue {issue_key}: {str(e)}")
1340 |             raise
1341 | 
1342 |     def batch_create_issues(
1343 |         self,
1344 |         issues: list[dict[str, Any]],
1345 |         validate_only: bool = False,
1346 |     ) -> list[JiraIssue]:
1347 |         """Create multiple Jira issues in a batch.
1348 | 
1349 |         Args:
1350 |             issues: List of issue dictionaries, each containing:
1351 |                 - project_key (str): Key of the project
1352 |                 - summary (str): Issue summary
1353 |                 - issue_type (str): Type of issue
1354 |                 - description (str, optional): Issue description
1355 |                 - assignee (str, optional): Username of assignee
1356 |                 - components (list[str], optional): List of component names
1357 |                 - **kwargs: Additional fields specific to your Jira instance
1358 |             validate_only: If True, only validates the issues without creating them
1359 | 
1360 |         Returns:
1361 |             List of created JiraIssue objects
1362 | 
1363 |         Raises:
1364 |             ValueError: If any required fields are missing or invalid
1365 |             MCPAtlassianAuthenticationError: If authentication fails
1366 |         """
1367 |         if not issues:
1368 |             return []
1369 | 
1370 |         # Prepare issues for bulk creation
1371 |         issue_updates = []
1372 |         for issue_data in issues:
1373 |             try:
1374 |                 # Extract and validate required fields
1375 |                 project_key = issue_data.pop("project_key", None)
1376 |                 summary = issue_data.pop("summary", None)
1377 |                 issue_type = issue_data.pop("issue_type", None)
1378 |                 description = issue_data.pop("description", "")
1379 |                 assignee = issue_data.pop("assignee", None)
1380 |                 components = issue_data.pop("components", None)
1381 | 
1382 |                 # Validate required fields
1383 |                 if not all([project_key, summary, issue_type]):
1384 |                     raise ValueError(
1385 |                         f"Missing required fields for issue: {project_key=}, {summary=}, {issue_type=}"
1386 |                     )
1387 | 
1388 |                 # Prepare fields dictionary
1389 |                 fields = {
1390 |                     "project": {"key": project_key},
1391 |                     "summary": summary,
1392 |                     "issuetype": {"name": issue_type},
1393 |                 }
1394 | 
1395 |                 # Add optional fields
1396 |                 if description:
1397 |                     fields["description"] = description
1398 | 
1399 |                 # Add assignee if provided
1400 |                 if assignee:
1401 |                     try:
1402 |                         # _get_account_id now returns the correct identifier (accountId for cloud, name for server)
1403 |                         assignee_identifier = self._get_account_id(assignee)
1404 |                         self._add_assignee_to_fields(fields, assignee_identifier)
1405 |                     except ValueError as e:
1406 |                         logger.warning(f"Could not assign issue: {str(e)}")
1407 | 
1408 |                 # Add components if provided
1409 |                 if components:
1410 |                     if isinstance(components, list):
1411 |                         valid_components = [
1412 |                             comp_name.strip()
1413 |                             for comp_name in components
1414 |                             if isinstance(comp_name, str) and comp_name.strip()
1415 |                         ]
1416 |                         if valid_components:
1417 |                             fields["components"] = [
1418 |                                 {"name": comp_name} for comp_name in valid_components
1419 |                             ]
1420 | 
1421 |                 # Add any remaining custom fields
1422 |                 self._process_additional_fields(fields, issue_data)
1423 | 
1424 |                 if validate_only:
1425 |                     # For validation, just log the issue that would be created
1426 |                     logger.info(
1427 |                         f"Validated issue creation: {project_key} - {summary} ({issue_type})"
1428 |                     )
1429 |                     continue
1430 | 
1431 |                 # Add to bulk creation list
1432 |                 issue_updates.append({"fields": fields})
1433 | 
1434 |             except Exception as e:
1435 |                 logger.error(f"Failed to prepare issue for creation: {str(e)}")
1436 |                 if not issue_updates:
1437 |                     raise
1438 | 
1439 |         if validate_only:
1440 |             return []
1441 | 
1442 |         try:
1443 |             # Call Jira's bulk create endpoint
1444 |             response = self.jira.create_issues(issue_updates)
1445 |             if not isinstance(response, dict):
1446 |                 msg = f"Unexpected return value type from `jira.create_issues`: {type(response)}"
1447 |                 logger.error(msg)
1448 |                 raise TypeError(msg)
1449 | 
1450 |             # Process results
1451 |             created_issues = []
1452 |             for issue_info in response.get("issues", []):
1453 |                 issue_key = issue_info.get("key")
1454 |                 if issue_key:
1455 |                     try:
1456 |                         # Fetch the full issue data
1457 |                         issue_data = self.jira.get_issue(issue_key)
1458 |                         if not isinstance(issue_data, dict):
1459 |                             msg = f"Unexpected return value type from `jira.get_issue`: {type(issue_data)}"
1460 |                             logger.error(msg)
1461 |                             raise TypeError(msg)
1462 | 
1463 |                         created_issues.append(
1464 |                             JiraIssue.from_api_response(
1465 |                                 issue_data,
1466 |                                 base_url=self.config.url
1467 |                                 if hasattr(self, "config")
1468 |                                 else None,
1469 |                             )
1470 |                         )
1471 |                     except Exception as e:
1472 |                         logger.error(
1473 |                             f"Error fetching created issue {issue_key}: {str(e)}"
1474 |                         )
1475 | 
1476 |             # Log any errors from the bulk creation
1477 |             errors = response.get("errors", [])
1478 |             if errors:
1479 |                 for error in errors:
1480 |                     logger.error(f"Bulk creation error: {error}")
1481 | 
1482 |             return created_issues
1483 | 
1484 |         except Exception as e:
1485 |             logger.error(f"Error in bulk issue creation: {str(e)}")
1486 |             raise
1487 | 
1488 |     def batch_get_changelogs(
1489 |         self, issue_ids_or_keys: list[str], fields: list[str] | None = None
1490 |     ) -> list[JiraIssue]:
1491 |         """
1492 |         Get changelogs for multiple issues in a batch. Repeatly fetch data if necessary.
1493 | 
1494 |         Warning:
1495 |             This function is only avaiable on Jira Cloud.
1496 | 
1497 |         Args:
1498 |             issue_ids_or_keys: List of issue IDs or keys
1499 |             fields: Filter the changelogs by fields, e.g. ['status', 'assignee']. Default to None for all fields.
1500 | 
1501 |         Returns:
1502 |             List of JiraIssue objects that only contain changelogs and id
1503 |         """
1504 | 
1505 |         if not self.config.is_cloud:
1506 |             error_msg = "Batch get issue changelogs is only available on Jira Cloud."
1507 |             logger.error(error_msg)
1508 |             raise NotImplementedError(error_msg)
1509 | 
1510 |         # Get paged api results
1511 |         paged_api_results = self.get_paged(
1512 |             method="post",
1513 |             url=self.jira.resource_url("changelog/bulkfetch"),
1514 |             params_or_json={
1515 |                 "fieldIds": fields,
1516 |                 "issueIdsOrKeys": issue_ids_or_keys,
1517 |             },
1518 |         )
1519 | 
1520 |         # Save (issue_id, changelogs)
1521 |         issue_changelog_results: defaultdict[str, list[JiraChangelog]] = defaultdict(
1522 |             list
1523 |         )
1524 | 
1525 |         for api_result in paged_api_results:
1526 |             for data in api_result.get("issueChangeLogs", []):
1527 |                 issue_id = data.get("issueId", "")
1528 |                 changelogs = [
1529 |                     JiraChangelog.from_api_response(changelog_data)
1530 |                     for changelog_data in data.get("changeHistories", [])
1531 |                 ]
1532 | 
1533 |                 issue_changelog_results[issue_id].extend(changelogs)
1534 | 
1535 |         issues = [
1536 |             JiraIssue(id=issue_id, changelogs=changelogs)
1537 |             for issue_id, changelogs in issue_changelog_results.items()
1538 |         ]
1539 | 
1540 |         return issues
1541 | 
```

