This is page 2 of 4. Use http://codebase.md/infinitiq-tech/mcp-jira?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .env.example
├── .github
│ ├── copilot-instructions.md
│ └── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ ├── config.yml
│ ├── copilot.yml
│ ├── story.yml
│ └── task.yml
├── .gitignore
├── .lock
├── bin
│ ├── activate
│ ├── activate.csh
│ ├── activate.fish
│ ├── Activate.ps1
│ ├── dotenv
│ ├── httpx
│ ├── jirashell
│ ├── mcp
│ ├── mcp-server-jira
│ ├── normalizer
│ ├── pip
│ ├── pip3
│ ├── pip3.10
│ ├── python
│ ├── python3
│ ├── python3.10
│ └── uvicorn
├── claude_reference
│ ├── jira_advanced.txt
│ ├── jira_api_documentation.txt
│ ├── jira_examples.txt
│ ├── jira_installation.md
│ └── jira_shell.txt
├── Dockerfile
├── docs
│ └── create_issues_v3_conversion.md
├── glama.json
├── jira.py
├── LICENSE
├── mcp
│ ├── __init__.py
│ ├── server
│ │ └── __init__.py
│ └── types
│ └── __init__.py
├── MCPReadme.md
├── pyproject.toml
├── pytest.ini
├── pyvenv.cfg
├── README.md
├── run_server.py
├── src
│ ├── __init__.py
│ ├── mcp
│ │ ├── __init__.py
│ │ ├── server
│ │ │ ├── __init__.py
│ │ │ └── stdio.py
│ │ └── types
│ │ └── __init__.py
│ └── mcp_server_jira
│ ├── __init__.py
│ ├── __main__.py
│ ├── jira_v3_api.py
│ └── server.py
├── tests
│ ├── __init__.py
│ ├── test_add_comment_v3_api_only.py
│ ├── test_bulk_create_issues_v3_api.py
│ ├── test_create_issue_v3_api_only.py
│ ├── test_create_issues_integration.py
│ ├── test_create_jira_issues_server.py
│ ├── test_get_transitions_v3.py
│ ├── test_jira_v3_api.py
│ ├── test_search_issues_v3_api.py
│ ├── test_server.py
│ └── test_transition_issue_v3_api_only.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/src/mcp_server_jira/jira_v3_api.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Jira v3 REST API client module
3 |
4 | This module provides direct HTTP client functionality for Jira's v3 REST API,
5 | offering enhanced functionality and security for operations that require the latest API features.
6 | """
7 |
8 | import json
9 | import logging
10 | from typing import Any, Dict, Optional
11 |
12 | import httpx
13 |
14 | logger = logging.getLogger("JiraMCPLogger") # Get the same logger instance
15 |
16 |
17 | class JiraV3APIClient:
18 | """Client for making direct requests to Jira's v3 REST API"""
19 |
20 | def __init__(
21 | self,
22 | server_url: str,
23 | username: Optional[str] = None,
24 | password: Optional[str] = None,
25 | token: Optional[str] = None,
26 | ):
27 | """Initialize the v3 API client
28 |
29 | Args:
30 | server_url: Jira server URL
31 | username: Username for authentication
32 | password: Password for basic auth
33 | token: API token for auth
34 | """
35 | self.server_url = server_url.rstrip("/")
36 | self.username = username
37 | self.auth_token = token or password
38 |
39 | if not self.username or not self.auth_token:
40 | raise ValueError(
41 | "Jira username and an API token (or password) are required for v3 API."
42 | )
43 |
44 | self.client = httpx.AsyncClient(
45 | auth=(self.username, self.auth_token),
46 | headers={"Accept": "application/json", "Content-Type": "application/json"},
47 | timeout=30.0,
48 | follow_redirects=True,
49 | )
50 |
51 | async def _make_v3_api_request(
52 | self,
53 | method: str,
54 | endpoint: str,
55 | data: Optional[Dict[str, Any]] = None,
56 | params: Optional[Dict[str, Any]] = None,
57 | ) -> Dict[str, Any]:
58 | """
59 | Sends an authenticated async HTTP request to a Jira v3 REST API endpoint.
60 | """
61 | url = f"{self.server_url}/rest/api/3{endpoint}"
62 |
63 | logger.debug(f"Attempting to make request: {method} {url}")
64 | logger.debug(f"Request params: {params}")
65 | logger.debug(f"Request JSON data: {data}")
66 |
67 | try:
68 | logger.info(f"AWAITING httpx.client.request for {method} {url}")
69 | response = await self.client.request(
70 | method=method.upper(),
71 | url=url,
72 | json=data,
73 | params=params,
74 | )
75 | logger.info(
76 | f"COMPLETED httpx.client.request for {url}. Status: {response.status_code}"
77 | )
78 | logger.debug(f"Raw response text (first 500 chars): {str(response.text)[:500]}")
79 |
80 | response.raise_for_status()
81 |
82 | if response.status_code == 204:
83 | return {}
84 |
85 | return response.json()
86 |
87 | except httpx.HTTPStatusError as e:
88 | logger.error(
89 | f"HTTP Status Error for {e.request.url!r}: {e.response.status_code}",
90 | exc_info=True,
91 | )
92 | error_details = f"Jira API returned an error: {e.response.status_code} {e.response.reason_phrase}."
93 | raise ValueError(error_details)
94 |
95 | except httpx.RequestError as e:
96 | logger.error(f"Request Error for {e.request.url!r}", exc_info=True)
97 | raise ValueError(f"A network error occurred while connecting to Jira: {e}")
98 | except Exception as e:
99 | logger.critical(
100 | "An unexpected error occurred in _make_v3_api_request", exc_info=True
101 | )
102 | raise
103 |
104 | async def create_project(
105 | self,
106 | key: str,
107 | assignee: str,
108 | name: Optional[str] = None,
109 | ptype: str = None,
110 | template_name: Optional[str] = None,
111 | avatarId: Optional[int] = None,
112 | issueSecurityScheme: Optional[int] = None,
113 | permissionScheme: Optional[int] = None,
114 | projectCategory: Optional[int] = None,
115 | notificationScheme: Optional[int] = None,
116 | categoryId: Optional[int] = None,
117 | url: str = None,
118 | ) -> Dict[str, Any]:
119 | """
120 | Creates a new Jira project using the v3 REST API.
121 |
122 | Requires a project key and the Atlassian accountId of the project lead (`assignee`). The v3 API mandates that `leadAccountId` is always provided, regardless of default project lead settings or UI behavior. Additional project attributes such as name, type, template, avatar, schemes, category, and documentation URL can be specified.
123 |
124 | Args:
125 | key: The unique project key (required).
126 | name: The project name. Defaults to the key if not provided.
127 | assignee: Atlassian accountId of the project lead (required by v3 API).
128 | ptype: Project type key (e.g., 'software', 'business', 'service_desk').
129 | template_name: Project template key for template-based creation.
130 | avatarId: ID of the avatar to assign to the project.
131 | issueSecurityScheme: ID of the issue security scheme.
132 | permissionScheme: ID of the permission scheme.
133 | projectCategory: ID of the project category.
134 | notificationScheme: ID of the notification scheme.
135 | categoryId: Alternative to projectCategory; preferred for v3 API.
136 | url: URL for project information or documentation.
137 |
138 | Returns:
139 | A dictionary containing details of the created project as returned by Jira.
140 |
141 | Raises:
142 | ValueError: If required parameters are missing or project creation fails.
143 | """
144 | if not key:
145 | raise ValueError("Project key is required")
146 |
147 | if not assignee:
148 | raise ValueError(
149 | "Parameter 'assignee' (leadAccountId) is required by the Jira v3 API"
150 | )
151 |
152 | payload = {
153 | "key": key,
154 | "name": name or key,
155 | "leadAccountId": assignee,
156 | "assigneeType": "PROJECT_LEAD",
157 | "projectTypeKey": ptype,
158 | "projectTemplateKey": template_name,
159 | "avatarId": avatarId,
160 | "issueSecurityScheme": issueSecurityScheme,
161 | "permissionScheme": permissionScheme,
162 | "notificationScheme": notificationScheme,
163 | "categoryId": categoryId or projectCategory,
164 | "url": url,
165 | }
166 |
167 | payload = {k: v for k, v in payload.items() if v is not None}
168 |
169 | print(f"Creating project with v3 API payload: {json.dumps(payload, indent=2)}")
170 | response_data = await self._make_v3_api_request(
171 | "POST", "/project", data=payload
172 | )
173 | print(f"Project creation response: {json.dumps(response_data, indent=2)}")
174 | return response_data
175 |
176 | async def get_projects(
177 | self,
178 | start_at: int = 0,
179 | max_results: int = 50,
180 | order_by: Optional[str] = None,
181 | ids: Optional[list] = None,
182 | keys: Optional[list] = None,
183 | query: Optional[str] = None,
184 | type_key: Optional[str] = None,
185 | category_id: Optional[int] = None,
186 | action: Optional[str] = None,
187 | expand: Optional[str] = None,
188 | ) -> Dict[str, Any]:
189 | """
190 | Get projects paginated using the v3 REST API.
191 |
192 | Returns a paginated list of projects visible to the user using the
193 | /rest/api/3/project/search endpoint.
194 |
195 | Args:
196 | start_at: The index of the first item to return (default: 0)
197 | max_results: The maximum number of items to return per page (default: 50)
198 | order_by: Order the results by a field:
199 | - category: Order by project category
200 | - issueCount: Order by total number of issues
201 | - key: Order by project key
202 | - lastIssueUpdatedDate: Order by last issue update date
203 | - name: Order by project name
204 | - owner: Order by project lead
205 | - archivedDate: Order by archived date
206 | - deletedDate: Order by deleted date
207 | ids: List of project IDs to return
208 | keys: List of project keys to return
209 | query: Filter projects by query string
210 | type_key: Filter projects by type key
211 | category_id: Filter projects by category ID
212 | action: Filter by action permission (view, browse, edit)
213 | expand: Expand additional project fields in response
214 |
215 | Returns:
216 | Dictionary containing the paginated response with projects and pagination info
217 |
218 | Raises:
219 | ValueError: If the API request fails
220 | """
221 | params = {
222 | "startAt": start_at,
223 | "maxResults": max_results,
224 | "orderBy": order_by,
225 | "id": ids,
226 | "keys": keys,
227 | "query": query,
228 | "typeKey": type_key,
229 | "categoryId": category_id,
230 | "action": action,
231 | "expand": expand,
232 | }
233 |
234 | params = {k: v for k, v in params.items() if v is not None}
235 |
236 | endpoint = "/project/search"
237 | print(
238 | f"Fetching projects with v3 API endpoint: {endpoint} with params: {params}"
239 | )
240 | response_data = await self._make_v3_api_request("GET", endpoint, params=params)
241 | print(f"Projects API response: {json.dumps(response_data, indent=2)}")
242 | return response_data
243 |
244 | async def get_transitions(
245 | self,
246 | issue_id_or_key: str,
247 | expand: Optional[str] = None,
248 | transition_id: Optional[str] = None,
249 | skip_remote_only_condition: Optional[bool] = None,
250 | include_unavailable_transitions: Optional[bool] = None,
251 | sort_by_ops_bar_and_status: Optional[bool] = None,
252 | ) -> Dict[str, Any]:
253 | """
254 | Get available transitions for an issue using the v3 REST API.
255 |
256 | Returns either all transitions or a transition that can be performed by the user
257 | on an issue, based on the issue's status.
258 |
259 | Args:
260 | issue_id_or_key: Issue ID or key (required)
261 | expand: Expand additional transition fields in response
262 | transition_id: Get only the transition matching this ID
263 | skip_remote_only_condition: Skip remote-only conditions check
264 | include_unavailable_transitions: Include transitions that can't be performed
265 | sort_by_ops_bar_and_status: Sort transitions by operations bar and status
266 |
267 | Returns:
268 | Dictionary containing the transitions response with transition details
269 |
270 | Raises:
271 | ValueError: If the API request fails
272 | """
273 | if not issue_id_or_key:
274 | raise ValueError("issue_id_or_key is required")
275 |
276 | params = {
277 | "expand": expand,
278 | "transitionId": transition_id,
279 | "skipRemoteOnlyCondition": skip_remote_only_condition,
280 | "includeUnavailableTransitions": include_unavailable_transitions,
281 | "sortByOpsBarAndStatus": sort_by_ops_bar_and_status,
282 | }
283 |
284 | params = {k: v for k, v in params.items() if v is not None}
285 |
286 | endpoint = f"/issue/{issue_id_or_key}/transitions"
287 | logger.debug(
288 | f"Fetching transitions with v3 API endpoint: {endpoint} with params: {params}"
289 | )
290 | response_data = await self._make_v3_api_request("GET", endpoint, params=params)
291 | logger.debug(f"Transitions API response: {json.dumps(response_data, indent=2)}")
292 | return response_data
293 |
294 | async def transition_issue(
295 | self,
296 | issue_id_or_key: str,
297 | transition_id: str,
298 | fields: Optional[Dict[str, Any]] = None,
299 | comment: Optional[str] = None,
300 | history_metadata: Optional[Dict[str, Any]] = None,
301 | properties: Optional[list] = None,
302 | ) -> Dict[str, Any]:
303 | """
304 | Transition an issue using the v3 REST API.
305 |
306 | Performs an issue transition and, if the transition has a screen,
307 | updates the fields from the transition screen.
308 |
309 | Args:
310 | issue_id_or_key: Issue ID or key (required)
311 | transition_id: ID of the transition to perform (required)
312 | fields: Dict containing field names and values to update during transition
313 | comment: Simple string comment to add during transition
314 | history_metadata: Optional history metadata for the transition
315 | properties: Optional list of properties to set
316 |
317 | Returns:
318 | Empty dictionary on success (204 No Content response)
319 |
320 | Raises:
321 | ValueError: If required parameters are missing or transition fails
322 | """
323 | if not issue_id_or_key:
324 | raise ValueError("issue_id_or_key is required")
325 |
326 | if not transition_id:
327 | raise ValueError("transition_id is required")
328 |
329 | # Build the request payload
330 | payload = {"transition": {"id": transition_id}}
331 |
332 | # Add fields if provided
333 | if fields:
334 | payload["fields"] = fields
335 |
336 | # Add comment if provided - convert simple string to ADF format
337 | if comment:
338 | payload["update"] = {
339 | "comment": [
340 | {
341 | "add": {
342 | "body": {
343 | "type": "doc",
344 | "version": 1,
345 | "content": [
346 | {
347 | "type": "paragraph",
348 | "content": [{"type": "text", "text": comment}],
349 | }
350 | ],
351 | }
352 | }
353 | }
354 | ]
355 | }
356 |
357 | # Add optional metadata
358 | if history_metadata:
359 | payload["historyMetadata"] = history_metadata
360 |
361 | if properties:
362 | payload["properties"] = properties
363 |
364 | endpoint = f"/issue/{issue_id_or_key}/transitions"
365 | logger.debug(f"Transitioning issue with v3 API endpoint: {endpoint}")
366 | logger.debug(f"Transition payload: {json.dumps(payload, indent=2)}")
367 |
368 | response_data = await self._make_v3_api_request("POST", endpoint, data=payload)
369 | logger.debug(f"Transition response: {response_data}")
370 | return response_data
371 |
372 | async def get_issue_types(self) -> Dict[str, Any]:
373 | """
374 | Get all issue types for user using the v3 REST API.
375 |
376 | Returns all issue types. This operation can be accessed anonymously.
377 |
378 | Permissions required: Issue types are only returned as follows:
379 | - if the user has the Administer Jira global permission, all issue types are returned.
380 | - if the user has the Browse projects project permission for one or more projects,
381 | the issue types associated with the projects the user has permission to browse are returned.
382 | - if the user is anonymous then they will be able to access projects with the Browse projects for anonymous users
383 | - if the user authentication is incorrect they will fall back to anonymous
384 |
385 | Returns:
386 | List of issue type dictionaries with fields like:
387 | - avatarId: Avatar ID for the issue type
388 | - description: Description of the issue type
389 | - hierarchyLevel: Hierarchy level
390 | - iconUrl: URL of the issue type icon
391 | - id: Issue type ID
392 | - name: Issue type name
393 | - self: REST API URL for the issue type
394 | - subtask: Whether this is a subtask type
395 |
396 | Raises:
397 | ValueError: If the API request fails
398 | """
399 | endpoint = "/issuetype"
400 | logger.debug(f"Fetching issue types with v3 API endpoint: {endpoint}")
401 | response_data = await self._make_v3_api_request("GET", endpoint)
402 | logger.debug(f"Issue types API response: {json.dumps(response_data, indent=2)}")
403 | return response_data
404 |
405 | async def add_comment(
406 | self,
407 | issue_id_or_key: str,
408 | comment: str,
409 | visibility: Optional[Dict[str, str]] = None,
410 | properties: Optional[list] = None,
411 | ) -> Dict[str, Any]:
412 | """
413 | Add a comment to an issue using the v3 REST API.
414 |
415 | Args:
416 | issue_id_or_key: Issue ID or key (required)
417 | comment: Comment text to add (required)
418 | visibility: Optional visibility settings (e.g., {"type": "role", "value": "Administrators"})
419 | properties: Optional list of properties to set
420 |
421 | Returns:
422 | Dict containing comment details:
423 | - id: Comment ID
424 | - body: Comment body in ADF format
425 | - author: Author information
426 | - created: Creation timestamp
427 | - updated: Last update timestamp
428 | - etc.
429 |
430 | Raises:
431 | ValueError: If required parameters are missing or comment creation fails
432 | """
433 | if not issue_id_or_key:
434 | raise ValueError("issue_id_or_key is required")
435 |
436 | if not comment:
437 | raise ValueError("comment is required")
438 |
439 | # Build the request payload with ADF format
440 | payload = {
441 | "body": {
442 | "type": "doc",
443 | "version": 1,
444 | "content": [
445 | {
446 | "type": "paragraph",
447 | "content": [{"type": "text", "text": comment}],
448 | }
449 | ],
450 | }
451 | }
452 |
453 | # Add optional visibility
454 | if visibility:
455 | payload["visibility"] = visibility
456 |
457 | # Add optional properties
458 | if properties:
459 | payload["properties"] = properties
460 |
461 | endpoint = f"/issue/{issue_id_or_key}/comment"
462 | logger.debug(f"Adding comment to issue {issue_id_or_key} with v3 API endpoint: {endpoint}")
463 | response_data = await self._make_v3_api_request("POST", endpoint, data=payload)
464 | logger.debug(f"Add comment API response: {json.dumps(response_data, indent=2)}")
465 | return response_data
466 |
467 | async def create_issue(
468 | self,
469 | fields: Dict[str, Any],
470 | update: Optional[Dict[str, Any]] = None,
471 | history_metadata: Optional[Dict[str, Any]] = None,
472 | properties: Optional[list] = None,
473 | transition: Optional[Dict[str, Any]] = None,
474 | ) -> Dict[str, Any]:
475 | """
476 | Create an issue using the v3 REST API.
477 |
478 | Creates an issue or, where the option to create subtasks is enabled in Jira, a subtask.
479 | A transition may be applied, to move the issue or subtask to a workflow step other than
480 | the default start step, and issue properties set.
481 |
482 | Args:
483 | fields: Dict containing field names and values (required).
484 | Must include project, summary, description, and issuetype.
485 | update: Dict containing update operations for fields
486 | history_metadata: Optional history metadata for the issue creation
487 | properties: Optional list of properties to set
488 | transition: Optional transition to apply after creation
489 |
490 | Returns:
491 | Dictionary containing the created issue details:
492 | - id: Issue ID
493 | - key: Issue key
494 | - self: URL to the created issue
495 | - transition: Transition result if applied
496 |
497 | Raises:
498 | ValueError: If required parameters are missing or creation fails
499 | """
500 | if not fields:
501 | raise ValueError("fields is required")
502 |
503 | # Build the request payload
504 | payload = {"fields": fields}
505 |
506 | # Add optional parameters
507 | if update:
508 | payload["update"] = update
509 |
510 | if history_metadata:
511 | payload["historyMetadata"] = history_metadata
512 |
513 | if properties:
514 | payload["properties"] = properties
515 |
516 | if transition:
517 | payload["transition"] = transition
518 |
519 | endpoint = "/issue"
520 | logger.debug(f"Creating issue with v3 API endpoint: {endpoint}")
521 | logger.debug(f"Create issue payload: {json.dumps(payload, indent=2)}")
522 |
523 | response_data = await self._make_v3_api_request("POST", endpoint, data=payload)
524 | logger.debug(f"Create issue response: {response_data}")
525 | return response_data
526 |
527 | async def search_issues(
528 | self,
529 | jql: str,
530 | start_at: int = 0,
531 | max_results: int = 50,
532 | fields: Optional[str] = None,
533 | expand: Optional[str] = None,
534 | properties: Optional[list] = None,
535 | fields_by_keys: Optional[bool] = None,
536 | fail_fast: Optional[bool] = None,
537 | reconcile_issues: Optional[list] = None,
538 | ) -> Dict[str, Any]:
539 | """
540 | Search for issues using JQL enhanced search (GET) via v3 REST API.
541 |
542 | Searches for issues using JQL. Recent updates might not be immediately visible
543 | in the returned search results. If you need read-after-write consistency,
544 | you can utilize the reconcileIssues parameter to ensure stronger consistency assurances.
545 | This operation can be accessed anonymously.
546 |
547 | Args:
548 | jql: JQL query string
549 | start_at: Index of the first issue to return (default: 0)
550 | max_results: Maximum number of results to return (default: 50)
551 | fields: Comma-separated list of fields to include in response
552 | expand: Use expand to include additional information about issues
553 | properties: List of issue properties to include in response
554 | fields_by_keys: Reference fields by their key (rather than ID)
555 | fail_fast: Fail fast when JQL query validation fails
556 | reconcile_issues: List of issue IDs to reconcile for read-after-write consistency
557 |
558 | Returns:
559 | Dictionary containing search results with:
560 | - issues: List of issue dictionaries
561 | - isLast: Boolean indicating if this is the last page
562 | - startAt: Starting index of results
563 | - maxResults: Maximum results per page
564 | - total: Total number of issues matching the query
565 |
566 | Raises:
567 | ValueError: If the API request fails or JQL is invalid
568 | """
569 | if not jql:
570 | raise ValueError("jql parameter is required")
571 |
572 | # Build query parameters
573 | params = {
574 | "jql": jql,
575 | "startAt": start_at,
576 | "maxResults": max_results,
577 | }
578 |
579 | # Add optional parameters if provided
580 | params["fields"] = fields if fields is not None else "*all"
581 | if expand:
582 | params["expand"] = expand
583 | if properties:
584 | params["properties"] = properties
585 | if fields_by_keys is not None:
586 | params["fieldsByKeys"] = fields_by_keys
587 | if fail_fast is not None:
588 | params["failFast"] = fail_fast
589 | if reconcile_issues:
590 | params["reconcileIssues"] = reconcile_issues
591 |
592 | # Remove None values
593 | params = {k: v for k, v in params.items() if v is not None}
594 |
595 | endpoint = "/search/jql"
596 | logger.debug(f"Searching issues with v3 API endpoint: {endpoint}")
597 | logger.debug(f"Search params: {params}")
598 |
599 | response_data = await self._make_v3_api_request("GET", endpoint, params=params)
600 | logger.debug(f"Search issues API response: {json.dumps(response_data, indent=2)}")
601 | return response_data
602 |
603 | async def bulk_create_issues(
604 | self,
605 | issue_updates: list
606 | ) -> Dict[str, Any]:
607 | """
608 | Bulk create issues using the v3 REST API.
609 |
610 | Creates up to 50 issues and, where the option to create subtasks is enabled in Jira,
611 | subtasks. Transitions may be applied, to move the issues or subtasks to a workflow
612 | step other than the default start step, and issue properties set.
613 |
614 | Args:
615 | issue_updates: List of issue creation specifications. Each item should contain
616 | 'fields' dict with issue fields, and optionally 'update' dict
617 | for additional operations during creation.
618 |
619 | Returns:
620 | Dict containing:
621 | - issues: List of successfully created issues with their details
622 | - errors: List of errors for failed issue creations
623 |
624 | Raises:
625 | ValueError: If required parameters are missing or bulk creation fails
626 | """
627 | if not issue_updates:
628 | raise ValueError("issue_updates list cannot be empty")
629 |
630 | if len(issue_updates) > 50:
631 | raise ValueError("Cannot create more than 50 issues in a single bulk operation")
632 |
633 | # Build the request payload for v3 API
634 | payload = {"issueUpdates": issue_updates}
635 |
636 | endpoint = "/issue/bulk"
637 | logger.debug(f"Bulk creating issues with v3 API endpoint: {endpoint}")
638 | logger.debug(f"Payload: {json.dumps(payload, indent=2)}")
639 |
640 | response_data = await self._make_v3_api_request("POST", endpoint, data=payload)
641 | logger.debug(f"Bulk create response: {json.dumps(response_data, indent=2)}")
642 |
643 | return response_data
644 |
```
--------------------------------------------------------------------------------
/src/mcp_server_jira/server.py:
--------------------------------------------------------------------------------
```python
1 | import asyncio
2 | import json
3 | import logging
4 | import os
5 | import sys
6 | from enum import Enum
7 | from pathlib import Path
8 | from typing import Any, Dict, List, Optional, Sequence, Union
9 |
10 | # --- Setup a dedicated file logger ---
11 | log_file_path = Path(__file__).parent / "jira_mcp_debug.log"
12 | logger = logging.getLogger("JiraMCPLogger")
13 | logger.setLevel(logging.DEBUG) # Capture all levels of logs
14 |
15 | # Create a file handler to write logs to a file
16 | # Use 'w' to overwrite the file on each run, ensuring a clean log
17 | handler = logging.FileHandler(log_file_path, mode="w")
18 | handler.setLevel(logging.DEBUG)
19 |
20 | # Create a formatter to make the logs readable
21 | formatter = logging.Formatter(
22 | "%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s"
23 | )
24 | handler.setFormatter(formatter)
25 |
26 | # Add the handler to the logger
27 | if not logger.handlers:
28 | logger.addHandler(handler)
29 |
30 | logger.info("Logger initialized. All subsequent logs will go to jira_mcp_debug.log")
31 | # --- End of logger setup ---
32 |
33 | try:
34 | from jira import JIRA
35 | except ImportError:
36 | from .jira import JIRA
37 |
38 | from pydantic import BaseModel
39 |
40 | from mcp.server import Server
41 | from mcp.server.stdio import stdio_server
42 | from mcp.types import EmbeddedResource, ImageContent, TextContent, Tool
43 |
44 | from .jira_v3_api import JiraV3APIClient
45 |
46 | try:
47 | from dotenv import load_dotenv
48 |
49 | # Try to load from .env file if it exists
50 | env_path = Path(__file__).parent.parent.parent.parent / ".env"
51 | if env_path.exists():
52 | load_dotenv(dotenv_path=env_path)
53 | except ImportError:
54 | # dotenv is optional
55 | pass
56 |
57 |
58 | class JiraTools(str, Enum):
59 | GET_PROJECTS = "get_jira_projects"
60 | GET_ISSUE = "get_jira_issue"
61 | SEARCH_ISSUES = "search_jira_issues"
62 | CREATE_ISSUE = "create_jira_issue"
63 | CREATE_ISSUES = "create_jira_issues"
64 | ADD_COMMENT = "add_jira_comment"
65 | GET_TRANSITIONS = "get_jira_transitions"
66 | TRANSITION_ISSUE = "transition_jira_issue"
67 | CREATE_PROJECT = "create_jira_project"
68 | GET_PROJECT_ISSUE_TYPES = "get_jira_project_issue_types"
69 |
70 |
71 | class JiraIssueField(BaseModel):
72 | name: str
73 | value: str
74 |
75 |
76 | class JiraIssueResult(BaseModel):
77 | key: str
78 | summary: str
79 | description: Optional[str] = None
80 | status: Optional[str] = None
81 | assignee: Optional[str] = None
82 | reporter: Optional[str] = None
83 | created: Optional[str] = None
84 | updated: Optional[str] = None
85 | fields: Optional[Dict[str, Any]] = None
86 | comments: Optional[List[Dict[str, Any]]] = None
87 | watchers: Optional[Dict[str, Any]] = None
88 | attachments: Optional[List[Dict[str, Any]]] = None
89 | subtasks: Optional[List[Dict[str, Any]]] = None
90 | project: Optional[Dict[str, Any]] = None
91 | issue_links: Optional[List[Dict[str, Any]]] = None
92 | worklog: Optional[List[Dict[str, Any]]] = None
93 | timetracking: Optional[Dict[str, Any]] = None
94 |
95 |
96 | class JiraProjectResult(BaseModel):
97 | key: str
98 | name: str
99 | id: str
100 | lead: Optional[str] = None
101 |
102 |
103 | class JiraTransitionResult(BaseModel):
104 | id: str
105 | name: str
106 |
107 |
108 | class JiraServer:
109 | def __init__(
110 | self,
111 | server_url: str = None,
112 | auth_method: str = None,
113 | username: str = None,
114 | password: str = None,
115 | token: str = None,
116 | ):
117 | self.server_url = server_url
118 | self.auth_method = auth_method
119 | self.username = username
120 | self.password = password
121 | self.token = token
122 |
123 | self._v3_api_client = JiraV3APIClient(
124 | server_url=self.server_url,
125 | username=self.username,
126 | token=self.token,
127 | password=password,
128 | )
129 | self.client = None
130 |
131 | def connect(self):
132 | """Connect to Jira server using provided authentication details"""
133 | if not self.server_url:
134 | print("Error: Jira server URL not provided")
135 | return False
136 |
137 | error_messages = []
138 |
139 | # Try multiple auth methods if possible
140 | try:
141 | # First, try the specified auth method
142 | if self.auth_method == "basic_auth":
143 | # Basic auth - either username/password or username/token
144 | if self.username and self.password:
145 | try:
146 | print(f"Trying basic_auth with username and password")
147 | self.client = JIRA(
148 | server=self.server_url,
149 | basic_auth=(self.username, self.password),
150 | )
151 | print("Connection successful with username/password")
152 | return True
153 | except Exception as e:
154 | error_msg = f"Failed basic_auth with username/password: {type(e).__name__}: {str(e)}"
155 | print(error_msg)
156 | error_messages.append(error_msg)
157 |
158 | if self.username and self.token:
159 | try:
160 | print(f"Trying basic_auth with username and API token")
161 | self.client = JIRA(
162 | server=self.server_url,
163 | basic_auth=(self.username, self.token),
164 | )
165 | print("Connection successful with username/token")
166 | return True
167 | except Exception as e:
168 | error_msg = f"Failed basic_auth with username/token: {type(e).__name__}: {str(e)}"
169 | print(error_msg)
170 | error_messages.append(error_msg)
171 |
172 | print("Error: Username and password/token required for basic auth")
173 | error_messages.append(
174 | "Username and password/token required for basic auth"
175 | )
176 |
177 | elif self.auth_method == "token_auth":
178 | # Token auth - just need the token
179 | if self.token:
180 | try:
181 | print(f"Trying token_auth with token")
182 | self.client = JIRA(
183 | server=self.server_url, token_auth=self.token
184 | )
185 | print("Connection successful with token_auth")
186 | return True
187 | except Exception as e:
188 | error_msg = f"Failed token_auth: {type(e).__name__}: {str(e)}"
189 | print(error_msg)
190 | error_messages.append(error_msg)
191 | else:
192 | print("Error: Token required for token auth")
193 | error_messages.append("Token required for token auth")
194 |
195 | # If we're here and have a token, try using it with basic_auth for Jira Cloud
196 | # (even if auth_method wasn't basic_auth)
197 | if self.token and self.username and not self.client:
198 | try:
199 | print(f"Trying fallback to basic_auth with username and token")
200 | self.client = JIRA(
201 | server=self.server_url, basic_auth=(self.username, self.token)
202 | )
203 | print("Connection successful with fallback basic_auth")
204 | return True
205 | except Exception as e:
206 | error_msg = (
207 | f"Failed fallback to basic_auth: {type(e).__name__}: {str(e)}"
208 | )
209 | print(error_msg)
210 | error_messages.append(error_msg)
211 |
212 | # If we're here and have a token, try using token_auth as a fallback
213 | # (even if auth_method wasn't token_auth)
214 | if self.token and not self.client:
215 | try:
216 | print(f"Trying fallback to token_auth")
217 | self.client = JIRA(server=self.server_url, token_auth=self.token)
218 | print("Connection successful with fallback token_auth")
219 | return True
220 | except Exception as e:
221 | error_msg = (
222 | f"Failed fallback to token_auth: {type(e).__name__}: {str(e)}"
223 | )
224 | print(error_msg)
225 | error_messages.append(error_msg)
226 |
227 | # Last resort: try anonymous access
228 | try:
229 | print(f"Trying anonymous access as last resort")
230 | self.client = JIRA(server=self.server_url)
231 | print("Connection successful with anonymous access")
232 | return True
233 | except Exception as e:
234 | error_msg = f"Failed anonymous access: {type(e).__name__}: {str(e)}"
235 | print(error_msg)
236 | error_messages.append(error_msg)
237 |
238 | # If we got here, all connection attempts failed
239 | print(f"All connection attempts failed: {', '.join(error_messages)}")
240 | return False
241 |
242 | except Exception as e:
243 | error_msg = f"Unexpected error in connect(): {type(e).__name__}: {str(e)}"
244 | print(error_msg)
245 | error_messages.append(error_msg)
246 | return False
247 |
248 | def _get_v3_api_client(self) -> JiraV3APIClient:
249 | """Get or create a v3 API client instance"""
250 | if not self._v3_api_client:
251 | self._v3_api_client = JiraV3APIClient(
252 | server_url=self.server_url,
253 | username=self.username,
254 | password=self.password,
255 | token=self.token,
256 | )
257 | return self._v3_api_client
258 |
259 | async def get_jira_projects(self) -> List[JiraProjectResult]:
260 | """Get all accessible Jira projects using v3 REST API"""
261 | logger.info("Starting get_jira_projects...")
262 | all_projects_data = []
263 | start_at = 0
264 | max_results = 50
265 | page_count = 0
266 |
267 | while True:
268 | page_count += 1
269 | logger.info(
270 | f"Pagination loop, page {page_count}: startAt={start_at}, maxResults={max_results}"
271 | )
272 |
273 | try:
274 | response = await self._v3_api_client.get_projects(
275 | start_at=start_at, max_results=max_results
276 | )
277 |
278 | projects = response.get("values", [])
279 | if not projects:
280 | logger.info("No more projects returned. Breaking pagination loop.")
281 | break
282 |
283 | all_projects_data.extend(projects)
284 |
285 | if response.get("isLast", False):
286 | logger.info("'isLast' is True. Breaking pagination loop.")
287 | break
288 |
289 | start_at += len(projects)
290 |
291 | # Yield control to the event loop to prevent deadlocks in the MCP framework.
292 | await asyncio.sleep(0)
293 |
294 | except Exception as e:
295 | logger.error(
296 | "Error inside get_jira_projects pagination loop", exc_info=True
297 | )
298 | raise
299 |
300 | logger.info(
301 | f"Finished get_jira_projects. Total projects found: {len(all_projects_data)}"
302 | )
303 |
304 | results = []
305 | for p in all_projects_data:
306 | results.append(
307 | JiraProjectResult(
308 | key=p.get("key"),
309 | name=p.get("name"),
310 | id=str(p.get("id")),
311 | lead=(p.get("lead") or {}).get("displayName"),
312 | )
313 | )
314 | logger.info(f"Added project {p.get('key')} to results")
315 | logger.info(f"Returning {len(results)} projects")
316 | sys.stdout.flush() # Flush stdout to ensure it's sent to MCP, otherwise hang occurs
317 | return results
318 |
319 | def get_jira_issue(self, issue_key: str) -> JiraIssueResult:
320 | """Get details for a specific issue by key"""
321 | if not self.client:
322 | if not self.connect():
323 | # Connection failed - provide clear error message
324 | raise ValueError(
325 | f"Failed to connect to Jira server at {self.server_url}. Check your authentication credentials."
326 | )
327 |
328 | try:
329 | issue = self.client.issue(issue_key)
330 |
331 | # Extract comments if available
332 | comments = []
333 | if hasattr(issue.fields, "comment") and hasattr(
334 | issue.fields.comment, "comments"
335 | ):
336 | for comment in issue.fields.comment.comments:
337 | comments.append(
338 | {
339 | "author": (
340 | getattr(
341 | comment.author, "displayName", str(comment.author)
342 | )
343 | if hasattr(comment, "author")
344 | else "Unknown"
345 | ),
346 | "body": comment.body,
347 | "created": comment.created,
348 | }
349 | )
350 |
351 | # Create a fields dictionary with custom fields
352 | fields = {}
353 | for field_name in dir(issue.fields):
354 | if not field_name.startswith("_") and field_name not in [
355 | "comment",
356 | "attachment",
357 | "summary",
358 | "description",
359 | "status",
360 | "assignee",
361 | "reporter",
362 | "created",
363 | "updated",
364 | ]:
365 | value = getattr(issue.fields, field_name)
366 | if value is not None:
367 | # Handle special field types
368 | if hasattr(value, "name"):
369 | fields[field_name] = value.name
370 | elif hasattr(value, "value"):
371 | fields[field_name] = value.value
372 | elif isinstance(value, list):
373 | if len(value) > 0:
374 | if hasattr(value[0], "name"):
375 | fields[field_name] = [item.name for item in value]
376 | else:
377 | fields[field_name] = value
378 | else:
379 | fields[field_name] = str(value)
380 |
381 | return JiraIssueResult(
382 | key=issue.key,
383 | summary=issue.fields.summary,
384 | description=issue.fields.description,
385 | status=(
386 | issue.fields.status.name
387 | if hasattr(issue.fields, "status")
388 | else None
389 | ),
390 | assignee=(
391 | issue.fields.assignee.displayName
392 | if hasattr(issue.fields, "assignee") and issue.fields.assignee
393 | else None
394 | ),
395 | reporter=(
396 | issue.fields.reporter.displayName
397 | if hasattr(issue.fields, "reporter") and issue.fields.reporter
398 | else None
399 | ),
400 | created=(
401 | issue.fields.created if hasattr(issue.fields, "created") else None
402 | ),
403 | updated=(
404 | issue.fields.updated if hasattr(issue.fields, "updated") else None
405 | ),
406 | fields=fields,
407 | comments=comments,
408 | )
409 | except Exception as e:
410 | print(f"Failed to get issue {issue_key}: {type(e).__name__}: {str(e)}")
411 | raise ValueError(
412 | f"Failed to get issue {issue_key}: {type(e).__name__}: {str(e)}"
413 | )
414 |
415 | async def search_jira_issues(
416 | self, jql: str, max_results: int = 10
417 | ) -> List[JiraIssueResult]:
418 | """Search for issues using JQL via v3 REST API with pagination support"""
419 | logger.info("Starting search_jira_issues...")
420 |
421 | try:
422 | # Use v3 API client
423 | v3_client = self._get_v3_api_client()
424 |
425 | # Collect all issues from all pages
426 | all_issues = []
427 | start_at = 0
428 | page_size = min(max_results, 100) # Jira typically limits to 100 per page
429 |
430 | while True:
431 | logger.debug(f"Fetching page starting at {start_at} with page size {page_size}")
432 | response_data = await v3_client.search_issues(
433 | jql=jql,
434 | start_at=start_at,
435 | max_results=page_size
436 | )
437 |
438 | # Extract issues from current page
439 | page_issues = response_data.get("issues", [])
440 | all_issues.extend(page_issues)
441 |
442 | logger.debug(f"Retrieved {len(page_issues)} issues from current page. Total so far: {len(all_issues)}")
443 |
444 | # Check if we've reached the user's max_results limit
445 | if len(all_issues) >= max_results:
446 | # Trim to exact max_results if we exceeded it
447 | all_issues = all_issues[:max_results]
448 | logger.debug(f"Reached max_results limit of {max_results}, stopping pagination")
449 | break
450 |
451 | # Check if this is the last page according to API
452 | is_last = response_data.get("isLast", True)
453 | if is_last:
454 | logger.debug("API indicates this is the last page, stopping pagination")
455 | break
456 |
457 | # If we have more pages, prepare for next iteration
458 | start_at = len(all_issues) # Use actual number of issues retrieved so far
459 |
460 | # Adjust page size for next request to not exceed max_results
461 | remaining_needed = max_results - len(all_issues)
462 | page_size = min(remaining_needed, 100)
463 |
464 | # Return raw issues list for full JSON data
465 | logger.info(f"Returning raw issues ({len(all_issues)}) for JQL: {jql}")
466 | return all_issues
467 |
468 |
469 | except Exception as e:
470 | error_msg = f"Failed to search issues: {type(e).__name__}: {str(e)}"
471 | logger.error(error_msg, exc_info=True)
472 | print(error_msg)
473 | raise ValueError(error_msg)
474 |
475 | async def create_jira_issue(
476 | self,
477 | project: str,
478 | summary: str,
479 | description: str,
480 | issue_type: str,
481 | fields: Optional[Dict[str, Any]] = None,
482 | ) -> JiraIssueResult:
483 | """Create a new Jira issue using v3 REST API
484 |
485 | Args:
486 | project: Project key (e.g., 'PROJ')
487 | summary: Issue summary/title
488 | description: Issue description
489 | issue_type: Issue type - common values include 'Bug', 'Task', 'Story', 'Epic', 'New Feature', 'Improvement'
490 | Note: Available issue types vary by Jira instance and project
491 | fields: Optional additional fields dictionary
492 |
493 | Returns:
494 | JiraIssueResult object with the created issue details
495 |
496 | Example:
497 | # Create a bug
498 | await create_jira_issue(
499 | project='PROJ',
500 | summary='Login button not working',
501 | description='The login button on the homepage is not responding to clicks',
502 | issue_type='Bug'
503 | )
504 |
505 | # Create a task with custom fields
506 | await create_jira_issue(
507 | project='PROJ',
508 | summary='Update documentation',
509 | description='Update API documentation with new endpoints',
510 | issue_type='Task',
511 | fields={
512 | 'assignee': 'jsmith',
513 | 'labels': ['documentation', 'api'],
514 | 'priority': {'name': 'High'}
515 | }
516 | )
517 | """
518 | logger.info("Starting create_jira_issue...")
519 |
520 | try:
521 | # Create a properly formatted issue dictionary
522 | issue_dict = {}
523 |
524 | # Process required fields first
525 | # Project field - required
526 | if isinstance(project, str):
527 | issue_dict["project"] = {"key": project}
528 | else:
529 | issue_dict["project"] = project
530 |
531 | # Summary - required
532 | issue_dict["summary"] = summary
533 |
534 | # Description
535 | if description:
536 | issue_dict["description"] = description
537 |
538 | # Issue type - required, with validation for common issue types
539 | logger.info(
540 | f"Processing issue_type: '{issue_type}' (type: {type(issue_type)})"
541 | )
542 | common_types = [
543 | "bug",
544 | "task",
545 | "story",
546 | "epic",
547 | "improvement",
548 | "newfeature",
549 | "new feature",
550 | ]
551 |
552 | if isinstance(issue_type, str):
553 | # Check for common issue type variants and fix case-sensitivity issues
554 | issue_type_lower = issue_type.lower()
555 |
556 | if issue_type_lower in common_types:
557 | # Convert first letter to uppercase for standard Jira types
558 | issue_type_proper = issue_type_lower.capitalize()
559 | if (
560 | issue_type_lower == "new feature"
561 | or issue_type_lower == "newfeature"
562 | ):
563 | issue_type_proper = "New Feature"
564 |
565 | logger.info(
566 | f"Note: Converting issue type from '{issue_type}' to '{issue_type_proper}'"
567 | )
568 | issue_dict["issuetype"] = {"name": issue_type_proper}
569 | else:
570 | # Use the type as provided - some Jira instances have custom types
571 | issue_dict["issuetype"] = {"name": issue_type}
572 | else:
573 | issue_dict["issuetype"] = issue_type
574 |
575 | # Add any additional fields with proper type handling
576 | if fields:
577 | for key, value in fields.items():
578 | # Skip fields we've already processed
579 | if key in [
580 | "project",
581 | "summary",
582 | "description",
583 | "issuetype",
584 | "issue_type",
585 | ]:
586 | continue
587 |
588 | # Handle special fields that require specific formats
589 | if key == "assignees" or key == "assignee":
590 | # Convert string to array for assignees or proper format for assignee
591 | if isinstance(value, str):
592 | if key == "assignees":
593 | issue_dict[key] = [value] if value else []
594 | else: # assignee
595 | issue_dict[key] = {"name": value} if value else None
596 | elif isinstance(value, list) and key == "assignee" and value:
597 | # If assignee is a list but should be a dict with name
598 | issue_dict[key] = {"name": value[0]}
599 | else:
600 | issue_dict[key] = value
601 | elif key == "labels":
602 | # Convert string to array for labels
603 | if isinstance(value, str):
604 | issue_dict[key] = [value] if value else []
605 | else:
606 | issue_dict[key] = value
607 | elif key == "milestone":
608 | # Convert string to number for milestone
609 | if isinstance(value, str) and value.isdigit():
610 | issue_dict[key] = int(value)
611 | else:
612 | issue_dict[key] = value
613 | else:
614 | issue_dict[key] = value
615 |
616 | # Use v3 API client
617 | v3_client = self._get_v3_api_client()
618 | response_data = await v3_client.create_issue(fields=issue_dict)
619 |
620 | # Extract issue details from v3 API response
621 | issue_key = response_data.get("key")
622 | issue_id = response_data.get("id")
623 |
624 | logger.info(f"Successfully created issue {issue_key} (ID: {issue_id})")
625 |
626 | # Return JiraIssueResult with the created issue details
627 | # For v3 API, we return what we have from the create response
628 | return JiraIssueResult(
629 | key=issue_key,
630 | summary=summary, # Use the summary we provided
631 | description=description, # Use the description we provided
632 | status="Open", # Default status for new issues
633 | )
634 |
635 | except Exception as e:
636 | error_msg = f"Failed to create issue: {type(e).__name__}: {str(e)}"
637 | logger.error(error_msg, exc_info=True)
638 |
639 | # Enhanced error handling for issue type errors
640 | if "issuetype" in str(e).lower() or "issue type" in str(e).lower():
641 | logger.info(
642 | "Issue type error detected, trying to provide helpful suggestions..."
643 | )
644 | try:
645 | project_key = (
646 | project if isinstance(project, str) else project.get("key")
647 | )
648 | if project_key:
649 | issue_types = await self.get_jira_project_issue_types(
650 | project_key
651 | )
652 | type_names = [t.get("name") for t in issue_types]
653 | logger.info(
654 | f"Available issue types for project {project_key}: {', '.join(type_names)}"
655 | )
656 |
657 | # Try to find the closest match
658 | attempted_type = issue_type
659 | closest = None
660 | attempted_lower = attempted_type.lower()
661 | for t in type_names:
662 | if (
663 | attempted_lower in t.lower()
664 | or t.lower() in attempted_lower
665 | ):
666 | closest = t
667 | break
668 |
669 | if closest:
670 | logger.info(
671 | f"The closest match to '{attempted_type}' is '{closest}'"
672 | )
673 | error_msg += f" Available types: {', '.join(type_names)}. Closest match: '{closest}'"
674 | else:
675 | error_msg += f" Available types: {', '.join(type_names)}"
676 | except Exception as fetch_error:
677 | logger.error(f"Could not fetch issue types: {str(fetch_error)}")
678 |
679 | raise ValueError(error_msg)
680 |
681 |
682 |
683 | # Re-raise the exception with more details
684 | if "issuetype" in error_message.lower():
685 | raise ValueError(
686 | f"Invalid issue type '{issue_dict.get('issuetype', {}).get('name', 'Unknown')}'. "
687 | + "Use get_jira_project_issue_types(project_key) to get valid types."
688 | )
689 | raise
690 |
691 | return JiraIssueResult(
692 | key=new_issue.key,
693 | summary=new_issue.fields.summary,
694 | description=new_issue.fields.description,
695 | status=(
696 | new_issue.fields.status.name
697 | if hasattr(new_issue.fields, "status")
698 | else None
699 | ),
700 | )
701 | except Exception as e:
702 | print(f"Failed to create issue: {type(e).__name__}: {str(e)}")
703 | raise ValueError(f"Failed to create issue: {type(e).__name__}: {str(e)}")
704 |
705 | async def create_jira_issues(
706 | self, field_list: List[Dict[str, Any]], prefetch: bool = True
707 | ) -> List[Dict[str, Any]]:
708 | """Bulk create new Jira issues using v3 REST API.
709 |
710 | Parameters:
711 | field_list (List[Dict[str, Any]]): a list of dicts each containing field names and the values to use.
712 | Each dict is an individual issue to create.
713 | prefetch (bool): True reloads the created issue Resource so all of its data is present in the value returned (Default: True)
714 |
715 | Returns:
716 | List[Dict[str, Any]]: List of created issues with their details
717 |
718 | Issue Types:
719 | Common issue types include: 'Bug', 'Task', 'Story', 'Epic', 'New Feature', 'Improvement'
720 | Note: Available issue types vary by Jira instance and project
721 |
722 | Example:
723 | # Create multiple issues in bulk
724 | await create_jira_issues([
725 | {
726 | 'project': 'PROJ',
727 | 'summary': 'Implement user authentication',
728 | 'description': 'Add login and registration functionality',
729 | 'issue_type': 'Story' # Note: case-sensitive, match to your Jira instance types
730 | },
731 | {
732 | 'project': 'PROJ',
733 | 'summary': 'Fix navigation bar display on mobile',
734 | 'description': 'Navigation bar is not displaying correctly on mobile devices',
735 | 'issue_type': 'Bug',
736 | 'priority': {'name': 'High'},
737 | 'labels': ['mobile', 'ui']
738 | }
739 | ])
740 | """
741 | logger.info("Starting create_jira_issues...")
742 |
743 | try:
744 | # Process each field dict to ensure proper formatting for v3 API
745 | processed_field_list = []
746 | for fields in field_list:
747 | # Create a properly formatted issue dictionary
748 | issue_dict = {}
749 |
750 | # Process required fields first to ensure they exist
751 | # Project field - required
752 | if "project" not in fields:
753 | raise ValueError("Each issue must have a 'project' field")
754 | project_value = fields["project"]
755 | if isinstance(project_value, str):
756 | issue_dict["project"] = {"key": project_value}
757 | else:
758 | issue_dict["project"] = project_value
759 |
760 | # Summary field - required
761 | if "summary" not in fields:
762 | raise ValueError("Each issue must have a 'summary' field")
763 | issue_dict["summary"] = fields["summary"]
764 |
765 | # Description field - convert to ADF format for v3 API if it's a simple string
766 | if "description" in fields:
767 | description = fields["description"]
768 | if isinstance(description, str):
769 | # Convert simple string to Atlassian Document Format
770 | issue_dict["description"] = {
771 | "type": "doc",
772 | "version": 1,
773 | "content": [
774 | {
775 | "type": "paragraph",
776 | "content": [
777 | {
778 | "type": "text",
779 | "text": description
780 | }
781 | ]
782 | }
783 | ]
784 | }
785 | else:
786 | # Assume it's already in ADF format
787 | issue_dict["description"] = description
788 |
789 | # Issue type field - required, handle both 'issuetype' and 'issue_type'
790 | issue_type = None
791 | if "issuetype" in fields:
792 | issue_type = fields["issuetype"]
793 | elif "issue_type" in fields:
794 | issue_type = fields["issue_type"]
795 | else:
796 | raise ValueError(
797 | "Each issue must have an 'issuetype' or 'issue_type' field"
798 | )
799 |
800 | # Check for common issue type variants and fix case-sensitivity issues
801 | logger.debug(
802 | f"Processing bulk issue_type: '{issue_type}' (type: {type(issue_type)})"
803 | )
804 | common_types = [
805 | "bug",
806 | "task",
807 | "story",
808 | "epic",
809 | "improvement",
810 | "newfeature",
811 | "new feature",
812 | ]
813 |
814 | if isinstance(issue_type, str):
815 | issue_type_lower = issue_type.lower()
816 |
817 | if issue_type_lower in common_types:
818 | # Convert first letter to uppercase for standard Jira types
819 | issue_type_proper = issue_type_lower.capitalize()
820 | if (
821 | issue_type_lower == "new feature"
822 | or issue_type_lower == "newfeature"
823 | ):
824 | issue_type_proper = "New Feature"
825 |
826 | logger.debug(
827 | f"Converting issue type from '{issue_type}' to '{issue_type_proper}'"
828 | )
829 | issue_dict["issuetype"] = {"name": issue_type_proper}
830 | else:
831 | # Use the type as provided - some Jira instances have custom types
832 | issue_dict["issuetype"] = {"name": issue_type}
833 | else:
834 | issue_dict["issuetype"] = issue_type
835 |
836 | # Process other fields
837 | for key, value in fields.items():
838 | if key in [
839 | "project",
840 | "summary",
841 | "description",
842 | "issuetype",
843 | "issue_type",
844 | ]:
845 | # Skip fields we've already processed
846 | continue
847 |
848 | # Handle special fields that require specific formats
849 | if key == "assignees" or key == "assignee":
850 | # Convert string to array for assignees or proper format for assignee
851 | if isinstance(value, str):
852 | if key == "assignees":
853 | issue_dict[key] = [value] if value else []
854 | else: # assignee
855 | issue_dict[key] = {"name": value} if value else None
856 | elif isinstance(value, list) and key == "assignee" and value:
857 | # If assignee is a list but should be a dict with name
858 | issue_dict[key] = {"name": value[0]}
859 | else:
860 | issue_dict[key] = value
861 | elif key == "labels":
862 | # Convert string to array for labels
863 | if isinstance(value, str):
864 | issue_dict[key] = [value] if value else []
865 | else:
866 | issue_dict[key] = value
867 | elif key == "milestone":
868 | # Convert string to number for milestone
869 | if isinstance(value, str) and value.isdigit():
870 | issue_dict[key] = int(value)
871 | else:
872 | issue_dict[key] = value
873 | else:
874 | issue_dict[key] = value
875 |
876 | # Add to the field list in v3 API format
877 | processed_field_list.append({"fields": issue_dict})
878 |
879 | logger.debug(f"Processed field list: {json.dumps(processed_field_list, indent=2)}")
880 |
881 | # Use v3 API client
882 | v3_client = self._get_v3_api_client()
883 |
884 | # Call the bulk create API
885 | response_data = await v3_client.bulk_create_issues(processed_field_list)
886 |
887 | # Process the results to maintain compatibility with existing interface
888 | processed_results = []
889 |
890 | # Handle successful issues
891 | if "issues" in response_data:
892 | for issue in response_data["issues"]:
893 | processed_results.append({
894 | "key": issue.get("key"),
895 | "id": issue.get("id"),
896 | "self": issue.get("self"),
897 | "success": True,
898 | })
899 |
900 | # Handle errors
901 | if "errors" in response_data:
902 | for error in response_data["errors"]:
903 | processed_results.append({
904 | "error": error,
905 | "success": False,
906 | })
907 |
908 | logger.info(f"Successfully processed {len(processed_results)} issue creations")
909 | return processed_results
910 |
911 | except Exception as e:
912 | error_msg = f"Failed to create issues in bulk: {type(e).__name__}: {str(e)}"
913 | logger.error(error_msg, exc_info=True)
914 | print(error_msg)
915 | raise ValueError(error_msg)
916 |
917 | async def add_jira_comment(self, issue_key: str, comment: str) -> Dict[str, Any]:
918 | """Add a comment to an issue using v3 REST API"""
919 | logger.info("Starting add_jira_comment...")
920 |
921 | try:
922 | # Use v3 API client
923 | v3_client = self._get_v3_api_client()
924 | comment_result = await v3_client.add_comment(
925 | issue_id_or_key=issue_key,
926 | comment=comment,
927 | )
928 |
929 | # Extract useful information from the v3 API response
930 | response_data = {
931 | "id": comment_result.get("id"),
932 | "body": comment_result.get("body", {}),
933 | "created": comment_result.get("created"),
934 | "updated": comment_result.get("updated"),
935 | }
936 |
937 | # Extract author information if available
938 | if "author" in comment_result:
939 | author = comment_result["author"]
940 | response_data["author"] = author.get("displayName", "Unknown")
941 | else:
942 | response_data["author"] = "Unknown"
943 |
944 | logger.info(f"Successfully added comment to issue {issue_key}")
945 | return response_data
946 |
947 | except Exception as e:
948 | error_msg = (
949 | f"Failed to add comment to {issue_key}: {type(e).__name__}: {str(e)}"
950 | )
951 | logger.error(error_msg, exc_info=True)
952 | print(error_msg)
953 | raise ValueError(error_msg)
954 |
955 | async def get_jira_transitions(self, issue_key: str) -> List[JiraTransitionResult]:
956 | """Get available transitions for an issue using v3 REST API"""
957 | logger.info("Starting get_jira_transitions...")
958 |
959 | try:
960 | # Use v3 API client
961 | v3_client = self._get_v3_api_client()
962 | response_data = await v3_client.get_transitions(issue_id_or_key=issue_key)
963 |
964 | # Extract transitions from response
965 | transitions = response_data.get("transitions", [])
966 |
967 | # Convert to JiraTransitionResult objects maintaining compatibility
968 | results = [
969 | JiraTransitionResult(id=transition["id"], name=transition["name"])
970 | for transition in transitions
971 | ]
972 |
973 | logger.info(f"Found {len(results)} transitions for issue {issue_key}")
974 | return results
975 |
976 | except Exception as e:
977 | error_msg = f"Failed to get transitions for {issue_key}: {type(e).__name__}: {str(e)}"
978 | logger.error(error_msg, exc_info=True)
979 | print(error_msg)
980 | raise ValueError(error_msg)
981 |
982 | async def transition_jira_issue(
983 | self,
984 | issue_key: str,
985 | transition_id: str,
986 | comment: Optional[str] = None,
987 | fields: Optional[Dict[str, Any]] = None,
988 | ) -> bool:
989 | """Transition an issue to a new state using v3 REST API"""
990 | logger.info("Starting transition_jira_issue...")
991 |
992 | try:
993 | # Use v3 API client
994 | v3_client = self._get_v3_api_client()
995 | await v3_client.transition_issue(
996 | issue_id_or_key=issue_key,
997 | transition_id=transition_id,
998 | fields=fields,
999 | comment=comment,
1000 | )
1001 |
1002 | logger.info(
1003 | f"Successfully transitioned issue {issue_key} to transition {transition_id}"
1004 | )
1005 | return True
1006 |
1007 | except Exception as e:
1008 | error_msg = (
1009 | f"Failed to transition {issue_key}: {type(e).__name__}: {str(e)}"
1010 | )
1011 | logger.error(error_msg, exc_info=True)
1012 | print(error_msg)
1013 | raise ValueError(error_msg)
1014 |
1015 | async def get_jira_project_issue_types(
1016 | self, project_key: str
1017 | ) -> List[Dict[str, Any]]:
1018 | """Get all available issue types for a specific project using v3 REST API
1019 |
1020 | Args:
1021 | project_key: The project key (e.g., 'PROJ') - kept for backward compatibility,
1022 | but the new API returns all issue types for the user
1023 |
1024 | Returns:
1025 | List of issue type dictionaries with name, id, and description
1026 |
1027 | Example:
1028 | get_jira_project_issue_types('PROJ') # Returns all issue types accessible to user
1029 | """
1030 | logger.info("Starting get_jira_project_issue_types...")
1031 |
1032 | try:
1033 | # Use v3 API client to get all issue types
1034 | v3_client = self._get_v3_api_client()
1035 | response_data = await v3_client.get_issue_types()
1036 |
1037 | # The new API returns the issue types directly as a list, not wrapped in an object
1038 | issue_types_data = (
1039 | response_data
1040 | if isinstance(response_data, list)
1041 | else response_data.get("issueTypes", [])
1042 | )
1043 |
1044 | # Convert to the expected format maintaining compatibility
1045 | issue_types = []
1046 | for issuetype in issue_types_data:
1047 | issue_types.append(
1048 | {
1049 | "id": issuetype.get("id"),
1050 | "name": issuetype.get("name"),
1051 | "description": issuetype.get("description"),
1052 | }
1053 | )
1054 |
1055 | logger.info(
1056 | f"Found {len(issue_types)} issue types (project_key: {project_key})"
1057 | )
1058 | return issue_types
1059 |
1060 | except Exception as e:
1061 | error_msg = f"Failed to get issue types: {type(e).__name__}: {str(e)}"
1062 | logger.error(error_msg, exc_info=True)
1063 | print(error_msg)
1064 | raise ValueError(error_msg)
1065 |
1066 | async def create_jira_project(
1067 | self,
1068 | key: str,
1069 | name: Optional[str] = None,
1070 | assignee: Optional[str] = None,
1071 | ptype: str = "software",
1072 | template_name: Optional[str] = None,
1073 | avatarId: Optional[int] = None,
1074 | issueSecurityScheme: Optional[int] = None,
1075 | permissionScheme: Optional[int] = None,
1076 | projectCategory: Optional[int] = None,
1077 | notificationScheme: Optional[int] = None,
1078 | categoryId: Optional[int] = None,
1079 | url: str = "",
1080 | ) -> JiraProjectResult:
1081 | """Create a project using Jira's v3 REST API
1082 |
1083 | Args:
1084 | key: Project key (required) - must match Jira project key requirements
1085 | name: Project name (defaults to key if not provided)
1086 | assignee: Lead account ID or username
1087 | ptype: Project type key ('software', 'business', 'service_desk')
1088 | template_name: Project template key for creating from templates
1089 | avatarId: ID of the avatar to use for the project
1090 | issueSecurityScheme: ID of the issue security scheme
1091 | permissionScheme: ID of the permission scheme
1092 | projectCategory: ID of the project category
1093 | notificationScheme: ID of the notification scheme
1094 | categoryId: Same as projectCategory (alternative parameter)
1095 | url: URL for project information/documentation
1096 |
1097 | Returns:
1098 | JiraProjectResult with the created project details
1099 |
1100 | Note:
1101 | This method uses Jira's v3 REST API endpoint: POST /rest/api/3/project
1102 |
1103 | Example:
1104 | # Create a basic software project
1105 | create_jira_project(
1106 | key='PROJ',
1107 | name='My Project',
1108 | ptype='software'
1109 | )
1110 |
1111 | # Create with template
1112 | create_jira_project(
1113 | key='BUSI',
1114 | name='Business Project',
1115 | ptype='business',
1116 | template_name='com.atlassian.jira-core-project-templates:jira-core-simplified-task-tracking'
1117 | )
1118 | """
1119 | if not key:
1120 | raise ValueError("Project key is required")
1121 |
1122 | try:
1123 | # Get the v3 API client
1124 | v3_client = self._get_v3_api_client()
1125 |
1126 | # Create project using v3 API
1127 | response_data = await v3_client.create_project(
1128 | key=key,
1129 | name=name,
1130 | assignee=assignee,
1131 | ptype=ptype,
1132 | template_name=template_name,
1133 | avatarId=avatarId,
1134 | issueSecurityScheme=issueSecurityScheme,
1135 | permissionScheme=permissionScheme,
1136 | projectCategory=projectCategory,
1137 | notificationScheme=notificationScheme,
1138 | categoryId=categoryId,
1139 | url=url,
1140 | )
1141 |
1142 | # Extract project details from response
1143 | project_id = response_data.get("id", "0")
1144 | project_key = response_data.get("key", key)
1145 |
1146 | # For lead information, we would need to make another API call
1147 | # For now, return None for lead as it's optional in our result model
1148 | lead = None
1149 |
1150 | return JiraProjectResult(
1151 | key=project_key, name=name or key, id=str(project_id), lead=lead
1152 | )
1153 |
1154 | except Exception as e:
1155 | error_msg = str(e)
1156 | print(f"Error creating project with v3 API: {error_msg}")
1157 | raise ValueError(f"Error creating project: {error_msg}")
1158 |
1159 |
1160 | async def serve(
1161 | server_url: Optional[str] = None,
1162 | auth_method: Optional[str] = None,
1163 | username: Optional[str] = None,
1164 | password: Optional[str] = None,
1165 | token: Optional[str] = None,
1166 | ) -> None:
1167 | server = Server("mcp-jira")
1168 | jira_server = JiraServer(
1169 | server_url=server_url,
1170 | auth_method=auth_method,
1171 | username=username,
1172 | password=password,
1173 | token=token,
1174 | )
1175 |
1176 | @server.list_tools()
1177 | async def list_tools() -> list[Tool]:
1178 | """List available Jira tools."""
1179 | return [
1180 | Tool(
1181 | name=JiraTools.GET_PROJECTS.value,
1182 | description="Get all accessible Jira projects",
1183 | inputSchema={"type": "object", "properties": {}, "required": []},
1184 | ),
1185 | Tool(
1186 | name=JiraTools.GET_ISSUE.value,
1187 | description="Get details for a specific Jira issue by key",
1188 | inputSchema={
1189 | "type": "object",
1190 | "properties": {
1191 | "issue_key": {
1192 | "type": "string",
1193 | "description": "The issue key (e.g., PROJECT-123)",
1194 | }
1195 | },
1196 | "required": ["issue_key"],
1197 | },
1198 | ),
1199 | Tool(
1200 | name=JiraTools.SEARCH_ISSUES.value,
1201 | description="Search for Jira issues using JQL (Jira Query Language)",
1202 | inputSchema={
1203 | "type": "object",
1204 | "properties": {
1205 | "jql": {
1206 | "type": "string",
1207 | "description": "JQL query string (e.g., 'project = MYPROJ AND status = \"In Progress\"')",
1208 | },
1209 | "max_results": {
1210 | "type": "integer",
1211 | "description": "Maximum number of results to return (default: 10)",
1212 | },
1213 | },
1214 | "required": ["jql"],
1215 | },
1216 | ),
1217 | Tool(
1218 | name=JiraTools.CREATE_ISSUE.value,
1219 | description="Create a new Jira issue. Common issue types include 'Bug', 'Task', 'Story', 'Epic' (capitalization handled automatically)",
1220 | inputSchema={
1221 | "type": "object",
1222 | "properties": {
1223 | "project": {
1224 | "type": "string",
1225 | "description": "Project key (e.g., 'MYPROJ')",
1226 | },
1227 | "summary": {
1228 | "type": "string",
1229 | "description": "Issue summary/title",
1230 | },
1231 | "description": {
1232 | "type": "string",
1233 | "description": "Issue description",
1234 | },
1235 | "issue_type": {
1236 | "type": "string",
1237 | "description": "Issue type (e.g., 'Bug', 'Task', 'Story', 'Epic', 'New Feature', 'Improvement'). IMPORTANT: Types are case-sensitive and vary by Jira instance.",
1238 | },
1239 | "fields": {
1240 | "type": "object",
1241 | "description": "Additional fields for the issue (optional)",
1242 | },
1243 | },
1244 | "required": ["project", "summary", "description", "issue_type"],
1245 | },
1246 | ),
1247 | Tool(
1248 | name=JiraTools.CREATE_ISSUES.value,
1249 | description="Bulk create new Jira issues. IMPORTANT: For 'issue_type', use the exact case-sensitive types in your Jira instance (common: 'Bug', 'Task', 'Story', 'Epic')",
1250 | inputSchema={
1251 | "type": "object",
1252 | "properties": {
1253 | "field_list": {
1254 | "type": "array",
1255 | "description": "A list of field dictionaries, each representing an issue to create",
1256 | "items": {
1257 | "type": "object",
1258 | "description": "Field dictionary for a single issue",
1259 | },
1260 | },
1261 | "prefetch": {
1262 | "type": "boolean",
1263 | "description": "Whether to reload created issues (default: true)",
1264 | },
1265 | },
1266 | "required": ["field_list"],
1267 | },
1268 | ),
1269 | Tool(
1270 | name=JiraTools.ADD_COMMENT.value,
1271 | description="Add a comment to a Jira issue",
1272 | inputSchema={
1273 | "type": "object",
1274 | "properties": {
1275 | "issue_key": {
1276 | "type": "string",
1277 | "description": "The issue key (e.g., PROJECT-123)",
1278 | },
1279 | "comment": {
1280 | "type": "string",
1281 | "description": "The comment text",
1282 | },
1283 | },
1284 | "required": ["issue_key", "comment"],
1285 | },
1286 | ),
1287 | Tool(
1288 | name=JiraTools.GET_TRANSITIONS.value,
1289 | description="Get available workflow transitions for a Jira issue",
1290 | inputSchema={
1291 | "type": "object",
1292 | "properties": {
1293 | "issue_key": {
1294 | "type": "string",
1295 | "description": "The issue key (e.g., PROJECT-123)",
1296 | }
1297 | },
1298 | "required": ["issue_key"],
1299 | },
1300 | ),
1301 | Tool(
1302 | name=JiraTools.TRANSITION_ISSUE.value,
1303 | description="Transition a Jira issue to a new status",
1304 | inputSchema={
1305 | "type": "object",
1306 | "properties": {
1307 | "issue_key": {
1308 | "type": "string",
1309 | "description": "The issue key (e.g., PROJECT-123)",
1310 | },
1311 | "transition_id": {
1312 | "type": "string",
1313 | "description": "ID of the transition to perform (get IDs using get_transitions)",
1314 | },
1315 | "comment": {
1316 | "type": "string",
1317 | "description": "Comment to add during transition (optional)",
1318 | },
1319 | "fields": {
1320 | "type": "object",
1321 | "description": "Additional fields to update during transition (optional)",
1322 | },
1323 | },
1324 | "required": ["issue_key", "transition_id"],
1325 | },
1326 | ),
1327 | Tool(
1328 | name=JiraTools.GET_PROJECT_ISSUE_TYPES.value,
1329 | description="Get all available issue types for a specific Jira project",
1330 | inputSchema={
1331 | "type": "object",
1332 | "properties": {
1333 | "project_key": {
1334 | "type": "string",
1335 | "description": "The project key (e.g., 'MYPROJ')",
1336 | }
1337 | },
1338 | "required": ["project_key"],
1339 | },
1340 | ),
1341 | Tool(
1342 | name=JiraTools.CREATE_PROJECT.value,
1343 | description="Create a new Jira project using v3 REST API",
1344 | inputSchema={
1345 | "type": "object",
1346 | "properties": {
1347 | "key": {
1348 | "type": "string",
1349 | "description": "Mandatory. Must match Jira project key requirements, usually only 2-10 uppercase characters.",
1350 | },
1351 | "name": {
1352 | "type": "string",
1353 | "description": "If not specified it will use the key value.",
1354 | },
1355 | "assignee": {
1356 | "type": "string",
1357 | "description": "Lead account ID or username (mapped to leadAccountId in v3 API).",
1358 | },
1359 | "ptype": {
1360 | "type": "string",
1361 | "description": "Project type key: 'software', 'business', or 'service_desk'. Defaults to 'software'.",
1362 | },
1363 | "template_name": {
1364 | "type": "string",
1365 | "description": "Project template key for creating from templates (mapped to projectTemplateKey in v3 API).",
1366 | },
1367 | "avatarId": {
1368 | "type": ["integer", "string"],
1369 | "description": "ID of the avatar to use for the project.",
1370 | },
1371 | "issueSecurityScheme": {
1372 | "type": ["integer", "string"],
1373 | "description": "Determines the security scheme to use.",
1374 | },
1375 | "permissionScheme": {
1376 | "type": ["integer", "string"],
1377 | "description": "Determines the permission scheme to use.",
1378 | },
1379 | "projectCategory": {
1380 | "type": ["integer", "string"],
1381 | "description": "Determines the category the project belongs to.",
1382 | },
1383 | "notificationScheme": {
1384 | "type": ["integer", "string"],
1385 | "description": "Determines the notification scheme to use. Default is None.",
1386 | },
1387 | "categoryId": {
1388 | "type": ["integer", "string"],
1389 | "description": "Same as projectCategory. Can be used interchangeably.",
1390 | },
1391 | "url": {
1392 | "type": "string",
1393 | "description": "A link to information about the project, such as documentation.",
1394 | },
1395 | },
1396 | "required": ["key"],
1397 | },
1398 | ),
1399 | ]
1400 |
1401 | @server.call_tool()
1402 | async def call_tool(
1403 | name: str, arguments: dict
1404 | ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
1405 | """Handle tool calls for Jira operations."""
1406 | logger.info(f"call_tool invoked. Tool: '{name}', Arguments: {arguments}")
1407 | try:
1408 | result: Any
1409 |
1410 | match name:
1411 | case JiraTools.GET_PROJECTS.value:
1412 | logger.info("About to AWAIT jira_server.get_jira_projects...")
1413 | result = await jira_server.get_jira_projects()
1414 | logger.info(
1415 | f"COMPLETED await jira_server.get_jira_projects. Result has {len(result)} items."
1416 | )
1417 |
1418 | case JiraTools.GET_ISSUE.value:
1419 | logger.info("Calling synchronous tool get_jira_issue...")
1420 | issue_key = arguments.get("issue_key")
1421 | if not issue_key:
1422 | raise ValueError("Missing required argument: issue_key")
1423 | result = jira_server.get_jira_issue(issue_key)
1424 | logger.info("Synchronous tool get_jira_issue completed.")
1425 |
1426 | case JiraTools.SEARCH_ISSUES.value:
1427 | logger.info("Calling async tool search_jira_issues...")
1428 | jql = arguments.get("jql")
1429 | if not jql:
1430 | raise ValueError("Missing required argument: jql")
1431 | max_results = arguments.get("max_results", 10)
1432 | result = await jira_server.search_jira_issues(jql, max_results)
1433 | logger.info("Async tool search_jira_issues completed.")
1434 |
1435 | case JiraTools.CREATE_ISSUE.value:
1436 | logger.info("About to AWAIT jira_server.create_jira_issue...")
1437 | required_args = ["project", "summary", "description", "issue_type"]
1438 | if not all(arg in arguments for arg in required_args):
1439 | missing = [arg for arg in required_args if arg not in arguments]
1440 | raise ValueError(
1441 | f"Missing required arguments: {', '.join(missing)}"
1442 | )
1443 | result = await jira_server.create_jira_issue(
1444 | arguments["project"],
1445 | arguments["summary"],
1446 | arguments["description"],
1447 | arguments["issue_type"],
1448 | arguments.get("fields", {}),
1449 | )
1450 | logger.info("COMPLETED await jira_server.create_jira_issue.")
1451 |
1452 | case JiraTools.CREATE_ISSUES.value:
1453 | logger.info("Calling async tool create_jira_issues...")
1454 | field_list = arguments.get("field_list")
1455 | if not field_list:
1456 | raise ValueError("Missing required argument: field_list")
1457 | prefetch = arguments.get("prefetch", True)
1458 | result = await jira_server.create_jira_issues(field_list, prefetch)
1459 | logger.info("Async tool create_jira_issues completed.")
1460 |
1461 | case JiraTools.ADD_COMMENT.value:
1462 | logger.info("About to AWAIT jira_server.add_jira_comment...")
1463 | issue_key = arguments.get("issue_key")
1464 | comment_text = arguments.get("comment") or arguments.get("body")
1465 | if not issue_key or not comment_text:
1466 | raise ValueError(
1467 | "Missing required arguments: issue_key and comment (or body)"
1468 | )
1469 | result = await jira_server.add_jira_comment(issue_key, comment_text)
1470 | logger.info("COMPLETED await jira_server.add_jira_comment.")
1471 |
1472 | case JiraTools.GET_TRANSITIONS.value:
1473 | logger.info("About to AWAIT jira_server.get_jira_transitions...")
1474 | issue_key = arguments.get("issue_key")
1475 | if not issue_key:
1476 | raise ValueError("Missing required argument: issue_key")
1477 | result = await jira_server.get_jira_transitions(issue_key)
1478 | logger.info("COMPLETED await jira_server.get_jira_transitions.")
1479 |
1480 | case JiraTools.TRANSITION_ISSUE.value:
1481 | logger.info("Calling async tool transition_jira_issue...")
1482 | issue_key = arguments.get("issue_key")
1483 | transition_id = arguments.get("transition_id")
1484 | if not issue_key or not transition_id:
1485 | raise ValueError(
1486 | "Missing required arguments: issue_key and transition_id"
1487 | )
1488 | comment = arguments.get("comment")
1489 | fields = arguments.get("fields")
1490 | result = await jira_server.transition_jira_issue(
1491 | issue_key, transition_id, comment, fields
1492 | )
1493 | logger.info("Async tool transition_jira_issue completed.")
1494 |
1495 | case JiraTools.GET_PROJECT_ISSUE_TYPES.value:
1496 | logger.info(
1497 | "Calling asynchronous tool get_jira_project_issue_types..."
1498 | )
1499 | project_key = arguments.get("project_key")
1500 | if not project_key:
1501 | raise ValueError("Missing required argument: project_key")
1502 | result = await jira_server.get_jira_project_issue_types(project_key)
1503 | logger.info(
1504 | "Asynchronous tool get_jira_project_issue_types completed."
1505 | )
1506 |
1507 | case JiraTools.CREATE_PROJECT.value:
1508 | logger.info("About to AWAIT jira_server.create_jira_project...")
1509 | key = arguments.get("key")
1510 | if not key:
1511 | raise ValueError("Missing required argument: key")
1512 | # Type conversion logic from original code
1513 | for int_key in [
1514 | "avatarId",
1515 | "issueSecurityScheme",
1516 | "permissionScheme",
1517 | "projectCategory",
1518 | "notificationScheme",
1519 | "categoryId",
1520 | ]:
1521 | if (
1522 | int_key in arguments
1523 | and isinstance(arguments[int_key], str)
1524 | and arguments[int_key].isdigit()
1525 | ):
1526 | arguments[int_key] = int(arguments[int_key])
1527 | result = await jira_server.create_jira_project(**arguments)
1528 | logger.info("COMPLETED await jira_server.create_jira_project.")
1529 |
1530 | case _:
1531 | raise ValueError(f"Unknown tool: {name}")
1532 |
1533 | logger.debug("Serializing result to JSON...")
1534 |
1535 | # Handle serialization properly for different result types
1536 | if isinstance(result, list):
1537 | # If it's a list, check each item individually
1538 | serialized_result = []
1539 | for item in result:
1540 | if hasattr(item, "model_dump"):
1541 | serialized_result.append(item.model_dump())
1542 | else:
1543 | # It's already a dict or basic type
1544 | serialized_result.append(item)
1545 | else:
1546 | # Single item result
1547 | if hasattr(result, "model_dump"):
1548 | serialized_result = result.model_dump()
1549 | else:
1550 | # It's already a dict or basic type
1551 | serialized_result = result
1552 |
1553 | json_result = json.dumps(serialized_result, indent=2)
1554 | return [TextContent(type="text", text=json_result)]
1555 |
1556 | except Exception as e:
1557 | logger.critical(
1558 | f"FATAL error in call_tool for tool '{name}'", exc_info=True
1559 | )
1560 | return [
1561 | TextContent(
1562 | type="text",
1563 | text=json.dumps(
1564 | {
1565 | "error": f"Error in tool '{name}': {type(e).__name__}: {str(e)}"
1566 | }
1567 | ),
1568 | )
1569 | ]
1570 |
1571 | options = server.create_initialization_options()
1572 | async with stdio_server() as (read_stream, write_stream):
1573 | await server.run(read_stream, write_stream, options)
1574 |
```