jira-mcp-tools 0.2.0__tar.gz
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.
- jira_mcp_tools-0.2.0/.gitattributes +1 -0
- jira_mcp_tools-0.2.0/.gitignore +50 -0
- jira_mcp_tools-0.2.0/.python-version +1 -0
- jira_mcp_tools-0.2.0/LICENSE +21 -0
- jira_mcp_tools-0.2.0/PKG-INFO +189 -0
- jira_mcp_tools-0.2.0/PUBLISHING.md +161 -0
- jira_mcp_tools-0.2.0/README.md +161 -0
- jira_mcp_tools-0.2.0/env.template +3 -0
- jira_mcp_tools-0.2.0/pyproject.toml +44 -0
- jira_mcp_tools-0.2.0/src/mcp-jira/__init__.py +7 -0
- jira_mcp_tools-0.2.0/src/mcp-jira/server.py +745 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
package-lock.json linguist-generated=true
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib/
|
|
14
|
+
lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
|
|
23
|
+
# Virtual environments
|
|
24
|
+
.venv/
|
|
25
|
+
venv/
|
|
26
|
+
ENV/
|
|
27
|
+
env/
|
|
28
|
+
|
|
29
|
+
# Environment variables
|
|
30
|
+
.env
|
|
31
|
+
.env.local
|
|
32
|
+
|
|
33
|
+
# IDE
|
|
34
|
+
.vscode/
|
|
35
|
+
.idea/
|
|
36
|
+
*.swp
|
|
37
|
+
*.swo
|
|
38
|
+
*~
|
|
39
|
+
|
|
40
|
+
# OS
|
|
41
|
+
.DS_Store
|
|
42
|
+
Thumbs.db
|
|
43
|
+
|
|
44
|
+
# Testing
|
|
45
|
+
.pytest_cache/
|
|
46
|
+
.coverage
|
|
47
|
+
htmlcov/
|
|
48
|
+
|
|
49
|
+
# UV
|
|
50
|
+
uv.lock
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -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.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jira-mcp-tools
|
|
3
|
+
Version: 0.2.0
|
|
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
|
+
- 🎫 **Get Issue Information**: Retrieve detailed issue descriptions and comments
|
|
36
|
+
- 📎 **Download Attachments**: Access and download issue attachments
|
|
37
|
+
- 🔒 **Secure Authentication**: Uses Jira API tokens for secure access
|
|
38
|
+
- 🌐 **Universal Compatibility**: Works with Jira Cloud, Server, and Data Center
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
### Using uvx (Recommended)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uvx mcp-jira
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Using pip
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install mcp-jira
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Prerequisites
|
|
55
|
+
|
|
56
|
+
- Python 3.10 or higher
|
|
57
|
+
- Jira account with API access
|
|
58
|
+
- Jira API token ([How to create](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/))
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
### Environment Variables
|
|
63
|
+
|
|
64
|
+
The server requires three environment variables:
|
|
65
|
+
|
|
66
|
+
- `JIRA_URL`: Your Jira instance URL (e.g., `https://your-company.atlassian.net/`)
|
|
67
|
+
- `JIRA_EMAIL`: Your Jira account email
|
|
68
|
+
- `JIRA_TOKEN`: Your Jira API token
|
|
69
|
+
|
|
70
|
+
### MCP Client Configuration
|
|
71
|
+
|
|
72
|
+
Add to your MCP client configuration (e.g., Claude Desktop, Bob):
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"mcpServers": {
|
|
77
|
+
"jira": {
|
|
78
|
+
"command": "uvx",
|
|
79
|
+
"args": ["mcp-jira"],
|
|
80
|
+
"env": {
|
|
81
|
+
"JIRA_URL": "https://your-jira-instance.com/",
|
|
82
|
+
"JIRA_EMAIL": "your-email@company.com",
|
|
83
|
+
"JIRA_TOKEN": "your-api-token"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Available Tools
|
|
91
|
+
|
|
92
|
+
### `get_jira_ticket_info`
|
|
93
|
+
|
|
94
|
+
Retrieves comprehensive information about a Jira issue.
|
|
95
|
+
|
|
96
|
+
**Parameters:**
|
|
97
|
+
- `issue_key` (string): The Jira issue key (e.g., "PROJ-123")
|
|
98
|
+
|
|
99
|
+
**Returns:**
|
|
100
|
+
- Issue description
|
|
101
|
+
- All comments with author and timestamp
|
|
102
|
+
- Comment bodies
|
|
103
|
+
|
|
104
|
+
**Example:**
|
|
105
|
+
```python
|
|
106
|
+
get_jira_ticket_info("PROJ-123")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### `get_jira_ticket_attachments`
|
|
110
|
+
|
|
111
|
+
Downloads and retrieves attachments from a Jira issue.
|
|
112
|
+
|
|
113
|
+
**Parameters:**
|
|
114
|
+
- `issue_key` (string): The Jira issue key (e.g., "PROJ-123")
|
|
115
|
+
|
|
116
|
+
**Returns:**
|
|
117
|
+
- List of attachments with filenames and content
|
|
118
|
+
|
|
119
|
+
**Example:**
|
|
120
|
+
```python
|
|
121
|
+
get_jira_ticket_attachments("PROJ-456")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
### Setup
|
|
127
|
+
|
|
128
|
+
1. Clone the repository
|
|
129
|
+
2. Install dependencies:
|
|
130
|
+
```bash
|
|
131
|
+
uv sync
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
3. Create a `.env` file based on `env.template`:
|
|
135
|
+
```bash
|
|
136
|
+
cp env.template .env
|
|
137
|
+
# Edit .env with your credentials
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Running Locally
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
uv run server.py
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Testing
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# Run tests
|
|
150
|
+
uv run pytest
|
|
151
|
+
|
|
152
|
+
# Run with coverage
|
|
153
|
+
uv run pytest --cov
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Use Cases
|
|
157
|
+
|
|
158
|
+
- **AI-Assisted Development**: Let AI assistants fetch and analyze Jira issues
|
|
159
|
+
- **Automated Workflows**: Integrate Jira data into automated processes
|
|
160
|
+
- **Context-Aware Coding**: Provide issue context to AI coding assistants
|
|
161
|
+
- **Documentation**: Auto-generate documentation from Jira issues
|
|
162
|
+
|
|
163
|
+
## Roadmap
|
|
164
|
+
|
|
165
|
+
- [ ] Support for creating and updating issues
|
|
166
|
+
- [ ] Advanced search capabilities
|
|
167
|
+
- [ ] Support for Jira workflows and transitions
|
|
168
|
+
- [ ] Enhanced attachment handling (PDFs, images)
|
|
169
|
+
- [ ] Bulk operations support
|
|
170
|
+
|
|
171
|
+
## Contributing
|
|
172
|
+
|
|
173
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
MIT License - see [LICENSE](LICENSE) file for details.
|
|
178
|
+
|
|
179
|
+
## Support
|
|
180
|
+
|
|
181
|
+
- **Issues**: [GitHub Issues](https://github.com/IBM/mcp-jira/issues)
|
|
182
|
+
- **Documentation**: [Full Documentation](https://github.com/IBM/mcp-jira)
|
|
183
|
+
|
|
184
|
+
## Acknowledgments
|
|
185
|
+
|
|
186
|
+
Built with:
|
|
187
|
+
- [FastMCP](https://github.com/jlowin/fastmcp) - Fast MCP server framework
|
|
188
|
+
- [jira-python](https://github.com/pycontribs/jira) - Python Jira library
|
|
189
|
+
- [Model Context Protocol](https://modelcontextprotocol.io/) - MCP specification
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Publishing mcp-jira to PyPI
|
|
2
|
+
|
|
3
|
+
This guide explains how to publish the mcp-jira package to PyPI.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
1. **PyPI Account**
|
|
8
|
+
- Create account at https://pypi.org/account/register/
|
|
9
|
+
- Verify your email address
|
|
10
|
+
|
|
11
|
+
2. **PyPI API Token**
|
|
12
|
+
- Go to https://pypi.org/manage/account/token/
|
|
13
|
+
- Create a new API token with scope "Entire account"
|
|
14
|
+
- Save the token securely (starts with `pypi-`)
|
|
15
|
+
|
|
16
|
+
3. **Install Build Tools**
|
|
17
|
+
```bash
|
|
18
|
+
pip install build twine
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Publishing Steps
|
|
22
|
+
|
|
23
|
+
### 1. Prepare the Release
|
|
24
|
+
|
|
25
|
+
Update version in `pyproject.toml`:
|
|
26
|
+
```toml
|
|
27
|
+
version = "0.1.2" # Increment version
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### 2. Build the Package
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
cd /path/to/mcp-jira
|
|
34
|
+
python -m build
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This creates:
|
|
38
|
+
- `dist/mcp_jira-0.1.2-py3-none-any.whl`
|
|
39
|
+
- `dist/mcp-jira-0.1.2.tar.gz`
|
|
40
|
+
|
|
41
|
+
### 3. Test on TestPyPI (Optional but Recommended)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Upload to TestPyPI
|
|
45
|
+
twine upload --repository testpypi dist/*
|
|
46
|
+
|
|
47
|
+
# Test installation
|
|
48
|
+
pip install --index-url https://test.pypi.org/simple/ mcp-jira
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 4. Upload to PyPI
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
twine upload dist/*
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
When prompted:
|
|
58
|
+
- Username: `__token__`
|
|
59
|
+
- Password: Your PyPI API token (including `pypi-` prefix)
|
|
60
|
+
|
|
61
|
+
### 5. Verify Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Install from PyPI
|
|
65
|
+
pip install mcp-jira
|
|
66
|
+
|
|
67
|
+
# Or with uvx
|
|
68
|
+
uvx mcp-jira
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Using API Token in CI/CD
|
|
72
|
+
|
|
73
|
+
For automated publishing, store the token as a secret:
|
|
74
|
+
|
|
75
|
+
### GitHub Actions
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
name: Publish to PyPI
|
|
79
|
+
|
|
80
|
+
on:
|
|
81
|
+
release:
|
|
82
|
+
types: [published]
|
|
83
|
+
|
|
84
|
+
jobs:
|
|
85
|
+
publish:
|
|
86
|
+
runs-on: ubuntu-latest
|
|
87
|
+
steps:
|
|
88
|
+
- uses: actions/checkout@v3
|
|
89
|
+
|
|
90
|
+
- name: Set up Python
|
|
91
|
+
uses: actions/setup-python@v4
|
|
92
|
+
with:
|
|
93
|
+
python-version: '3.10'
|
|
94
|
+
|
|
95
|
+
- name: Install dependencies
|
|
96
|
+
run: |
|
|
97
|
+
pip install build twine
|
|
98
|
+
|
|
99
|
+
- name: Build package
|
|
100
|
+
run: python -m build
|
|
101
|
+
|
|
102
|
+
- name: Publish to PyPI
|
|
103
|
+
env:
|
|
104
|
+
TWINE_USERNAME: __token__
|
|
105
|
+
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
|
106
|
+
run: twine upload dist/*
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Version Management
|
|
110
|
+
|
|
111
|
+
Follow semantic versioning (MAJOR.MINOR.PATCH):
|
|
112
|
+
|
|
113
|
+
- **MAJOR**: Breaking changes
|
|
114
|
+
- **MINOR**: New features (backward compatible)
|
|
115
|
+
- **PATCH**: Bug fixes
|
|
116
|
+
|
|
117
|
+
Examples:
|
|
118
|
+
- `0.1.1` → `0.1.2` (bug fix)
|
|
119
|
+
- `0.1.2` → `0.2.0` (new feature)
|
|
120
|
+
- `0.2.0` → `1.0.0` (stable release)
|
|
121
|
+
|
|
122
|
+
## Troubleshooting
|
|
123
|
+
|
|
124
|
+
### "File already exists" Error
|
|
125
|
+
|
|
126
|
+
You cannot re-upload the same version. Increment the version number.
|
|
127
|
+
|
|
128
|
+
### Authentication Failed
|
|
129
|
+
|
|
130
|
+
- Ensure username is `__token__`
|
|
131
|
+
- Verify token includes `pypi-` prefix
|
|
132
|
+
- Check token hasn't expired
|
|
133
|
+
|
|
134
|
+
### Package Name Conflict
|
|
135
|
+
|
|
136
|
+
If `mcp-jira` is taken, choose alternative:
|
|
137
|
+
- `mcp-jira-server`
|
|
138
|
+
- `jira-mcp-server`
|
|
139
|
+
- `ibm-mcp-jira`
|
|
140
|
+
|
|
141
|
+
## Post-Publishing
|
|
142
|
+
|
|
143
|
+
1. **Create GitHub Release**
|
|
144
|
+
- Tag: `v0.1.2`
|
|
145
|
+
- Title: `Release 0.1.2`
|
|
146
|
+
- Description: Changelog
|
|
147
|
+
|
|
148
|
+
2. **Update Documentation**
|
|
149
|
+
- Update README with new version
|
|
150
|
+
- Update bob-marketplace-registry
|
|
151
|
+
|
|
152
|
+
3. **Announce**
|
|
153
|
+
- Internal Slack channels
|
|
154
|
+
- GitHub discussions
|
|
155
|
+
- Documentation sites
|
|
156
|
+
|
|
157
|
+
## Resources
|
|
158
|
+
|
|
159
|
+
- [PyPI Help](https://pypi.org/help/)
|
|
160
|
+
- [Python Packaging Guide](https://packaging.python.org/)
|
|
161
|
+
- [Twine Documentation](https://twine.readthedocs.io/)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# MCP Server for JIRA
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🎫 **Get Issue Information**: Retrieve detailed issue descriptions and comments
|
|
8
|
+
- 📎 **Download Attachments**: Access and download issue attachments
|
|
9
|
+
- 🔒 **Secure Authentication**: Uses Jira API tokens for secure access
|
|
10
|
+
- 🌐 **Universal Compatibility**: Works with Jira Cloud, Server, and Data Center
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
### Using uvx (Recommended)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uvx mcp-jira
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Using pip
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install mcp-jira
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Prerequisites
|
|
27
|
+
|
|
28
|
+
- Python 3.10 or higher
|
|
29
|
+
- Jira account with API access
|
|
30
|
+
- Jira API token ([How to create](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/))
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
### Environment Variables
|
|
35
|
+
|
|
36
|
+
The server requires three environment variables:
|
|
37
|
+
|
|
38
|
+
- `JIRA_URL`: Your Jira instance URL (e.g., `https://your-company.atlassian.net/`)
|
|
39
|
+
- `JIRA_EMAIL`: Your Jira account email
|
|
40
|
+
- `JIRA_TOKEN`: Your Jira API token
|
|
41
|
+
|
|
42
|
+
### MCP Client Configuration
|
|
43
|
+
|
|
44
|
+
Add to your MCP client configuration (e.g., Claude Desktop, Bob):
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"jira": {
|
|
50
|
+
"command": "uvx",
|
|
51
|
+
"args": ["mcp-jira"],
|
|
52
|
+
"env": {
|
|
53
|
+
"JIRA_URL": "https://your-jira-instance.com/",
|
|
54
|
+
"JIRA_EMAIL": "your-email@company.com",
|
|
55
|
+
"JIRA_TOKEN": "your-api-token"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Available Tools
|
|
63
|
+
|
|
64
|
+
### `get_jira_ticket_info`
|
|
65
|
+
|
|
66
|
+
Retrieves comprehensive information about a Jira issue.
|
|
67
|
+
|
|
68
|
+
**Parameters:**
|
|
69
|
+
- `issue_key` (string): The Jira issue key (e.g., "PROJ-123")
|
|
70
|
+
|
|
71
|
+
**Returns:**
|
|
72
|
+
- Issue description
|
|
73
|
+
- All comments with author and timestamp
|
|
74
|
+
- Comment bodies
|
|
75
|
+
|
|
76
|
+
**Example:**
|
|
77
|
+
```python
|
|
78
|
+
get_jira_ticket_info("PROJ-123")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### `get_jira_ticket_attachments`
|
|
82
|
+
|
|
83
|
+
Downloads and retrieves attachments from a Jira issue.
|
|
84
|
+
|
|
85
|
+
**Parameters:**
|
|
86
|
+
- `issue_key` (string): The Jira issue key (e.g., "PROJ-123")
|
|
87
|
+
|
|
88
|
+
**Returns:**
|
|
89
|
+
- List of attachments with filenames and content
|
|
90
|
+
|
|
91
|
+
**Example:**
|
|
92
|
+
```python
|
|
93
|
+
get_jira_ticket_attachments("PROJ-456")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
### Setup
|
|
99
|
+
|
|
100
|
+
1. Clone the repository
|
|
101
|
+
2. Install dependencies:
|
|
102
|
+
```bash
|
|
103
|
+
uv sync
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
3. Create a `.env` file based on `env.template`:
|
|
107
|
+
```bash
|
|
108
|
+
cp env.template .env
|
|
109
|
+
# Edit .env with your credentials
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Running Locally
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
uv run server.py
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Testing
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
# Run tests
|
|
122
|
+
uv run pytest
|
|
123
|
+
|
|
124
|
+
# Run with coverage
|
|
125
|
+
uv run pytest --cov
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Use Cases
|
|
129
|
+
|
|
130
|
+
- **AI-Assisted Development**: Let AI assistants fetch and analyze Jira issues
|
|
131
|
+
- **Automated Workflows**: Integrate Jira data into automated processes
|
|
132
|
+
- **Context-Aware Coding**: Provide issue context to AI coding assistants
|
|
133
|
+
- **Documentation**: Auto-generate documentation from Jira issues
|
|
134
|
+
|
|
135
|
+
## Roadmap
|
|
136
|
+
|
|
137
|
+
- [ ] Support for creating and updating issues
|
|
138
|
+
- [ ] Advanced search capabilities
|
|
139
|
+
- [ ] Support for Jira workflows and transitions
|
|
140
|
+
- [ ] Enhanced attachment handling (PDFs, images)
|
|
141
|
+
- [ ] Bulk operations support
|
|
142
|
+
|
|
143
|
+
## Contributing
|
|
144
|
+
|
|
145
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT License - see [LICENSE](LICENSE) file for details.
|
|
150
|
+
|
|
151
|
+
## Support
|
|
152
|
+
|
|
153
|
+
- **Issues**: [GitHub Issues](https://github.com/IBM/mcp-jira/issues)
|
|
154
|
+
- **Documentation**: [Full Documentation](https://github.com/IBM/mcp-jira)
|
|
155
|
+
|
|
156
|
+
## Acknowledgments
|
|
157
|
+
|
|
158
|
+
Built with:
|
|
159
|
+
- [FastMCP](https://github.com/jlowin/fastmcp) - Fast MCP server framework
|
|
160
|
+
- [jira-python](https://github.com/pycontribs/jira) - Python Jira library
|
|
161
|
+
- [Model Context Protocol](https://modelcontextprotocol.io/) - MCP specification
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jira-mcp-tools"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Model Context Protocol server for Jira integration"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = {text = "MIT"}
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "IBM", email = "opensource@ibm.com"}
|
|
10
|
+
]
|
|
11
|
+
keywords = ["mcp", "jira", "model-context-protocol", "ai", "llm"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"fastmcp>=2.6.1",
|
|
24
|
+
"click>=8.0.0",
|
|
25
|
+
"ruff>=0.11.0",
|
|
26
|
+
"python-dotenv>=1.0.0",
|
|
27
|
+
"requests>=2.32.5",
|
|
28
|
+
"jira>=3.4.0"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/IBM/jira-mcp-tools"
|
|
33
|
+
Repository = "https://github.com/IBM/jira-mcp-tools"
|
|
34
|
+
Issues = "https://github.com/IBM/jira-mcp-tools/issues"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
jira-mcp-tools = "mcp-jira.server:main"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/mcp-jira"]
|
|
@@ -0,0 +1,745 @@
|
|
|
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
|
+
class JiraContext:
|
|
466
|
+
def __init__(self, connector: JiraApiClient):
|
|
467
|
+
self.connector = connector
|
|
468
|
+
|
|
469
|
+
@asynccontextmanager
|
|
470
|
+
async def server_lifespan(server: FastMCP) -> AsyncIterator[JiraContext]:
|
|
471
|
+
"""Manage application lifecycle for Jira Api Client."""
|
|
472
|
+
jira_url = os.getenv("JIRA_URL")
|
|
473
|
+
jira_email = os.getenv("JIRA_EMAIL")
|
|
474
|
+
jira_token = os.getenv("JIRA_TOKEN")
|
|
475
|
+
connector = JiraApiClient(jira_url, jira_email, jira_token)
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
yield JiraContext(connector)
|
|
479
|
+
finally:
|
|
480
|
+
pass
|
|
481
|
+
|
|
482
|
+
mcp = FastMCP(name="JIRA", lifespan=server_lifespan)
|
|
483
|
+
|
|
484
|
+
@mcp.tool()
|
|
485
|
+
async def get_jira_ticket_info(issue_key: str, ctx: Context) -> str:
|
|
486
|
+
"""Get detailed information about a JIRA issue."""
|
|
487
|
+
if not issue_key:
|
|
488
|
+
return "Error: Missing required parameter 'issue_key'"
|
|
489
|
+
|
|
490
|
+
connector = ctx.request_context.lifespan_context.connector
|
|
491
|
+
try:
|
|
492
|
+
return await connector.get_jira_ticket_info(issue_key)
|
|
493
|
+
except ValueError as e:
|
|
494
|
+
return f"Error: {str(e)}"
|
|
495
|
+
|
|
496
|
+
@mcp.tool()
|
|
497
|
+
async def get_jira_ticket_attachments(issue_key: str, ctx: Context) -> str:
|
|
498
|
+
"""Get the attachments of a JIRA issue."""
|
|
499
|
+
if not issue_key:
|
|
500
|
+
return "Error: Missing required parameter 'issue_key'"
|
|
501
|
+
|
|
502
|
+
connector = ctx.request_context.lifespan_context.connector
|
|
503
|
+
try:
|
|
504
|
+
return await connector.get_jira_ticket_attachments(issue_key)
|
|
505
|
+
except ValueError as e:
|
|
506
|
+
return f"Error: {str(e)}"
|
|
507
|
+
|
|
508
|
+
@mcp.tool()
|
|
509
|
+
async def search_issues(jql: str, max_results: int = 50, fields: Optional[str] = None, ctx: Context = None) -> str:
|
|
510
|
+
"""Search for Jira issues using JQL (Jira Query Language).
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
jql: JQL query string (e.g., 'project = PROJ AND status = "In Progress"')
|
|
514
|
+
max_results: Maximum number of results to return (default: 50)
|
|
515
|
+
fields: Comma-separated list of fields to return (default: summary,status,assignee,priority,created,updated,duedate,labels,components)
|
|
516
|
+
|
|
517
|
+
Examples:
|
|
518
|
+
- 'project = MYPROJ'
|
|
519
|
+
- 'assignee = currentUser() AND status != Done'
|
|
520
|
+
- 'updated >= -7d ORDER BY updated DESC'
|
|
521
|
+
- 'labels = urgent AND duedate < now()'
|
|
522
|
+
"""
|
|
523
|
+
if not jql:
|
|
524
|
+
return "Error: Missing required parameter 'jql'"
|
|
525
|
+
|
|
526
|
+
connector = ctx.request_context.lifespan_context.connector
|
|
527
|
+
try:
|
|
528
|
+
return await connector.search_issues(jql, max_results, fields)
|
|
529
|
+
except ValueError as e:
|
|
530
|
+
return f"Error: {str(e)}"
|
|
531
|
+
|
|
532
|
+
@mcp.tool()
|
|
533
|
+
async def get_epic_details(epic_key: str, ctx: Context) -> str:
|
|
534
|
+
"""Get detailed information about an epic including all child issues and progress metrics.
|
|
535
|
+
|
|
536
|
+
Args:
|
|
537
|
+
epic_key: The epic issue key (e.g., 'PROJ-123')
|
|
538
|
+
|
|
539
|
+
Returns:
|
|
540
|
+
Epic summary, status, description, child issues, completion percentage, and story points
|
|
541
|
+
"""
|
|
542
|
+
if not epic_key:
|
|
543
|
+
return "Error: Missing required parameter 'epic_key'"
|
|
544
|
+
|
|
545
|
+
connector = ctx.request_context.lifespan_context.connector
|
|
546
|
+
try:
|
|
547
|
+
return await connector.get_epic_details(epic_key)
|
|
548
|
+
except ValueError as e:
|
|
549
|
+
return f"Error: {str(e)}"
|
|
550
|
+
|
|
551
|
+
@mcp.tool()
|
|
552
|
+
async def get_sprint_info(board_id: int, sprint_id: Optional[int] = None, ctx: Context = None) -> str:
|
|
553
|
+
"""Get information about sprints on a Jira board.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
board_id: The Jira board ID
|
|
557
|
+
sprint_id: Optional specific sprint ID. If not provided, returns all sprints for the board
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
Sprint details including name, state, dates, and issues (if sprint_id provided)
|
|
561
|
+
or list of all sprints (if sprint_id not provided)
|
|
562
|
+
"""
|
|
563
|
+
if not board_id:
|
|
564
|
+
return "Error: Missing required parameter 'board_id'"
|
|
565
|
+
|
|
566
|
+
connector = ctx.request_context.lifespan_context.connector
|
|
567
|
+
try:
|
|
568
|
+
return await connector.get_sprint_info(board_id, sprint_id)
|
|
569
|
+
except ValueError as e:
|
|
570
|
+
return f"Error: {str(e)}"
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@mcp.tool()
|
|
574
|
+
async def get_issue_history(issue_key: str, ctx: Context) -> str:
|
|
575
|
+
"""Get the change history of a Jira issue.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
issue_key: The issue key (e.g., 'PROJ-123')
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
Complete change history including status transitions, assignee changes, and field updates
|
|
582
|
+
"""
|
|
583
|
+
if not issue_key:
|
|
584
|
+
return "Error: Missing required parameter 'issue_key'"
|
|
585
|
+
|
|
586
|
+
connector = ctx.request_context.lifespan_context.connector
|
|
587
|
+
try:
|
|
588
|
+
return await connector.get_issue_history(issue_key)
|
|
589
|
+
except ValueError as e:
|
|
590
|
+
return f"Error: {str(e)}"
|
|
591
|
+
|
|
592
|
+
@mcp.tool()
|
|
593
|
+
async def get_project_releases(project_key: str, ctx: Context) -> str:
|
|
594
|
+
"""Get all releases/versions for a project.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
project_key: The project key (e.g., 'PROJ')
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
List of all releases with their status, dates, and associated issues
|
|
601
|
+
"""
|
|
602
|
+
if not project_key:
|
|
603
|
+
return "Error: Missing required parameter 'project_key'"
|
|
604
|
+
|
|
605
|
+
connector = ctx.request_context.lifespan_context.connector
|
|
606
|
+
try:
|
|
607
|
+
return await connector.get_project_releases(project_key)
|
|
608
|
+
except ValueError as e:
|
|
609
|
+
return f"Error: {str(e)}"
|
|
610
|
+
|
|
611
|
+
@mcp.tool()
|
|
612
|
+
async def get_issue_links(issue_key: str, ctx: Context) -> str:
|
|
613
|
+
"""Get all linked issues for a given issue.
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
issue_key: The issue key (e.g., 'PROJ-123')
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
All issue links including blocks/blocked by, relates to, and other link types
|
|
620
|
+
"""
|
|
621
|
+
if not issue_key:
|
|
622
|
+
return "Error: Missing required parameter 'issue_key'"
|
|
623
|
+
|
|
624
|
+
connector = ctx.request_context.lifespan_context.connector
|
|
625
|
+
try:
|
|
626
|
+
return await connector.get_issue_links(issue_key)
|
|
627
|
+
except ValueError as e:
|
|
628
|
+
return f"Error: {str(e)}"
|
|
629
|
+
|
|
630
|
+
@mcp.tool()
|
|
631
|
+
async def add_comment(issue_key: str, comment_text: str, ctx: Context) -> str:
|
|
632
|
+
"""Add a comment to a Jira issue.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
issue_key: The issue key (e.g., 'PROJ-123')
|
|
636
|
+
comment_text: The comment text to add
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
Comment details including ID, author, and timestamp
|
|
640
|
+
"""
|
|
641
|
+
if not issue_key or not comment_text:
|
|
642
|
+
return "Error: Missing required parameters 'issue_key' and 'comment_text'"
|
|
643
|
+
|
|
644
|
+
connector = ctx.request_context.lifespan_context.connector
|
|
645
|
+
try:
|
|
646
|
+
return await connector.add_comment(issue_key, comment_text)
|
|
647
|
+
except ValueError as e:
|
|
648
|
+
return f"Error: {str(e)}"
|
|
649
|
+
|
|
650
|
+
@mcp.tool()
|
|
651
|
+
async def update_issue(issue_key: str, fields: dict, ctx: Context) -> str:
|
|
652
|
+
"""Update fields of a Jira issue.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
issue_key: The issue key (e.g., 'PROJ-123')
|
|
656
|
+
fields: Dictionary of fields to update (e.g., {'summary': 'New title', 'description': 'New desc'})
|
|
657
|
+
|
|
658
|
+
Returns:
|
|
659
|
+
Updated issue details
|
|
660
|
+
|
|
661
|
+
Examples:
|
|
662
|
+
- Update summary: {'summary': 'New title'}
|
|
663
|
+
- Update description: {'description': 'New description'}
|
|
664
|
+
- Update priority: {'priority': {'name': 'High'}}
|
|
665
|
+
- Update labels: {'labels': ['bug', 'urgent']}
|
|
666
|
+
"""
|
|
667
|
+
if not issue_key or not fields:
|
|
668
|
+
return "Error: Missing required parameters 'issue_key' and 'fields'"
|
|
669
|
+
|
|
670
|
+
connector = ctx.request_context.lifespan_context.connector
|
|
671
|
+
try:
|
|
672
|
+
return await connector.update_issue(issue_key, fields)
|
|
673
|
+
except ValueError as e:
|
|
674
|
+
return f"Error: {str(e)}"
|
|
675
|
+
|
|
676
|
+
@mcp.tool()
|
|
677
|
+
async def create_issue(project_key: str, summary: str, description: str, issue_type: str = "Task", ctx: Context = None) -> str:
|
|
678
|
+
"""Create a new Jira issue.
|
|
679
|
+
|
|
680
|
+
Args:
|
|
681
|
+
project_key: The project key (e.g., 'PROJ')
|
|
682
|
+
summary: Issue title/summary
|
|
683
|
+
description: Issue description
|
|
684
|
+
issue_type: Type of issue (default: 'Task', options: 'Bug', 'Story', 'Epic', etc.)
|
|
685
|
+
|
|
686
|
+
Returns:
|
|
687
|
+
Created issue details including key and URL
|
|
688
|
+
"""
|
|
689
|
+
if not project_key or not summary or not description:
|
|
690
|
+
return "Error: Missing required parameters 'project_key', 'summary', and 'description'"
|
|
691
|
+
|
|
692
|
+
connector = ctx.request_context.lifespan_context.connector
|
|
693
|
+
try:
|
|
694
|
+
return await connector.create_issue(project_key, summary, description, issue_type)
|
|
695
|
+
except ValueError as e:
|
|
696
|
+
return f"Error: {str(e)}"
|
|
697
|
+
|
|
698
|
+
@mcp.tool()
|
|
699
|
+
async def transition_issue(issue_key: str, transition_name: str, ctx: Context) -> str:
|
|
700
|
+
"""Transition a Jira issue to a new status.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
issue_key: The issue key (e.g., 'PROJ-123')
|
|
704
|
+
transition_name: Name of the transition (e.g., 'In Progress', 'Done', 'To Do')
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
Transition details including old and new status
|
|
708
|
+
|
|
709
|
+
Note: Available transitions depend on the workflow. If transition fails,
|
|
710
|
+
the error will list available transitions for the issue.
|
|
711
|
+
"""
|
|
712
|
+
if not issue_key or not transition_name:
|
|
713
|
+
return "Error: Missing required parameters 'issue_key' and 'transition_name'"
|
|
714
|
+
|
|
715
|
+
connector = ctx.request_context.lifespan_context.connector
|
|
716
|
+
try:
|
|
717
|
+
return await connector.transition_issue(issue_key, transition_name)
|
|
718
|
+
except ValueError as e:
|
|
719
|
+
return f"Error: {str(e)}"
|
|
720
|
+
|
|
721
|
+
@mcp.tool()
|
|
722
|
+
async def assign_issue(issue_key: str, assignee: str, ctx: Context) -> str:
|
|
723
|
+
"""Assign a Jira issue to a user.
|
|
724
|
+
|
|
725
|
+
Args:
|
|
726
|
+
issue_key: The issue key (e.g., 'PROJ-123')
|
|
727
|
+
assignee: Username or email of assignee (use 'none' or 'unassigned' to unassign)
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
Assignment details including old and new assignee
|
|
731
|
+
"""
|
|
732
|
+
if not issue_key or not assignee:
|
|
733
|
+
return "Error: Missing required parameters 'issue_key' and 'assignee'"
|
|
734
|
+
|
|
735
|
+
connector = ctx.request_context.lifespan_context.connector
|
|
736
|
+
try:
|
|
737
|
+
return await connector.assign_issue(issue_key, assignee)
|
|
738
|
+
except ValueError as e:
|
|
739
|
+
return f"Error: {str(e)}"
|
|
740
|
+
|
|
741
|
+
def main():
|
|
742
|
+
mcp.run()
|
|
743
|
+
|
|
744
|
+
if __name__ == "__main__":
|
|
745
|
+
main()
|