This is page 12 of 13. Use http://codebase.md/sooperset/mcp-atlassian?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 '[email protected]', 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., '[email protected]', '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., '[email protected]', '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': '[email protected]', '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 |
```