jira-mcp-tools 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,335 @@
1
+ Metadata-Version: 2.4
2
+ Name: jira-mcp-tools
3
+ Version: 0.2.2
4
+ Summary: Model Context Protocol server for Jira integration
5
+ Project-URL: Homepage, https://github.com/IBM/jira-mcp-tools
6
+ Project-URL: Repository, https://github.com/IBM/jira-mcp-tools
7
+ Project-URL: Issues, https://github.com/IBM/jira-mcp-tools/issues
8
+ Author-email: IBM <opensource@ibm.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai,jira,llm,mcp,model-context-protocol
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: click>=8.0.0
22
+ Requires-Dist: fastmcp>=2.6.1
23
+ Requires-Dist: jira>=3.4.0
24
+ Requires-Dist: python-dotenv>=1.0.0
25
+ Requires-Dist: requests>=2.32.5
26
+ Requires-Dist: ruff>=0.11.0
27
+ Description-Content-Type: text/markdown
28
+
29
+ # MCP Server for JIRA
30
+
31
+ A Model Context Protocol (MCP) server that provides seamless integration with Jira. This server enables AI assistants to retrieve issue information, comments, and attachments from any Jira instance.
32
+
33
+ ## Features
34
+
35
+ - 🎫 **Issue Management**: Get, create, update, and transition issues
36
+ - 🔍 **Advanced Search**: JQL-based issue search with custom fields
37
+ - 📊 **Epic & Sprint Tracking**: Monitor progress, story points, and completion
38
+ - 🔗 **Issue Relationships**: View and manage issue links
39
+ - 💬 **Comments**: Add and retrieve issue comments
40
+ - 📎 **Attachments**: Download issue attachments
41
+ - 📜 **History**: Track all changes to issues
42
+ - 🚀 **Release Management**: View project versions and releases
43
+ - 👥 **Assignment**: Assign/unassign issues to users
44
+ - 🔒 **Secure Authentication**: Uses Jira API tokens
45
+
46
+ ## Installation
47
+
48
+ ### Using uvx (Recommended)
49
+
50
+ ```bash
51
+ uvx jira-mcp-tools
52
+ ```
53
+
54
+ ### Using pip
55
+
56
+ ```bash
57
+ pip3 install jira-mcp-tools
58
+ ```
59
+
60
+ ## Prerequisites
61
+
62
+ - Python 3.10 or higher
63
+ - Jira API token ([How to create](https://jsw.ibm.com/plugins/servlet/de.resolution.apitokenauth/admin))
64
+ - UV package manager ([Installation guide](https://docs.astral.sh/uv/getting-started/installation/))
65
+
66
+ ## Configuration
67
+
68
+ ### Environment Variables
69
+
70
+ The server requires three environment variables:
71
+
72
+ - `JIRA_URL`: Your Jira instance URL
73
+ - `JIRA_EMAIL`: Your Jira account email
74
+ - `JIRA_TOKEN`: Your Jira API token
75
+
76
+ ### MCP Client Configuration
77
+
78
+ Add to your MCP client configuration (e.g., Claude Desktop, Bob):
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "jira": {
84
+ "command": "uvx",
85
+ "args": ["jira-mcp-tools"],
86
+ "env": {
87
+ "JIRA_URL": "https://jsw.ibm.com/",
88
+ "JIRA_EMAIL": "your-email@ibm.com",
89
+ "JIRA_TOKEN": "your-api-token"
90
+ }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ ## Available Tools
97
+
98
+ ### Issue Information
99
+
100
+ #### `get_jira_ticket_info`
101
+ Get detailed information about a Jira issue including description and comments.
102
+
103
+ **Parameters:**
104
+ - `issue_key` (string): Issue key (e.g., "PROJ-123")
105
+
106
+ **Example:** `get_jira_ticket_info("PROJ-123")`
107
+
108
+ #### `get_jira_ticket_attachments`
109
+ Download attachments from a Jira issue.
110
+
111
+ **Parameters:**
112
+ - `issue_key` (string): Issue key
113
+
114
+ **Example:** `get_jira_ticket_attachments("PROJ-456")`
115
+
116
+ #### `get_issue_history`
117
+ Get complete change history of an issue including status transitions and field updates.
118
+
119
+ **Parameters:**
120
+ - `issue_key` (string): Issue key
121
+
122
+ **Example:** `get_issue_history("PROJ-123")`
123
+
124
+ #### `get_issue_links`
125
+ Get all linked issues (blocks, relates to, etc.).
126
+
127
+ **Parameters:**
128
+ - `issue_key` (string): Issue key
129
+
130
+ **Example:** `get_issue_links("PROJ-123")`
131
+
132
+ ### Search & Discovery
133
+
134
+ #### `search_issues`
135
+ Search for issues using JQL (Jira Query Language).
136
+
137
+ **Parameters:**
138
+ - `jql` (string): JQL query (e.g., 'project = PROJ AND status = "In Progress"')
139
+ - `max_results` (int, optional): Maximum results (default: 50)
140
+ - `fields` (string, optional): Comma-separated field list
141
+
142
+ **Examples:**
143
+ ```python
144
+ search_issues('project = MYPROJ')
145
+ search_issues('assignee = currentUser() AND status != Done')
146
+ search_issues('updated >= -7d ORDER BY updated DESC')
147
+ ```
148
+
149
+ ### Epic & Sprint Management
150
+
151
+ #### `get_epic_details`
152
+ Get epic information including child issues, progress metrics, and story points.
153
+
154
+ **Parameters:**
155
+ - `epic_key` (string): Epic issue key
156
+
157
+ **Example:** `get_epic_details("PROJ-123")`
158
+
159
+ #### `get_sprint_info`
160
+ Get sprint information for a board.
161
+
162
+ **Parameters:**
163
+ - `board_id` (int): Jira board ID
164
+ - `sprint_id` (int, optional): Specific sprint ID (omit for all sprints)
165
+
166
+ **Examples:**
167
+ ```python
168
+ get_sprint_info(42) # All sprints for board 42
169
+ get_sprint_info(42, 123) # Specific sprint
170
+ ```
171
+
172
+ ### Project Management
173
+
174
+ #### `get_project_releases`
175
+ Get all releases/versions for a project with associated issues.
176
+
177
+ **Parameters:**
178
+ - `project_key` (string): Project key (e.g., "PROJ")
179
+
180
+ **Example:** `get_project_releases("PROJ")`
181
+
182
+ ### Issue Modification
183
+
184
+ #### `create_issue`
185
+ Create a new Jira issue.
186
+
187
+ **Parameters:**
188
+ - `project_key` (string): Project key
189
+ - `summary` (string): Issue title
190
+ - `description` (string): Issue description
191
+ - `issue_type` (string, optional): Issue type (default: "Task")
192
+
193
+ **Example:** `create_issue("PROJ", "Fix bug", "Description here", "Bug")`
194
+
195
+ #### `update_issue`
196
+ Update fields of an existing issue.
197
+
198
+ **Parameters:**
199
+ - `issue_key` (string): Issue key
200
+ - `fields` (dict): Fields to update
201
+
202
+ **Examples:**
203
+ ```python
204
+ update_issue("PROJ-123", {"summary": "New title"})
205
+ update_issue("PROJ-123", {"priority": {"name": "High"}})
206
+ update_issue("PROJ-123", {"labels": ["bug", "urgent"]})
207
+ ```
208
+
209
+ #### `transition_issue`
210
+ Move an issue to a new status.
211
+
212
+ **Parameters:**
213
+ - `issue_key` (string): Issue key
214
+ - `transition_name` (string): Transition name (e.g., "In Progress", "Done")
215
+
216
+ **Example:** `transition_issue("PROJ-123", "In Progress")`
217
+
218
+ #### `assign_issue`
219
+ Assign an issue to a user.
220
+
221
+ **Parameters:**
222
+ - `issue_key` (string): Issue key
223
+ - `assignee` (string): Username/email (use "none" to unassign)
224
+
225
+ **Example:** `assign_issue("PROJ-123", "user@company.com")`
226
+
227
+ #### `add_comment`
228
+ Add a comment to an issue.
229
+
230
+ **Parameters:**
231
+ - `issue_key` (string): Issue key
232
+ - `comment_text` (string): Comment text
233
+
234
+ **Example:** `add_comment("PROJ-123", "This is a comment")`
235
+
236
+ ## Development
237
+
238
+ ### Local Development Setup
239
+
240
+ 1. **Clone the repository**
241
+ ```bash
242
+ git clone <repository-url>
243
+ cd mcp-jira
244
+ ```
245
+
246
+ 2. **Install dependencies**
247
+ ```bash
248
+ uv sync
249
+ ```
250
+
251
+ 3. **Configure environment**
252
+ ```bash
253
+ cp env.template .env
254
+ # Edit .env with your Jira credentials:
255
+ # JIRA_URL=https://your-instance.atlassian.net/
256
+ # JIRA_EMAIL=your-email@company.com
257
+ # JIRA_TOKEN=your-api-token
258
+ ```
259
+
260
+ 4. **Run the server**
261
+ ```bash
262
+ uv run mcp_jira/server.py
263
+ ```
264
+
265
+ ### Testing with Bob
266
+
267
+ 1. **Add to Bob's MCP settings** (`~/.bob/mcp_settings.json` or via Bob UI):
268
+ ```json
269
+ {
270
+ "mcpServers": {
271
+ "jira": {
272
+ "command": "uv",
273
+ "args": ["--directory", "/path/to/mcp-jira", "run", "mcp_jira/server.py"],
274
+ "env": {
275
+ "JIRA_URL": "https://your-instance.atlassian.net/",
276
+ "JIRA_EMAIL": "your-email@company.com",
277
+ "JIRA_TOKEN": "your-api-token"
278
+ }
279
+ }
280
+ }
281
+ }
282
+ ```
283
+
284
+ 2. **Restart Bob** to load the MCP server
285
+
286
+ 3. **Switch to Advanced mode** in Bob to access MCP tools
287
+
288
+ 4. **Test the connection**:
289
+ - Ask Bob: "Search for issues in project PROJ"
290
+ - Or: "Get details for issue PROJ-123"
291
+
292
+ ### Running Tests
293
+
294
+ ```bash
295
+ # Run tests
296
+ uv run pytest
297
+
298
+ # Run with coverage
299
+ uv run pytest --cov
300
+ ```
301
+
302
+ ## Use Cases
303
+
304
+ - **AI-Assisted Development**: Let AI assistants fetch and analyze Jira issues
305
+ - **Automated Workflows**: Integrate Jira data into automated processes
306
+ - **Context-Aware Coding**: Provide issue context to AI coding assistants
307
+ - **Documentation**: Auto-generate documentation from Jira issues
308
+
309
+ ## Roadmap
310
+
311
+ - [ ] Support for creating and updating issues
312
+ - [ ] Advanced search capabilities
313
+ - [ ] Support for Jira workflows and transitions
314
+ - [ ] Enhanced attachment handling (PDFs, images)
315
+ - [ ] Bulk operations support
316
+
317
+ ## Contributing
318
+
319
+ Contributions are welcome! Please feel free to submit a Pull Request.
320
+
321
+ ## License
322
+
323
+ MIT License - see [LICENSE](LICENSE) file for details.
324
+
325
+ ## Support
326
+
327
+ - **Issues**: [GitHub Issues](https://github.com/IBM/mcp-jira/issues)
328
+ - **Documentation**: [Full Documentation](https://github.com/IBM/mcp-jira)
329
+
330
+ ## Acknowledgments
331
+
332
+ Built with:
333
+ - [FastMCP](https://github.com/jlowin/fastmcp) - Fast MCP server framework
334
+ - [jira-python](https://github.com/pycontribs/jira) - Python Jira library
335
+ - [Model Context Protocol](https://modelcontextprotocol.io/) - MCP specification
@@ -0,0 +1,7 @@
1
+ mcp_jira/__init__.py,sha256=PugTUGNtlK-reFcQmrxsyfFWAXX1q4jmf6wcoaC0sLA,141
2
+ mcp_jira/server.py,sha256=rVM0ULxXVD_KGQqRhzRX8588lTNZRAXYCdE74Xpmij4,32379
3
+ jira_mcp_tools-0.2.2.dist-info/METADATA,sha256=jfZobmst10MjZeQGFfrv-M16PmQ2pu4F1Dsv_SkBeEY,8744
4
+ jira_mcp_tools-0.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
5
+ jira_mcp_tools-0.2.2.dist-info/entry_points.txt,sha256=1gX2cROyAHAz9VU2OYkt7YoMA3_n3cSNyq_lNwDQ2gY,56
6
+ jira_mcp_tools-0.2.2.dist-info/licenses/LICENSE,sha256=9YcDZtS4tFlpy2vN3uujs7nXbD51sWN0vGI1NvQG5JU,1059
7
+ jira_mcp_tools-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jira-mcp-tools = mcp_jira.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 IBM
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mcp_jira/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from . import server
2
+
3
+ def main():
4
+ server.main()
5
+
6
+ # Optionally expose other important items at package level
7
+ __all__ = ['main', 'server']
mcp_jira/server.py ADDED
@@ -0,0 +1,824 @@
1
+ import os
2
+ import json
3
+ import requests
4
+ from fastmcp import FastMCP, Context
5
+ from contextlib import asynccontextmanager
6
+ from typing import AsyncIterator, Optional
7
+ from jira import JIRA
8
+
9
+ class JiraApiClient:
10
+ def __init__(self, server_url, email, api_token):
11
+ self.jira = JIRA(server=server_url, basic_auth=(email, api_token))
12
+
13
+ async def get_jira_ticket_info(self, issue_key: str):
14
+ """Get information about a JIRA issue."""
15
+ try:
16
+ # Check if we have a valid cached response
17
+ issue = self.jira.issue(issue_key)
18
+ # Get description
19
+ description = issue.fields.description or "No description"
20
+
21
+ # Get comments
22
+ comments = []
23
+ for comment in issue.fields.comment.comments:
24
+ comments.append({
25
+ 'author': comment.author.displayName,
26
+ 'created': comment.created,
27
+ 'body': comment.body
28
+ })
29
+
30
+ result = {
31
+ 'description': description,
32
+ 'comments': comments
33
+ }
34
+
35
+ #TODO: handle comments with images
36
+
37
+ return json.dumps(result, indent=2)
38
+
39
+ except requests.exceptions.RequestException as e:
40
+ # Handle network errors, timeouts, etc.
41
+ raise ValueError(f"Failed to connect to JIRA API: {str(e)}")
42
+ except json.JSONDecodeError as e:
43
+ # Handle malformed JSON responses
44
+ raise ValueError(f"Failed to parse JIRA API response: {str(e)}")
45
+ except Exception as e:
46
+ raise ValueError(f"Failed to get JIRA info: {str(e)}")
47
+
48
+ async def get_jira_ticket_attachments(self, issue_key: str):
49
+ """Get the attachments of a JIRA issue."""
50
+ try:
51
+ issue = self.jira.issue(issue_key)
52
+ attachments = []
53
+ for attachment in issue.fields.attachment:
54
+ attachments.append({
55
+ 'filename': attachment.filename,
56
+ 'content': attachment.get()
57
+ })
58
+
59
+ #TODO: handle pdf and and image attachments
60
+
61
+ return attachments
62
+ except requests.exceptions.RequestException as e:
63
+ # Handle network errors, timeouts, etc.
64
+ raise ValueError(f"Failed to connect to JIRA API: {str(e)}")
65
+ except json.JSONDecodeError as e:
66
+ # Handle malformed JSON responses
67
+ raise ValueError(f"Failed to parse JIRA API response: {str(e)}")
68
+ except Exception as e:
69
+ raise ValueError(f"Failed to get JIRA info: {str(e)}")
70
+
71
+ async def search_issues(self, jql: str, max_results: int = 50, fields: Optional[str] = None):
72
+ """Search for issues using JQL (Jira Query Language)."""
73
+ try:
74
+ # Default fields if not specified
75
+ if not fields:
76
+ fields = "summary,status,assignee,priority,created,updated,duedate,labels,components"
77
+
78
+ issues = self.jira.search_issues(jql, maxResults=max_results, fields=fields)
79
+
80
+ results = []
81
+ for issue in issues:
82
+ issue_data = {
83
+ 'key': issue.key,
84
+ 'summary': issue.fields.summary,
85
+ 'status': issue.fields.status.name,
86
+ 'assignee': issue.fields.assignee.displayName if issue.fields.assignee else 'Unassigned',
87
+ 'priority': issue.fields.priority.name if issue.fields.priority else 'None',
88
+ 'created': issue.fields.created,
89
+ 'updated': issue.fields.updated,
90
+ 'duedate': issue.fields.duedate if hasattr(issue.fields, 'duedate') else None,
91
+ 'labels': issue.fields.labels if hasattr(issue.fields, 'labels') else [],
92
+ 'components': [c.name for c in issue.fields.components] if hasattr(issue.fields, 'components') else []
93
+ }
94
+ results.append(issue_data)
95
+
96
+ return json.dumps({
97
+ 'total': len(results),
98
+ 'issues': results
99
+ }, indent=2)
100
+
101
+ except Exception as e:
102
+ raise ValueError(f"Failed to search issues: {str(e)}")
103
+
104
+ async def get_epic_details(self, epic_key: str):
105
+ """Get detailed information about an epic including all child issues."""
106
+ try:
107
+ epic = self.jira.issue(epic_key)
108
+
109
+ # Get all issues in this epic using JQL
110
+ jql = f'"Epic Link" = {epic_key}'
111
+ child_issues = self.jira.search_issues(jql, maxResults=1000)
112
+
113
+ # Calculate progress metrics
114
+ total_issues = len(child_issues)
115
+ done_issues = sum(1 for issue in child_issues if issue.fields.status.name.lower() in ['done', 'closed', 'resolved'])
116
+ in_progress = sum(1 for issue in child_issues if issue.fields.status.name.lower() in ['in progress', 'in review'])
117
+
118
+ # Get story points if available
119
+ total_points = 0
120
+ done_points = 0
121
+ for issue in child_issues:
122
+ # Story points field varies by Jira instance (customfield_10016 is common)
123
+ if hasattr(issue.fields, 'customfield_10016') and issue.fields.customfield_10016:
124
+ points = float(issue.fields.customfield_10016)
125
+ total_points += points
126
+ if issue.fields.status.name.lower() in ['done', 'closed', 'resolved']:
127
+ done_points += points
128
+
129
+ # Build child issues list
130
+ children = []
131
+ for issue in child_issues:
132
+ children.append({
133
+ 'key': issue.key,
134
+ 'summary': issue.fields.summary,
135
+ 'status': issue.fields.status.name,
136
+ 'assignee': issue.fields.assignee.displayName if issue.fields.assignee else 'Unassigned',
137
+ 'type': issue.fields.issuetype.name
138
+ })
139
+
140
+ result = {
141
+ 'epic_key': epic_key,
142
+ 'epic_summary': epic.fields.summary,
143
+ 'epic_status': epic.fields.status.name,
144
+ 'description': epic.fields.description or 'No description',
145
+ 'total_issues': total_issues,
146
+ 'done_issues': done_issues,
147
+ 'in_progress_issues': in_progress,
148
+ 'todo_issues': total_issues - done_issues - in_progress,
149
+ 'completion_percentage': round((done_issues / total_issues * 100) if total_issues > 0 else 0, 1),
150
+ 'total_story_points': total_points,
151
+ 'done_story_points': done_points,
152
+ 'child_issues': children
153
+ }
154
+
155
+ return json.dumps(result, indent=2)
156
+
157
+ except Exception as e:
158
+ raise ValueError(f"Failed to get epic details: {str(e)}")
159
+
160
+ async def get_sprint_info(self, board_id: int, sprint_id: Optional[int] = None):
161
+ """Get information about sprints on a board."""
162
+ try:
163
+ if sprint_id:
164
+ # Get specific sprint
165
+ sprint = self.jira.sprint(sprint_id)
166
+ issues = self.jira.search_issues(f'sprint = {sprint_id}', maxResults=1000)
167
+
168
+ sprint_data = {
169
+ 'id': sprint.id,
170
+ 'name': sprint.name,
171
+ 'state': sprint.state,
172
+ 'start_date': sprint.startDate if hasattr(sprint, 'startDate') else None,
173
+ 'end_date': sprint.endDate if hasattr(sprint, 'endDate') else None,
174
+ 'total_issues': len(issues),
175
+ 'issues': []
176
+ }
177
+
178
+ for issue in issues:
179
+ sprint_data['issues'].append({
180
+ 'key': issue.key,
181
+ 'summary': issue.fields.summary,
182
+ 'status': issue.fields.status.name,
183
+ 'assignee': issue.fields.assignee.displayName if issue.fields.assignee else 'Unassigned'
184
+ })
185
+
186
+ return json.dumps(sprint_data, indent=2)
187
+ else:
188
+ # Get all sprints for board
189
+ sprints = self.jira.sprints(board_id)
190
+
191
+ sprint_list = []
192
+ for sprint in sprints:
193
+ sprint_list.append({
194
+ 'id': sprint.id,
195
+ 'name': sprint.name,
196
+ 'state': sprint.state,
197
+ 'start_date': sprint.startDate if hasattr(sprint, 'startDate') else None,
198
+ 'end_date': sprint.endDate if hasattr(sprint, 'endDate') else None
199
+ })
200
+
201
+ return json.dumps({
202
+ 'board_id': board_id,
203
+ 'total_sprints': len(sprint_list),
204
+ 'sprints': sprint_list
205
+ }, indent=2)
206
+
207
+ except Exception as e:
208
+ raise ValueError(f"Failed to get sprint info: {str(e)}")
209
+
210
+
211
+ async def get_issue_history(self, issue_key: str):
212
+ """Get the change history of a Jira issue."""
213
+ try:
214
+ issue = self.jira.issue(issue_key, expand='changelog')
215
+
216
+ history = []
217
+ for change in issue.changelog.histories:
218
+ change_data = {
219
+ 'author': change.author.displayName,
220
+ 'created': change.created,
221
+ 'changes': []
222
+ }
223
+
224
+ for item in change.items:
225
+ change_data['changes'].append({
226
+ 'field': item.field,
227
+ 'fieldtype': item.fieldtype,
228
+ 'from': item.fromString,
229
+ 'to': item.toString
230
+ })
231
+
232
+ history.append(change_data)
233
+
234
+ result = {
235
+ 'issue_key': issue_key,
236
+ 'summary': issue.fields.summary,
237
+ 'current_status': issue.fields.status.name,
238
+ 'history': history
239
+ }
240
+
241
+ return json.dumps(result, indent=2)
242
+
243
+ except Exception as e:
244
+ raise ValueError(f"Failed to get issue history: {str(e)}")
245
+
246
+ async def get_project_releases(self, project_key: str):
247
+ """Get all releases/versions for a project."""
248
+ try:
249
+ versions = self.jira.project_versions(project_key)
250
+
251
+ releases = []
252
+ for version in versions:
253
+ # Get issues for this version
254
+ jql = f'project = {project_key} AND fixVersion = "{version.name}"'
255
+ issues = self.jira.search_issues(jql, maxResults=1000)
256
+
257
+ release_data = {
258
+ 'id': version.id,
259
+ 'name': version.name,
260
+ 'description': version.description if hasattr(version, 'description') else None,
261
+ 'released': version.released if hasattr(version, 'released') else False,
262
+ 'release_date': version.releaseDate if hasattr(version, 'releaseDate') else None,
263
+ 'start_date': version.startDate if hasattr(version, 'startDate') else None,
264
+ 'archived': version.archived if hasattr(version, 'archived') else False,
265
+ 'total_issues': len(issues),
266
+ 'issues': []
267
+ }
268
+
269
+ # Add issue summaries
270
+ for issue in issues[:50]: # Limit to first 50 issues
271
+ release_data['issues'].append({
272
+ 'key': issue.key,
273
+ 'summary': issue.fields.summary,
274
+ 'status': issue.fields.status.name,
275
+ 'type': issue.fields.issuetype.name
276
+ })
277
+
278
+ releases.append(release_data)
279
+
280
+ return json.dumps({
281
+ 'project_key': project_key,
282
+ 'total_releases': len(releases),
283
+ 'releases': releases
284
+ }, indent=2)
285
+
286
+ except Exception as e:
287
+ raise ValueError(f"Failed to get project releases: {str(e)}")
288
+
289
+ async def get_issue_links(self, issue_key: str):
290
+ """Get all linked issues for a given issue."""
291
+ try:
292
+ issue = self.jira.issue(issue_key)
293
+
294
+ links = []
295
+ if hasattr(issue.fields, 'issuelinks'):
296
+ for link in issue.fields.issuelinks:
297
+ link_data = {
298
+ 'type': link.type.name,
299
+ 'direction': None,
300
+ 'linked_issue': {}
301
+ }
302
+
303
+ # Determine direction and get linked issue
304
+ if hasattr(link, 'outwardIssue'):
305
+ link_data['direction'] = 'outward'
306
+ linked = link.outwardIssue
307
+ elif hasattr(link, 'inwardIssue'):
308
+ link_data['direction'] = 'inward'
309
+ linked = link.inwardIssue
310
+ else:
311
+ continue
312
+
313
+ link_data['linked_issue'] = {
314
+ 'key': linked.key,
315
+ 'summary': linked.fields.summary,
316
+ 'status': linked.fields.status.name,
317
+ 'type': linked.fields.issuetype.name
318
+ }
319
+
320
+ links.append(link_data)
321
+
322
+ result = {
323
+ 'issue_key': issue_key,
324
+ 'summary': issue.fields.summary,
325
+ 'total_links': len(links),
326
+ 'links': links
327
+ }
328
+
329
+ return json.dumps(result, indent=2)
330
+
331
+ except Exception as e:
332
+ raise ValueError(f"Failed to get issue links: {str(e)}")
333
+
334
+ async def add_comment(self, issue_key: str, comment_text: str):
335
+ """Add a comment to a Jira issue."""
336
+ try:
337
+ issue = self.jira.issue(issue_key)
338
+ comment = self.jira.add_comment(issue, comment_text)
339
+
340
+ result = {
341
+ 'issue_key': issue_key,
342
+ 'comment_id': comment.id,
343
+ 'author': comment.author.displayName,
344
+ 'created': comment.created,
345
+ 'body': comment.body
346
+ }
347
+
348
+ return json.dumps(result, indent=2)
349
+
350
+ except Exception as e:
351
+ raise ValueError(f"Failed to add comment: {str(e)}")
352
+
353
+ async def update_issue(self, issue_key: str, fields: dict):
354
+ """Update fields of a Jira issue."""
355
+ try:
356
+ issue = self.jira.issue(issue_key)
357
+ issue.update(fields=fields)
358
+
359
+ # Get updated issue
360
+ updated_issue = self.jira.issue(issue_key)
361
+
362
+ result = {
363
+ 'issue_key': issue_key,
364
+ 'summary': updated_issue.fields.summary,
365
+ 'status': updated_issue.fields.status.name,
366
+ 'updated': updated_issue.fields.updated,
367
+ 'updated_fields': list(fields.keys())
368
+ }
369
+
370
+ return json.dumps(result, indent=2)
371
+
372
+ except Exception as e:
373
+ raise ValueError(f"Failed to update issue: {str(e)}")
374
+
375
+ async def create_issue(self, project_key: str, summary: str, description: str, issue_type: str = "Task"):
376
+ """Create a new Jira issue."""
377
+ try:
378
+ issue_dict = {
379
+ 'project': {'key': project_key},
380
+ 'summary': summary,
381
+ 'description': description,
382
+ 'issuetype': {'name': issue_type}
383
+ }
384
+
385
+ new_issue = self.jira.create_issue(fields=issue_dict)
386
+
387
+ result = {
388
+ 'issue_key': new_issue.key,
389
+ 'summary': new_issue.fields.summary,
390
+ 'status': new_issue.fields.status.name,
391
+ 'issue_type': new_issue.fields.issuetype.name,
392
+ 'created': new_issue.fields.created,
393
+ 'url': f"{self.jira._options['server']}/browse/{new_issue.key}"
394
+ }
395
+
396
+ return json.dumps(result, indent=2)
397
+
398
+ except Exception as e:
399
+ raise ValueError(f"Failed to create issue: {str(e)}")
400
+
401
+ async def transition_issue(self, issue_key: str, transition_name: str):
402
+ """Transition a Jira issue to a new status."""
403
+ try:
404
+ issue = self.jira.issue(issue_key)
405
+
406
+ # Get available transitions
407
+ transitions = self.jira.transitions(issue)
408
+ transition_id = None
409
+
410
+ for t in transitions:
411
+ if t['name'].lower() == transition_name.lower():
412
+ transition_id = t['id']
413
+ break
414
+
415
+ if not transition_id:
416
+ available = [t['name'] for t in transitions]
417
+ raise ValueError(f"Transition '{transition_name}' not found. Available: {', '.join(available)}")
418
+
419
+ # Perform transition
420
+ self.jira.transition_issue(issue, transition_id)
421
+
422
+ # Get updated issue
423
+ updated_issue = self.jira.issue(issue_key)
424
+
425
+ result = {
426
+ 'issue_key': issue_key,
427
+ 'summary': updated_issue.fields.summary,
428
+ 'old_status': issue.fields.status.name,
429
+ 'new_status': updated_issue.fields.status.name,
430
+ 'transition': transition_name
431
+ }
432
+
433
+ return json.dumps(result, indent=2)
434
+
435
+ except Exception as e:
436
+ raise ValueError(f"Failed to transition issue: {str(e)}")
437
+
438
+ async def assign_issue(self, issue_key: str, assignee: str):
439
+ """Assign a Jira issue to a user."""
440
+ try:
441
+ issue = self.jira.issue(issue_key)
442
+ old_assignee = issue.fields.assignee.displayName if issue.fields.assignee else 'Unassigned'
443
+
444
+ # Assign issue (use None for unassign, or username/email)
445
+ if assignee.lower() in ['none', 'unassigned', '']:
446
+ self.jira.assign_issue(issue, None)
447
+ new_assignee = 'Unassigned'
448
+ else:
449
+ self.jira.assign_issue(issue, assignee)
450
+ updated_issue = self.jira.issue(issue_key)
451
+ new_assignee = updated_issue.fields.assignee.displayName if updated_issue.fields.assignee else 'Unassigned'
452
+
453
+ result = {
454
+ 'issue_key': issue_key,
455
+ 'summary': issue.fields.summary,
456
+ 'old_assignee': old_assignee,
457
+ 'new_assignee': new_assignee
458
+ }
459
+
460
+ return json.dumps(result, indent=2)
461
+
462
+ except Exception as e:
463
+ raise ValueError(f"Failed to assign issue: {str(e)}")
464
+
465
+ async def get_development_links(self, issue_key: str):
466
+ """Get development links (GitHub branches, PRs, commits) for a Jira issue."""
467
+ try:
468
+ issue = self.jira.issue(issue_key)
469
+
470
+ # Get remote links which contain development information
471
+ remote_links = self.jira.remote_links(issue)
472
+
473
+ dev_links = {
474
+ 'issue_key': issue_key,
475
+ 'summary': issue.fields.summary,
476
+ 'branches': [],
477
+ 'pull_requests': [],
478
+ 'commits': [],
479
+ 'other_links': []
480
+ }
481
+
482
+ for link in remote_links:
483
+ link_obj = link.object if hasattr(link, 'object') else {}
484
+ url = link_obj.get('url', '') if isinstance(link_obj, dict) else ''
485
+ title = link_obj.get('title', '') if isinstance(link_obj, dict) else ''
486
+
487
+ # Categorize based on URL patterns
488
+ if 'github.com' in url or 'gitlab.com' in url or 'bitbucket.org' in url:
489
+ if '/pull/' in url or '/merge_requests/' in url:
490
+ dev_links['pull_requests'].append({
491
+ 'url': url,
492
+ 'title': title,
493
+ 'status': link_obj.get('status', {}).get('resolved', False) if isinstance(link_obj, dict) else None
494
+ })
495
+ elif '/tree/' in url or '/branch/' in url:
496
+ dev_links['branches'].append({
497
+ 'url': url,
498
+ 'title': title
499
+ })
500
+ elif '/commit/' in url:
501
+ dev_links['commits'].append({
502
+ 'url': url,
503
+ 'title': title
504
+ })
505
+ else:
506
+ dev_links['other_links'].append({
507
+ 'url': url,
508
+ 'title': title
509
+ })
510
+ else:
511
+ dev_links['other_links'].append({
512
+ 'url': url,
513
+ 'title': title
514
+ })
515
+
516
+ dev_links['total_branches'] = len(dev_links['branches'])
517
+ dev_links['total_pull_requests'] = len(dev_links['pull_requests'])
518
+ dev_links['total_commits'] = len(dev_links['commits'])
519
+
520
+ return json.dumps(dev_links, indent=2)
521
+
522
+ except Exception as e:
523
+ raise ValueError(f"Failed to get development links: {str(e)}")
524
+
525
+ class JiraContext:
526
+ def __init__(self, connector: JiraApiClient):
527
+ self.connector = connector
528
+
529
+ @asynccontextmanager
530
+ async def server_lifespan(server: FastMCP) -> AsyncIterator[JiraContext]:
531
+ """Manage application lifecycle for Jira Api Client."""
532
+ jira_url = os.getenv("JIRA_URL")
533
+ jira_email = os.getenv("JIRA_EMAIL")
534
+ jira_token = os.getenv("JIRA_TOKEN")
535
+ connector = JiraApiClient(jira_url, jira_email, jira_token)
536
+
537
+ try:
538
+ yield JiraContext(connector)
539
+ finally:
540
+ pass
541
+
542
+ mcp = FastMCP(name="JIRA", lifespan=server_lifespan)
543
+
544
+ @mcp.tool()
545
+ async def get_jira_ticket_info(issue_key: str, ctx: Context) -> str:
546
+ """Get detailed information about a JIRA issue."""
547
+ if not issue_key:
548
+ return "Error: Missing required parameter 'issue_key'"
549
+
550
+ connector = ctx.request_context.lifespan_context.connector
551
+ try:
552
+ return await connector.get_jira_ticket_info(issue_key)
553
+ except ValueError as e:
554
+ return f"Error: {str(e)}"
555
+
556
+ @mcp.tool()
557
+ async def get_jira_ticket_attachments(issue_key: str, ctx: Context) -> str:
558
+ """Get the attachments of a JIRA issue."""
559
+ if not issue_key:
560
+ return "Error: Missing required parameter 'issue_key'"
561
+
562
+ connector = ctx.request_context.lifespan_context.connector
563
+ try:
564
+ return await connector.get_jira_ticket_attachments(issue_key)
565
+ except ValueError as e:
566
+ return f"Error: {str(e)}"
567
+
568
+ @mcp.tool()
569
+ async def search_issues(jql: str, max_results: int = 50, fields: Optional[str] = None, ctx: Context = None) -> str:
570
+ """Search for Jira issues using JQL (Jira Query Language).
571
+
572
+ Args:
573
+ jql: JQL query string (e.g., 'project = PROJ AND status = "In Progress"')
574
+ max_results: Maximum number of results to return (default: 50)
575
+ fields: Comma-separated list of fields to return (default: summary,status,assignee,priority,created,updated,duedate,labels,components)
576
+
577
+ Examples:
578
+ - 'project = MYPROJ'
579
+ - 'assignee = currentUser() AND status != Done'
580
+ - 'updated >= -7d ORDER BY updated DESC'
581
+ - 'labels = urgent AND duedate < now()'
582
+ """
583
+ if not jql:
584
+ return "Error: Missing required parameter 'jql'"
585
+
586
+ connector = ctx.request_context.lifespan_context.connector
587
+ try:
588
+ return await connector.search_issues(jql, max_results, fields)
589
+ except ValueError as e:
590
+ return f"Error: {str(e)}"
591
+
592
+ @mcp.tool()
593
+ async def get_epic_details(epic_key: str, ctx: Context) -> str:
594
+ """Get detailed information about an epic including all child issues and progress metrics.
595
+
596
+ Args:
597
+ epic_key: The epic issue key (e.g., 'PROJ-123')
598
+
599
+ Returns:
600
+ Epic summary, status, description, child issues, completion percentage, and story points
601
+ """
602
+ if not epic_key:
603
+ return "Error: Missing required parameter 'epic_key'"
604
+
605
+ connector = ctx.request_context.lifespan_context.connector
606
+ try:
607
+ return await connector.get_epic_details(epic_key)
608
+ except ValueError as e:
609
+ return f"Error: {str(e)}"
610
+
611
+ @mcp.tool()
612
+ async def get_sprint_info(board_id: int, sprint_id: Optional[int] = None, ctx: Context = None) -> str:
613
+ """Get information about sprints on a Jira board.
614
+
615
+ Args:
616
+ board_id: The Jira board ID
617
+ sprint_id: Optional specific sprint ID. If not provided, returns all sprints for the board
618
+
619
+ Returns:
620
+ Sprint details including name, state, dates, and issues (if sprint_id provided)
621
+ or list of all sprints (if sprint_id not provided)
622
+ """
623
+ if not board_id:
624
+ return "Error: Missing required parameter 'board_id'"
625
+
626
+ connector = ctx.request_context.lifespan_context.connector
627
+ try:
628
+ return await connector.get_sprint_info(board_id, sprint_id)
629
+ except ValueError as e:
630
+ return f"Error: {str(e)}"
631
+
632
+
633
+ @mcp.tool()
634
+ async def get_issue_history(issue_key: str, ctx: Context) -> str:
635
+ """Get the change history of a Jira issue.
636
+
637
+ Args:
638
+ issue_key: The issue key (e.g., 'PROJ-123')
639
+
640
+ Returns:
641
+ Complete change history including status transitions, assignee changes, and field updates
642
+ """
643
+ if not issue_key:
644
+ return "Error: Missing required parameter 'issue_key'"
645
+
646
+ connector = ctx.request_context.lifespan_context.connector
647
+ try:
648
+ return await connector.get_issue_history(issue_key)
649
+ except ValueError as e:
650
+ return f"Error: {str(e)}"
651
+
652
+ @mcp.tool()
653
+ async def get_project_releases(project_key: str, ctx: Context) -> str:
654
+ """Get all releases/versions for a project.
655
+
656
+ Args:
657
+ project_key: The project key (e.g., 'PROJ')
658
+
659
+ Returns:
660
+ List of all releases with their status, dates, and associated issues
661
+ """
662
+ if not project_key:
663
+ return "Error: Missing required parameter 'project_key'"
664
+
665
+ connector = ctx.request_context.lifespan_context.connector
666
+ try:
667
+ return await connector.get_project_releases(project_key)
668
+ except ValueError as e:
669
+ return f"Error: {str(e)}"
670
+
671
+ @mcp.tool()
672
+ async def get_issue_links(issue_key: str, ctx: Context) -> str:
673
+ """Get all linked issues for a given issue.
674
+
675
+ Args:
676
+ issue_key: The issue key (e.g., 'PROJ-123')
677
+
678
+ Returns:
679
+ All issue links including blocks/blocked by, relates to, and other link types
680
+ """
681
+ if not issue_key:
682
+ return "Error: Missing required parameter 'issue_key'"
683
+
684
+ connector = ctx.request_context.lifespan_context.connector
685
+ try:
686
+ return await connector.get_issue_links(issue_key)
687
+ except ValueError as e:
688
+ return f"Error: {str(e)}"
689
+
690
+ @mcp.tool()
691
+ async def add_comment(issue_key: str, comment_text: str, ctx: Context) -> str:
692
+ """Add a comment to a Jira issue.
693
+
694
+ Args:
695
+ issue_key: The issue key (e.g., 'PROJ-123')
696
+ comment_text: The comment text to add
697
+
698
+ Returns:
699
+ Comment details including ID, author, and timestamp
700
+ """
701
+ if not issue_key or not comment_text:
702
+ return "Error: Missing required parameters 'issue_key' and 'comment_text'"
703
+
704
+ connector = ctx.request_context.lifespan_context.connector
705
+ try:
706
+ return await connector.add_comment(issue_key, comment_text)
707
+ except ValueError as e:
708
+ return f"Error: {str(e)}"
709
+
710
+ @mcp.tool()
711
+ async def update_issue(issue_key: str, fields: dict, ctx: Context) -> str:
712
+ """Update fields of a Jira issue.
713
+
714
+ Args:
715
+ issue_key: The issue key (e.g., 'PROJ-123')
716
+ fields: Dictionary of fields to update (e.g., {'summary': 'New title', 'description': 'New desc'})
717
+
718
+ Returns:
719
+ Updated issue details
720
+
721
+ Examples:
722
+ - Update summary: {'summary': 'New title'}
723
+ - Update description: {'description': 'New description'}
724
+ - Update priority: {'priority': {'name': 'High'}}
725
+ - Update labels: {'labels': ['bug', 'urgent']}
726
+ """
727
+ if not issue_key or not fields:
728
+ return "Error: Missing required parameters 'issue_key' and 'fields'"
729
+
730
+ connector = ctx.request_context.lifespan_context.connector
731
+ try:
732
+ return await connector.update_issue(issue_key, fields)
733
+ except ValueError as e:
734
+ return f"Error: {str(e)}"
735
+
736
+ @mcp.tool()
737
+ async def create_issue(project_key: str, summary: str, description: str, issue_type: str = "Task", ctx: Context = None) -> str:
738
+ """Create a new Jira issue.
739
+
740
+ Args:
741
+ project_key: The project key (e.g., 'PROJ')
742
+ summary: Issue title/summary
743
+ description: Issue description
744
+ issue_type: Type of issue (default: 'Task', options: 'Bug', 'Story', 'Epic', etc.)
745
+
746
+ Returns:
747
+ Created issue details including key and URL
748
+ """
749
+ if not project_key or not summary or not description:
750
+ return "Error: Missing required parameters 'project_key', 'summary', and 'description'"
751
+
752
+ connector = ctx.request_context.lifespan_context.connector
753
+ try:
754
+ return await connector.create_issue(project_key, summary, description, issue_type)
755
+ except ValueError as e:
756
+ return f"Error: {str(e)}"
757
+
758
+ @mcp.tool()
759
+ async def transition_issue(issue_key: str, transition_name: str, ctx: Context) -> str:
760
+ """Transition a Jira issue to a new status.
761
+
762
+ Args:
763
+ issue_key: The issue key (e.g., 'PROJ-123')
764
+ transition_name: Name of the transition (e.g., 'In Progress', 'Done', 'To Do')
765
+
766
+ Returns:
767
+ Transition details including old and new status
768
+
769
+ Note: Available transitions depend on the workflow. If transition fails,
770
+ the error will list available transitions for the issue.
771
+ """
772
+ if not issue_key or not transition_name:
773
+ return "Error: Missing required parameters 'issue_key' and 'transition_name'"
774
+
775
+ connector = ctx.request_context.lifespan_context.connector
776
+ try:
777
+ return await connector.transition_issue(issue_key, transition_name)
778
+ except ValueError as e:
779
+ return f"Error: {str(e)}"
780
+
781
+ @mcp.tool()
782
+ async def assign_issue(issue_key: str, assignee: str, ctx: Context) -> str:
783
+ """Assign a Jira issue to a user.
784
+
785
+ Args:
786
+ issue_key: The issue key (e.g., 'PROJ-123')
787
+ assignee: Username or email of assignee (use 'none' or 'unassigned' to unassign)
788
+
789
+ Returns:
790
+ Assignment details including old and new assignee
791
+ """
792
+ if not issue_key or not assignee:
793
+ return "Error: Missing required parameters 'issue_key' and 'assignee'"
794
+
795
+ connector = ctx.request_context.lifespan_context.connector
796
+ try:
797
+ return await connector.assign_issue(issue_key, assignee)
798
+ except ValueError as e:
799
+ return f"Error: {str(e)}"
800
+
801
+ @mcp.tool()
802
+ async def get_development_links(issue_key: str, ctx: Context) -> str:
803
+ """Get development links (GitHub branches, PRs, commits) for a Jira issue.
804
+
805
+ Args:
806
+ issue_key: The issue key (e.g., 'PROJ-123')
807
+
808
+ Returns:
809
+ Development links including GitHub/GitLab/Bitbucket branches, pull requests, and commits
810
+ """
811
+ if not issue_key:
812
+ return "Error: Missing required parameter 'issue_key'"
813
+
814
+ connector = ctx.request_context.lifespan_context.connector
815
+ try:
816
+ return await connector.get_development_links(issue_key)
817
+ except ValueError as e:
818
+ return f"Error: {str(e)}"
819
+
820
+ def main():
821
+ mcp.run()
822
+
823
+ if __name__ == "__main__":
824
+ main()