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,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
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()
|