# Directory Structure
```
├── .env.example
├── .gitignore
├── dist
│ ├── __tests__
│ │ ├── server.test.d.ts
│ │ └── server.test.js
│ ├── index.d.ts
│ ├── index.js
│ ├── schemas.d.ts
│ ├── schemas.js
│ ├── server.d.ts
│ ├── server.js
│ ├── types.d.ts
│ └── types.js
├── LICENSE
├── node_modules
│ ├── .package-lock.json
│ ├── @modelcontextprotocol
│ │ └── sdk
│ │ ├── LICENSE
│ │ ├── package.json
│ │ └── README.md
│ ├── bytes
│ │ ├── History.md
│ │ ├── index.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ └── Readme.md
│ ├── content-type
│ │ ├── HISTORY.md
│ │ ├── index.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ └── README.md
│ ├── depd
│ │ ├── History.md
│ │ ├── index.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ └── Readme.md
│ ├── http-errors
│ │ ├── HISTORY.md
│ │ ├── index.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ └── README.md
│ ├── iconv-lite
│ │ ├── .github
│ │ │ └── dependabot.yml
│ │ ├── Changelog.md
│ │ ├── encodings
│ │ │ ├── dbcs-codec.js
│ │ │ ├── dbcs-data.js
│ │ │ ├── index.js
│ │ │ ├── internal.js
│ │ │ ├── sbcs-codec.js
│ │ │ ├── sbcs-data-generated.js
│ │ │ ├── sbcs-data.js
│ │ │ ├── tables
│ │ │ │ ├── big5-added.json
│ │ │ │ ├── cp936.json
│ │ │ │ ├── cp949.json
│ │ │ │ ├── cp950.json
│ │ │ │ ├── eucjp.json
│ │ │ │ ├── gb18030-ranges.json
│ │ │ │ ├── gbk-added.json
│ │ │ │ └── shiftjis.json
│ │ │ ├── utf16.js
│ │ │ ├── utf32.js
│ │ │ └── utf7.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ └── README.md
│ ├── inherits
│ │ ├── inherits_browser.js
│ │ ├── inherits.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ └── README.md
│ ├── raw-body
│ │ ├── HISTORY.md
│ │ ├── index.d.ts
│ │ ├── index.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ ├── README.md
│ │ └── SECURITY.md
│ ├── safer-buffer
│ │ ├── dangerous.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ ├── Porting-Buffer.md
│ │ ├── Readme.md
│ │ ├── safer.js
│ │ └── tests.js
│ ├── setprototypeof
│ │ ├── index.d.ts
│ │ ├── index.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ ├── README.md
│ │ └── test
│ │ └── index.js
│ ├── statuses
│ │ ├── codes.json
│ │ ├── HISTORY.md
│ │ ├── index.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ └── README.md
│ ├── toidentifier
│ │ ├── HISTORY.md
│ │ ├── index.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ └── README.md
│ ├── unpipe
│ │ ├── HISTORY.md
│ │ ├── index.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ └── README.md
│ └── zod
│ ├── index.d.ts
│ ├── LICENSE
│ ├── package.json
│ └── README.md
├── package-lock.json
├── pyproject.toml
├── QUICKSTART.md
├── README.md
├── SECURITY.md
├── setup.py
├── src
│ └── mcp_jira
│ ├── __init__.py
│ ├── __main__.py
│ ├── config.py
│ ├── jira_client.py
│ ├── mcp_protocol.py
│ ├── simple_mcp_server.py
│ └── types.py
└── tests
├── __init__.py
├── conftest.py
├── test_jira_client.py
└── test_simple_mcp_server.py
```
# Files
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
```
1 | # Jira Configuration
2 | JIRA_URL=https://your-domain.atlassian.net
3 | [email protected]
4 | JIRA_API_TOKEN=your_api_token
5 | PROJECT_KEY=PROJ
6 | DEFAULT_BOARD_ID=123
7 |
8 | # Application Settings
9 | DEBUG_MODE=false
10 | LOG_LEVEL=INFO
11 |
12 | # Optional: Sprint Defaults
13 | DEFAULT_SPRINT_LENGTH=14
14 | STORY_POINTS_FIELD=customfield_10026
15 | MAX_SPRINT_ITEMS=50
16 |
17 | # Performance Settings
18 | JIRA_REQUEST_TIMEOUT=30
19 | CACHE_TTL=300
20 | MAX_CONCURRENT_REQUESTS=10
21 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | ### Python ###
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | *.so
6 | .Python
7 | env/
8 | build/
9 | develop-eggs/
10 | dist/
11 | downloads/
12 | eggs/
13 | .eggs/
14 | lib/
15 | lib64/
16 | parts/
17 | sdist/
18 | var/
19 | wheels/
20 | *.egg-info/
21 | .installed.cfg
22 | *.egg
23 |
24 | # Virtual Environment
25 | venv/
26 | ENV/
27 |
28 | # IDE
29 | .idea/
30 | .vscode/
31 | *.swp
32 | *.swo
33 |
34 | # Environment variables
35 | .env
36 | .env.local
37 |
38 | # Logs
39 | *.log
40 |
41 | # Unit test / coverage reports
42 | htmlcov/
43 | .tox/
44 | .nox/
45 | .coverage
46 | .coverage.*
47 | .cache
48 | nosetests.xml
49 | coverage.xml
50 | *.cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # MCP Jira Integration
2 |
3 | A simple Model Context Protocol (MCP) server for Jira that allows LLMs to act as project managers and personal assistants for teams using Jira.
4 |
5 | ## Features
6 |
7 | ### Core MCP Tools
8 | - **create_issue** - Create new Jira issues with proper formatting
9 | - **search_issues** - Search issues using JQL with smart formatting
10 | - **get_sprint_status** - Get comprehensive sprint progress reports
11 | - **get_team_workload** - Analyze team member workloads and capacity
12 | - **generate_standup_report** - Generate daily standup reports automatically
13 |
14 | ### Project Management Capabilities
15 | - Sprint progress tracking with visual indicators
16 | - Team workload analysis and capacity planning
17 | - Automated daily standup report generation
18 | - Issue creation with proper prioritization
19 | - Smart search and filtering of issues
20 |
21 | ## Requirements
22 |
23 | - Python 3.8 or higher
24 | - Jira account with API token
25 | - MCP-compatible client (like Claude Desktop)
26 |
27 | ## Quick Setup
28 |
29 | 1. **Clone and install**:
30 | ```bash
31 | cd mcp-jira
32 | pip install -e .
33 | ```
34 |
35 | 2. **Configure Jira credentials** in `.env`:
36 | ```env
37 | JIRA_URL=https://your-domain.atlassian.net
38 | [email protected]
39 | JIRA_API_TOKEN=your_api_token
40 | PROJECT_KEY=PROJ
41 | DEFAULT_BOARD_ID=123
42 | ```
43 |
44 | 3. **Run the MCP server**:
45 | ```bash
46 | python -m mcp_jira.simple_mcp_server
47 | ```
48 |
49 | ## Usage Examples
50 |
51 | ### Creating Issues
52 | "Create a high priority bug for the login system not working properly"
53 | - Auto-assigns proper issue type, priority, and formatting
54 |
55 | ### Sprint Management
56 | "What's our current sprint status?"
57 | - Gets comprehensive progress report with metrics and visual indicators
58 |
59 | ### Team Management
60 | "Show me the team workload for john.doe, jane.smith, mike.wilson"
61 | - Analyzes capacity and provides workload distribution
62 |
63 | ### Daily Standups
64 | "Generate today's standup report"
65 | - Creates formatted report with completed, in-progress, and blocked items
66 |
67 | ## MCP Integration
68 |
69 | ### With Claude Desktop
70 | Add to your `claude_desktop_config.json`:
71 | ```json
72 | {
73 | "mcpServers": {
74 | "mcp-jira": {
75 | "command": "python",
76 | "args": ["-m", "mcp_jira.simple_mcp_server"],
77 | "cwd": "/path/to/mcp-jira"
78 | }
79 | }
80 | }
81 | ```
82 |
83 | ### With Other MCP Clients
84 | The server follows the standard MCP protocol and works with any MCP-compatible client.
85 |
86 | ## Configuration
87 |
88 | ### Required Environment Variables
89 | - `JIRA_URL` - Your Jira instance URL
90 | - `JIRA_USERNAME` - Your Jira username/email
91 | - `JIRA_API_TOKEN` - Your Jira API token
92 | - `PROJECT_KEY` - Default project key for operations
93 |
94 | ### Optional Settings
95 | - `DEFAULT_BOARD_ID` - Default board for sprint operations
96 | - `DEBUG_MODE` - Enable debug logging (default: false)
97 | - `LOG_LEVEL` - Logging level (default: INFO)
98 |
99 | ## Getting Jira API Token
100 |
101 | 1. Go to [Atlassian Account Settings](https://id.atlassian.com/manage-profile/security/api-tokens)
102 | 2. Click "Create API token"
103 | 3. Give it a name and copy the token
104 | 4. Use your email as username and the token as password
105 |
106 | ## Architecture
107 |
108 | This implementation prioritizes simplicity:
109 | - **Single MCP server file** - All tools in one place
110 | - **Standard MCP protocol** - Uses official MCP SDK
111 | - **Rich formatting** - Provides beautiful, readable reports
112 | - **Error handling** - Graceful handling of Jira API issues
113 | - **Async support** - Fast and responsive operations
114 |
115 | ## Troubleshooting
116 |
117 | ### Common Issues
118 |
119 | 1. **"No active sprint found"**
120 | - Make sure your board has an active sprint
121 | - Check that `DEFAULT_BOARD_ID` is set correctly
122 |
123 | 2. **Authentication errors**
124 | - Verify your API token is correct
125 | - Check that your username is your email address
126 |
127 | 3. **Permission errors**
128 | - Ensure your Jira user has appropriate project permissions
129 | - Check that the project key exists and you have access
130 |
131 | ### Debug Mode
132 | Set `DEBUG_MODE=true` in your `.env` file for detailed logging.
133 |
134 | ## Contributing
135 |
136 | 1. Fork the repository
137 | 2. Make your changes
138 | 3. Test with your Jira instance
139 | 4. Submit a pull request
140 |
141 | ## License
142 |
143 | MIT License - see LICENSE file
144 |
```
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
```markdown
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | We release patches for security vulnerabilities. Which versions are eligible for receiving such patches depends on the CVSS v3.0 Rating:
6 |
7 | | Version | Supported |
8 | | ------- | ------------------ |
9 | | 1.x.x | :white_check_mark: |
10 |
11 | ## Reporting a Vulnerability
12 |
13 | Please report (suspected) security vulnerabilities to [email protected]. You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days.
14 |
15 | ## Security Measures
16 |
17 | 1. **Authentication**: All JIRA API calls require proper authentication using API tokens
18 | 2. **Data Protection**: Sensitive data like API tokens should be provided via environment variables
19 | 3. **Input Validation**: All inputs are validated before being used in JIRA API calls
20 | 4. **Error Handling**: Errors are caught and handled appropriately without exposing sensitive information
```
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/src/mcp_jira/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """Model Context Protocol server for Jira with Scrum Master capabilities"""
2 |
3 | from importlib.metadata import version
4 |
5 | __version__ = version("mcp-jira")
6 |
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [project]
2 | name = "mcp-jira"
3 | version = "0.1.0"
4 | description = "Model Context Protocol server for Jira with Scrum Master capabilities"
5 | authors = [{name = "Warzuponus"}]
6 | dependencies = [
7 | "mcp>=1.0.0",
8 | "jira>=3.5.1",
9 | "python-dotenv>=0.19.0",
10 | "pydantic>=2.0.0",
11 | "pydantic-settings>=2.0.0",
12 | "aiohttp>=3.8.0"
13 | ]
14 |
15 | [build-system]
16 | requires = ["hatchling"]
17 | build-backend = "hatchling.build"
18 |
19 | [tool.pytest.ini_options]
20 | testpaths = ["tests"]
21 |
```
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Setup script for mcp-jira.
4 | """
5 |
6 | from setuptools import setup, find_packages
7 |
8 | with open("README.md", "r", encoding="utf-8") as fh:
9 | long_description = fh.read()
10 |
11 | setup(
12 | name="mcp-jira",
13 | version="0.1.0",
14 | author="Warzuponus",
15 | description="Model Context Protocol server for Jira with project management capabilities",
16 | long_description=long_description,
17 | long_description_content_type="text/markdown",
18 | url="https://github.com/warzuponus/mcp-jira",
19 | packages=find_packages(where="src"),
20 | package_dir={"": "src"},
21 | classifiers=[
22 | "Development Status :: 3 - Alpha",
23 | "Intended Audience :: Developers",
24 | "License :: OSI Approved :: MIT License",
25 | "Operating System :: OS Independent",
26 | "Programming Language :: Python :: 3",
27 | "Programming Language :: Python :: 3.8",
28 | "Programming Language :: Python :: 3.9",
29 | "Programming Language :: Python :: 3.10",
30 | "Programming Language :: Python :: 3.11",
31 | "Programming Language :: Python :: 3.12",
32 | ],
33 | python_requires=">=3.8",
34 | install_requires=[
35 | "mcp>=1.0.0",
36 | "jira>=3.5.1",
37 | "python-dotenv>=0.19.0",
38 | "pydantic>=2.0.0",
39 | "pydantic-settings>=2.0.0",
40 | "aiohttp>=3.8.0",
41 | ],
42 | entry_points={
43 | "console_scripts": [
44 | "mcp-jira=mcp_jira.simple_mcp_server:main",
45 | ],
46 | },
47 | include_package_data=True,
48 | zip_safe=False,
49 | )
```
--------------------------------------------------------------------------------
/src/mcp_jira/__main__.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Main entry point for mcp-jira.
4 | Allows running with `python -m mcp_jira`.
5 | """
6 |
7 | import asyncio
8 | import sys
9 | import logging
10 | from pathlib import Path
11 |
12 | from .simple_mcp_server import main
13 | from .config import get_settings, initialize_logging
14 |
15 | def setup_logging():
16 | """Set up logging configuration."""
17 | try:
18 | settings = get_settings()
19 | initialize_logging(settings)
20 | except Exception as e:
21 | # Fallback logging if config fails
22 | logging.basicConfig(
23 | level=logging.INFO,
24 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
25 | )
26 | logging.getLogger(__name__).warning(f"Failed to load settings: {e}")
27 |
28 | def check_env_file():
29 | """Check if .env file exists and provide helpful guidance."""
30 | env_path = Path(".env")
31 | if not env_path.exists():
32 | print("⚠️ No .env file found!")
33 | print("Please create a .env file with your Jira configuration:")
34 | print("")
35 | print("JIRA_URL=https://your-domain.atlassian.net")
36 | print("[email protected]")
37 | print("JIRA_API_TOKEN=your_api_token")
38 | print("PROJECT_KEY=PROJ")
39 | print("DEFAULT_BOARD_ID=123")
40 | print("")
41 | print("You can copy .env.example to .env and edit it with your values.")
42 | return False
43 | return True
44 |
45 | if __name__ == "__main__":
46 | print("🚀 Starting MCP Jira Server...")
47 |
48 | setup_logging()
49 | logger = logging.getLogger(__name__)
50 |
51 | if not check_env_file():
52 | sys.exit(1)
53 |
54 | try:
55 | logger.info("Initializing MCP Jira server...")
56 | asyncio.run(main())
57 | except KeyboardInterrupt:
58 | logger.info("Server stopped by user")
59 | except Exception as e:
60 | logger.exception(f"Server failed to start: {e}")
61 | print(f"❌ Error: {e}")
62 | sys.exit(1)
```
--------------------------------------------------------------------------------
/QUICKSTART.md:
--------------------------------------------------------------------------------
```markdown
1 | # Quick Start Guide
2 |
3 | Get your MCP Jira server running in 5 minutes!
4 |
5 | ## Prerequisites
6 |
7 | - Python 3.8+
8 | - Jira account with API access
9 | - Claude Desktop or another MCP client
10 |
11 | ## Step 1: Install
12 |
13 | ```bash
14 | cd mcp-jira
15 | pip install -e .
16 | ```
17 |
18 | ## Step 2: Configure
19 |
20 | Create a `.env` file:
21 |
22 | ```bash
23 | # Copy the example
24 | cp .env.example .env
25 |
26 | # Edit with your details
27 | nano .env
28 | ```
29 |
30 | Required settings:
31 | ```env
32 | JIRA_URL=https://your-company.atlassian.net
33 | [email protected]
34 | JIRA_API_TOKEN=your_api_token_here
35 | PROJECT_KEY=PROJ
36 | ```
37 |
38 | ## Step 3: Get Jira API Token
39 |
40 | 1. Go to https://id.atlassian.com/manage-profile/security/api-tokens
41 | 2. Click "Create API token"
42 | 3. Copy the token to your `.env` file
43 |
44 | ## Step 4: Test the Server
45 |
46 | ```bash
47 | python -m mcp_jira
48 | ```
49 |
50 | You should see: `🚀 Starting MCP Jira Server...`
51 |
52 | ## Step 5: Connect to Claude Desktop
53 |
54 | Add to your `claude_desktop_config.json`:
55 |
56 | ```json
57 | {
58 | "mcpServers": {
59 | "mcp-jira": {
60 | "command": "python",
61 | "args": ["-m", "mcp_jira"],
62 | "cwd": "/path/to/mcp-jira"
63 | }
64 | }
65 | }
66 | ```
67 |
68 | ## Step 6: Try It Out!
69 |
70 | In Claude Desktop, try:
71 |
72 | - "Create a high priority bug for login issues"
73 | - "What's our current sprint status?"
74 | - "Show me all issues assigned to john.doe"
75 | - "Generate today's standup report"
76 |
77 | ## Troubleshooting
78 |
79 | ### "No .env file found"
80 | - Make sure you created `.env` in the project root
81 | - Copy from `.env.example` if available
82 |
83 | ### "Authentication failed"
84 | - Check your API token is correct
85 | - Verify your username is your email address
86 | - Ensure the Jira URL is correct
87 |
88 | ### "No active sprint found"
89 | - Make sure your board has an active sprint
90 | - Set `DEFAULT_BOARD_ID` in your `.env`
91 |
92 | ### "Permission denied"
93 | - Verify your Jira user has project access
94 | - Check the `PROJECT_KEY` is correct
95 |
96 | ## Next Steps
97 |
98 | - Explore all available tools with "What tools do you have?"
99 | - Set up team workload monitoring
100 | - Automate your daily standups
101 | - Create custom JQL searches
102 |
103 | Happy project managing! 🎯
```
--------------------------------------------------------------------------------
/src/mcp_jira/config.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Configuration management for MCP Jira.
3 | Handles environment variables, settings validation, and configuration defaults.
4 | """
5 |
6 | from pydantic import HttpUrl, SecretStr, field_validator
7 | from pydantic_settings import BaseSettings
8 | from typing import Optional
9 | import os
10 | from functools import lru_cache
11 |
12 | class Settings(BaseSettings):
13 | """
14 | Configuration settings for the MCP Jira application.
15 | Uses Pydantic for validation and environment variable loading.
16 | """
17 | # Jira Configuration
18 | jira_url: HttpUrl
19 | jira_username: str
20 | jira_api_token: SecretStr
21 | project_key: str
22 | default_board_id: Optional[int] = None
23 |
24 | # Application Settings
25 | debug_mode: bool = False
26 | log_level: str = "INFO"
27 |
28 | # Sprint Defaults
29 | default_sprint_length: int = 14 # days
30 | story_points_field: str = "customfield_10026" # Default story points field
31 | max_sprint_items: int = 50
32 |
33 | # Performance Settings
34 | jira_request_timeout: int = 30 # seconds
35 | cache_ttl: int = 300 # seconds
36 | max_concurrent_requests: int = 10
37 |
38 | class Config:
39 | """Pydantic configuration"""
40 | env_file = ".env"
41 | env_file_encoding = "utf-8"
42 | case_sensitive = False
43 |
44 | @field_validator("log_level")
45 | @classmethod
46 | def validate_log_level(cls, v: str) -> str:
47 | """Validate log level is a valid Python logging level"""
48 | valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
49 | upper_v = v.upper()
50 | if upper_v not in valid_levels:
51 | raise ValueError(f"Log level must be one of {valid_levels}")
52 | return upper_v
53 |
54 | @field_validator("jira_url")
55 | @classmethod
56 | def validate_jira_url(cls, v: HttpUrl) -> HttpUrl:
57 | """Ensure Jira URL is properly formatted"""
58 | url_str = str(v)
59 | if not url_str.endswith("/"):
60 | url_str += "/"
61 | return HttpUrl(url_str)
62 |
63 | @lru_cache()
64 | def get_settings() -> Settings:
65 | """
66 | Get settings with LRU cache to avoid reading environment variables multiple times.
67 | """
68 | return Settings()
69 |
70 | def initialize_logging(settings: Settings) -> None:
71 | """Initialize logging configuration"""
72 | import logging
73 |
74 | logging.basicConfig(
75 | level=settings.log_level,
76 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
77 | )
78 |
79 | # Set third-party loggers to WARNING to reduce noise
80 | logging.getLogger("aiohttp").setLevel(logging.WARNING)
81 | logging.getLogger("urllib3").setLevel(logging.WARNING)
82 |
83 | # Example .env file template
84 | ENV_TEMPLATE = """
85 | # Jira Configuration
86 | JIRA_URL=https://your-domain.atlassian.net
87 | [email protected]
88 | JIRA_API_TOKEN=your_api_token
89 | PROJECT_KEY=PROJ
90 | DEFAULT_BOARD_ID=123
91 |
92 | # Application Settings
93 | DEBUG_MODE=false
94 | LOG_LEVEL=INFO
95 | """
96 |
97 | def generate_env_template() -> str:
98 | """Generate a template .env file"""
99 | return ENV_TEMPLATE.strip()
100 |
```
--------------------------------------------------------------------------------
/tests/test_jira_client.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Tests for the Jira client implementation.
3 | """
4 |
5 | import pytest
6 | from unittest.mock import Mock, AsyncMock, patch, MagicMock
7 | from mcp_jira.jira_client import JiraClient
8 | from mcp_jira.types import IssueType, Priority
9 |
10 | @pytest.mark.asyncio
11 | @patch('aiohttp.ClientSession')
12 | async def test_create_issue(mock_session_class, mock_jira_client):
13 | """Test creating a Jira issue"""
14 | # Set up mock session
15 | mock_session = MagicMock()
16 | mock_session_class.return_value.__aenter__.return_value = mock_session
17 | mock_session_class.return_value.__aexit__.return_value = None
18 |
19 | # Mock the POST response
20 | mock_response = MagicMock()
21 | mock_response.status = 201
22 | mock_response.json = AsyncMock(return_value={"key": "TEST-1"})
23 | mock_session.post.return_value.__aenter__.return_value = mock_response
24 | mock_session.post.return_value.__aexit__.return_value = None
25 |
26 | result = await mock_jira_client.create_issue(
27 | summary="Test Issue",
28 | description="Test Description",
29 | issue_type=IssueType.STORY,
30 | priority=Priority.HIGH,
31 | story_points=5
32 | )
33 | assert result == "TEST-1"
34 |
35 | @pytest.mark.asyncio
36 | async def test_get_sprint(mock_jira_client, sample_sprint):
37 | """Test getting sprint details"""
38 | sprint = await mock_jira_client.get_sprint(1)
39 | assert sprint.id == 1
40 | assert sprint.name == "Test Sprint"
41 |
42 | @pytest.mark.asyncio
43 | async def test_get_sprint_issues(mock_jira_client, sample_issue):
44 | """Test getting sprint issues"""
45 | issues = await mock_jira_client.get_sprint_issues(1)
46 | assert len(issues) > 0
47 | assert issues[0].key == sample_issue.key
48 | assert issues[0].summary == sample_issue.summary
49 |
50 | @pytest.mark.asyncio
51 | async def test_get_backlog_issues(mock_jira_client):
52 | """Test getting backlog issues"""
53 | issues = await mock_jira_client.get_backlog_issues()
54 | assert len(issues) > 0
55 | assert all(isinstance(issue.key, str) for issue in issues)
56 |
57 | @pytest.mark.asyncio
58 | async def test_search_issues(mock_jira_client):
59 | """Test searching issues"""
60 | jql = 'project = "TEST"'
61 | issues = await mock_jira_client.search_issues(jql)
62 | assert len(issues) > 0
63 | assert all(hasattr(issue, 'key') for issue in issues)
64 |
65 | @pytest.mark.asyncio
66 | async def test_get_issue_history(mock_jira_client):
67 | """Test getting issue history"""
68 | history = await mock_jira_client.get_issue_history("TEST-1")
69 | assert isinstance(history, list)
70 |
71 | @pytest.mark.asyncio
72 | async def test_get_assigned_issues(mock_jira_client):
73 | """Test getting assigned issues"""
74 | issues = await mock_jira_client.get_assigned_issues("test_user")
75 | assert len(issues) > 0
76 | assert all(hasattr(issue, 'assignee') for issue in issues)
77 |
78 | @pytest.mark.asyncio
79 | async def test_error_handling(mock_jira_client, mock_response):
80 | """Test error handling"""
81 | # Mock error response
82 | mock_jira_client._session.post = mock_response(500, {"error": "Test error"})
83 |
84 | with pytest.raises(Exception):
85 | await mock_jira_client.create_issue(
86 | summary="Test Issue",
87 | description="Test Description",
88 | issue_type=IssueType.STORY,
89 | priority=Priority.HIGH
90 | )
```
--------------------------------------------------------------------------------
/src/mcp_jira/types.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Type definitions and enums for the MCP Jira server.
3 | Includes all custom types used across the application.
4 | """
5 |
6 | from enum import Enum, auto
7 | from typing import List, Dict, Any, Optional
8 | from pydantic import BaseModel, Field
9 | from datetime import datetime
10 |
11 | class IssueType(str, Enum):
12 | """Jira issue types"""
13 | STORY = "Story"
14 | BUG = "Bug"
15 | TASK = "Task"
16 | EPIC = "Epic"
17 | SUBTASK = "Sub-task"
18 | INCIDENT = "Incident"
19 | SERVICE_REQUEST = "Service Request"
20 |
21 | class Priority(str, Enum):
22 | """Jira priority levels"""
23 | HIGHEST = "Highest"
24 | HIGH = "High"
25 | MEDIUM = "Medium"
26 | LOW = "Low"
27 | LOWEST = "Lowest"
28 |
29 | class SprintStatus(str, Enum):
30 | """Sprint statuses"""
31 | PLANNING = "Planning"
32 | ACTIVE = "Active"
33 | COMPLETED = "Completed"
34 | CANCELLED = "Cancelled"
35 |
36 | class IssueStatus(str, Enum):
37 | """Issue statuses"""
38 | TODO = "To Do"
39 | IN_PROGRESS = "In Progress"
40 | REVIEW = "Review"
41 | BLOCKED = "Blocked"
42 | DONE = "Done"
43 |
44 | class RiskLevel(str, Enum):
45 | """Risk levels for sprint analysis"""
46 | HIGH = "High"
47 | MEDIUM = "Medium"
48 | LOW = "Low"
49 |
50 | class RiskType(str, Enum):
51 | """Types of risks that can be identified"""
52 | SCOPE_CREEP = "Scope Creep"
53 | RESOURCE_CONSTRAINT = "Resource Constraint"
54 | TECHNICAL_DEBT = "Technical Debt"
55 | DEPENDENCY_RISK = "Dependency Risk"
56 | VELOCITY_RISK = "Velocity Risk"
57 | CAPACITY_RISK = "Capacity Risk"
58 |
59 | # Pydantic models for structured data
60 | class TeamMember(BaseModel):
61 | """Team member information"""
62 | username: str
63 | display_name: str
64 | email: Optional[str]
65 | role: Optional[str]
66 | capacity: Optional[float] = Field(
67 | default=1.0,
68 | description="Capacity as percentage (1.0 = 100%)"
69 | )
70 |
71 | class Issue(BaseModel):
72 | """Jira issue details"""
73 | key: str
74 | summary: str
75 | description: Optional[str]
76 | issue_type: IssueType
77 | priority: Priority
78 | status: IssueStatus
79 | assignee: Optional[TeamMember]
80 | story_points: Optional[float]
81 | labels: List[str] = []
82 | components: List[str] = []
83 | created_at: datetime
84 | updated_at: datetime
85 | blocked_by: List[str] = []
86 | blocks: List[str] = []
87 |
88 | class Sprint(BaseModel):
89 | """Sprint information"""
90 | id: int
91 | name: str
92 | goal: Optional[str]
93 | status: SprintStatus
94 | start_date: Optional[datetime]
95 | end_date: Optional[datetime]
96 | completed_points: float = 0
97 | total_points: float = 0
98 | team_members: List[TeamMember] = []
99 |
100 | class Risk(BaseModel):
101 | """Risk assessment details"""
102 | type: RiskType
103 | level: RiskLevel
104 | description: str
105 | impact: str
106 | mitigation: Optional[str]
107 | affected_issues: List[str] = []
108 |
109 | class SprintMetrics(BaseModel):
110 | """Sprint performance metrics"""
111 | velocity: float
112 | completion_rate: float
113 | average_cycle_time: float
114 | blocked_issues_count: int
115 | scope_changes: int
116 | team_capacity: float
117 | burndown_ideal: Dict[str, float]
118 | burndown_actual: Dict[str, float]
119 |
120 | class WorkloadBalance(BaseModel):
121 | """Workload distribution information"""
122 | team_member: TeamMember
123 | assigned_points: float
124 | issue_count: int
125 | current_capacity: float
126 | recommendations: List[str]
127 |
128 | class DailyStandupItem(BaseModel):
129 | """Individual standup update"""
130 | issue_key: str
131 | summary: str
132 | status: IssueStatus
133 | assignee: str
134 | blocked_reason: Optional[str]
135 | notes: Optional[str]
136 | time_spent: Optional[float]
137 |
138 | # Custom exceptions
139 | class JiraError(Exception):
140 | """Base exception for Jira-related errors"""
141 | pass
142 |
143 | class SprintError(Exception):
144 | """Base exception for Sprint-related errors"""
145 | pass
146 |
147 | class ConfigError(Exception):
148 | """Base exception for configuration errors"""
149 | pass
150 |
151 | # Type aliases for complex types
152 | SprintPlanningResult = Dict[str, List[Issue]]
153 | WorkloadDistribution = Dict[str, WorkloadBalance]
154 | RiskAssessment = List[Risk]
155 | TeamCapacityMap = Dict[str, float]
156 |
```
--------------------------------------------------------------------------------
/tests/test_simple_mcp_server.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Tests for the simple MCP server implementation.
3 | """
4 |
5 | import pytest
6 | import asyncio
7 | from unittest.mock import Mock, AsyncMock, patch
8 |
9 | from mcp_jira.simple_mcp_server import (
10 | list_tools, call_tool, handle_create_issue,
11 | handle_search_issues, handle_sprint_status
12 | )
13 | from mcp_jira.types import IssueType, Priority, Issue, Sprint, IssueStatus, SprintStatus
14 | from mcp.types import Tool, TextContent
15 |
16 | @pytest.mark.asyncio
17 | async def test_list_tools():
18 | """Test that tools are properly listed"""
19 | tools = await list_tools()
20 |
21 | assert len(tools) == 5
22 | tool_names = [tool.name for tool in tools]
23 |
24 | expected_tools = [
25 | "create_issue", "search_issues", "get_sprint_status",
26 | "get_team_workload", "generate_standup_report"
27 | ]
28 |
29 | for expected_tool in expected_tools:
30 | assert expected_tool in tool_names
31 |
32 | @pytest.mark.asyncio
33 | async def test_create_issue_tool():
34 | """Test create_issue tool"""
35 | with patch('mcp_jira.simple_mcp_server.jira_client') as mock_client:
36 | mock_client.create_issue = AsyncMock(return_value="TEST-123")
37 |
38 | args = {
39 | "summary": "Test Issue",
40 | "description": "Test Description",
41 | "issue_type": "Story",
42 | "priority": "High"
43 | }
44 |
45 | result = await handle_create_issue(args)
46 |
47 | assert len(result) == 1
48 | assert isinstance(result[0], TextContent)
49 | assert "TEST-123" in result[0].text
50 | assert "✅" in result[0].text
51 |
52 | @pytest.mark.asyncio
53 | async def test_search_issues_tool():
54 | """Test search_issues tool"""
55 | with patch('mcp_jira.simple_mcp_server.jira_client') as mock_client:
56 | # Mock issue data
57 | mock_issue = Mock()
58 | mock_issue.key = "TEST-1"
59 | mock_issue.summary = "Test Issue"
60 | mock_issue.status.value = "In Progress"
61 | mock_issue.priority.value = "High"
62 | mock_issue.assignee = None
63 | mock_issue.story_points = 5
64 |
65 | mock_client.search_issues = AsyncMock(return_value=[mock_issue])
66 |
67 | args = {"jql": "project = TEST"}
68 | result = await handle_search_issues(args)
69 |
70 | assert len(result) == 1
71 | assert isinstance(result[0], TextContent)
72 | assert "TEST-1" in result[0].text
73 | assert "Test Issue" in result[0].text
74 |
75 | @pytest.mark.asyncio
76 | async def test_search_issues_no_results():
77 | """Test search_issues with no results"""
78 | with patch('mcp_jira.simple_mcp_server.jira_client') as mock_client:
79 | mock_client.search_issues = AsyncMock(return_value=[])
80 |
81 | args = {"jql": "project = EMPTY"}
82 | result = await handle_search_issues(args)
83 |
84 | assert len(result) == 1
85 | assert "No issues found" in result[0].text
86 |
87 | @pytest.mark.asyncio
88 | async def test_sprint_status_tool():
89 | """Test get_sprint_status tool"""
90 | with patch('mcp_jira.simple_mcp_server.jira_client') as mock_client:
91 | # Mock sprint data
92 | mock_sprint = Mock()
93 | mock_sprint.id = 1
94 | mock_sprint.name = "Test Sprint"
95 | mock_sprint.status.value = "Active"
96 | mock_sprint.goal = "Complete features"
97 | mock_sprint.start_date = None
98 | mock_sprint.end_date = None
99 |
100 | # Mock issues
101 | mock_issue = Mock()
102 | mock_issue.story_points = 5
103 | mock_issue.status.value = "Done"
104 |
105 | mock_client.get_active_sprint = AsyncMock(return_value=mock_sprint)
106 | mock_client.get_sprint_issues = AsyncMock(return_value=[mock_issue])
107 |
108 | args = {}
109 | result = await handle_sprint_status(args)
110 |
111 | assert len(result) == 1
112 | assert isinstance(result[0], TextContent)
113 | assert "Test Sprint" in result[0].text
114 | assert "📊" in result[0].text
115 |
116 | @pytest.mark.asyncio
117 | async def test_call_tool_unknown():
118 | """Test calling an unknown tool"""
119 | with patch('mcp_jira.simple_mcp_server.jira_client', Mock()):
120 | result = await call_tool("unknown_tool", {})
121 |
122 | assert len(result) == 1
123 | assert "Unknown tool" in result[0].text
124 |
125 | @pytest.mark.asyncio
126 | async def test_call_tool_no_client():
127 | """Test calling tool when client is not initialized"""
128 | with patch('mcp_jira.simple_mcp_server.jira_client', None):
129 | result = await call_tool("create_issue", {})
130 |
131 | assert len(result) == 1
132 | assert "Jira client not initialized" in result[0].text
```
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | PyTest configuration and fixtures for MCP Jira tests.
3 | """
4 |
5 | import pytest
6 | from typing import Dict, Any
7 | import aiohttp
8 | from datetime import datetime
9 | from unittest.mock import MagicMock, AsyncMock
10 |
11 | from mcp_jira.config import Settings
12 | from mcp_jira.jira_client import JiraClient
13 | from mcp_jira.types import Issue, Sprint, TeamMember, IssueType, Priority, IssueStatus
14 |
15 | @pytest.fixture
16 | def test_settings():
17 | """Provide test settings"""
18 | # Mock environment variables for testing
19 | import os
20 | os.environ["JIRA_URL"] = "https://test-jira.example.com"
21 | os.environ["JIRA_USERNAME"] = "test_user"
22 | os.environ["JIRA_API_TOKEN"] = "test_token"
23 | os.environ["PROJECT_KEY"] = "TEST"
24 | os.environ["DEFAULT_BOARD_ID"] = "1"
25 |
26 | return Settings()
27 |
28 | @pytest.fixture
29 | def mock_response():
30 | """Create a mock aiohttp response"""
31 | class MockResponse:
32 | def __init__(self, status: int, data: Dict[str, Any]):
33 | self.status = status
34 | self._data = data
35 |
36 | async def json(self):
37 | return self._data
38 |
39 | async def text(self):
40 | return str(self._data)
41 |
42 | async def __aenter__(self):
43 | return self
44 |
45 | async def __aexit__(self, exc_type, exc_val, exc_tb):
46 | pass
47 |
48 | return MockResponse
49 |
50 | @pytest.fixture
51 | def mock_jira_client(test_settings):
52 | """Create a mock Jira client"""
53 | client = JiraClient(test_settings)
54 |
55 | # Mock the entire session to prevent HTTP calls
56 | client._session = MagicMock()
57 |
58 | # Mock all HTTP methods to return successful responses
59 | async def mock_get(*args, **kwargs):
60 | # Mock sprint response
61 | if "sprint" in str(args[0]):
62 | return MagicMock(status=200, json=AsyncMock(return_value={
63 | "id": 1,
64 | "name": "Test Sprint",
65 | "goal": "Test Goal",
66 | "state": "active",
67 | "startDate": "2024-01-08T00:00:00.000Z",
68 | "endDate": "2024-01-22T00:00:00.000Z"
69 | }))
70 | # Mock issue response
71 | elif "issue" in str(args[0]):
72 | return MagicMock(status=200, json=AsyncMock(return_value={
73 | "issues": [{
74 | "key": "TEST-1",
75 | "fields": {
76 | "summary": "Test Issue",
77 | "description": "Test Description",
78 | "issuetype": {"name": "Story"},
79 | "priority": {"name": "High"},
80 | "status": {"name": "To Do"},
81 | "assignee": {
82 | "name": "test_user",
83 | "displayName": "Test User",
84 | "emailAddress": "[email protected]"
85 | },
86 | "created": "2024-01-08T10:00:00.000Z",
87 | "updated": "2024-01-08T10:00:00.000Z",
88 | "customfield_10026": 5
89 | }
90 | }]
91 | }))
92 |
93 | async def mock_post(*args, **kwargs):
94 | # Mock issue creation
95 | if "issue" in str(args[0]):
96 | return MagicMock(status=201, json=AsyncMock(return_value={"key": "TEST-1"}))
97 | # Mock search
98 | else:
99 | return MagicMock(status=200, json=AsyncMock(return_value={
100 | "issues": [{
101 | "key": "TEST-1",
102 | "fields": {
103 | "summary": "Test Issue",
104 | "description": "Test Description",
105 | "issuetype": {"name": "Story"},
106 | "priority": {"name": "High"},
107 | "status": {"name": "To Do"},
108 | "assignee": {
109 | "name": "test_user",
110 | "displayName": "Test User",
111 | "emailAddress": "[email protected]"
112 | },
113 | "created": "2024-01-08T10:00:00.000Z",
114 | "updated": "2024-01-08T10:00:00.000Z",
115 | "customfield_10026": 5
116 | }
117 | }]
118 | }))
119 |
120 | client._session.get = AsyncMock(side_effect=mock_get)
121 | client._session.post = AsyncMock(side_effect=mock_post)
122 |
123 | return client
124 |
125 | @pytest.fixture
126 | def sample_issue():
127 | """Provide a sample issue"""
128 | return Issue(
129 | key="TEST-1",
130 | summary="Test Issue",
131 | description="Test Description",
132 | issue_type=IssueType.STORY,
133 | priority=Priority.HIGH,
134 | status=IssueStatus.TODO,
135 | assignee=TeamMember(
136 | username="test_user",
137 | display_name="Test User",
138 | email="[email protected]",
139 | role="Developer"
140 | ),
141 | story_points=5,
142 | labels=[],
143 | components=[],
144 | created_at=datetime.fromisoformat("2024-01-08T10:00:00.000"),
145 | updated_at=datetime.fromisoformat("2024-01-08T10:00:00.000"),
146 | blocked_by=[],
147 | blocks=[]
148 | )
149 |
150 | @pytest.fixture
151 | def sample_sprint():
152 | """Provide a sample sprint"""
153 | return {
154 | "id": 1,
155 | "name": "Test Sprint",
156 | "goal": "Test Goal",
157 | "state": "active",
158 | "startDate": "2024-01-08T00:00:00.000Z",
159 | "endDate": "2024-01-22T00:00:00.000Z"
160 | }
```
--------------------------------------------------------------------------------
/src/mcp_jira/mcp_protocol.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | MCP (Model Context Protocol) implementation for Jira integration.
3 | Handles function definitions, resource management, and protocol handlers.
4 | """
5 |
6 | from enum import Enum
7 | from typing import Dict, Any, List, Optional, Union, Callable
8 | from pydantic import BaseModel, Field
9 | import asyncio
10 | import logging
11 | from datetime import datetime, timezone
12 |
13 | from .types import (
14 | Issue, Sprint, TeamMember, SprintStatus,
15 | IssueType, Priority, Risk
16 | )
17 | from .jira_client import JiraClient
18 |
19 | logger = logging.getLogger(__name__)
20 |
21 | class MCPResourceType(str, Enum):
22 | """MCP Resource Types"""
23 | ISSUE = "issue"
24 | SPRINT = "sprint"
25 | TEAM = "team"
26 | METRICS = "metrics"
27 | REPORT = "report"
28 |
29 | class MCPFunction(BaseModel):
30 | """MCP Function Definition"""
31 | name: str
32 | description: str
33 | resource_type: MCPResourceType
34 | parameters: Dict[str, Any]
35 | returns: Dict[str, Any]
36 | handler: Optional[str] = None
37 |
38 | class MCPContext(BaseModel):
39 | """MCP Context Information"""
40 | conversation_id: str
41 | user_id: str
42 | timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
43 | metadata: Dict[str, Any] = Field(default_factory=dict)
44 |
45 | class MCPRequest(BaseModel):
46 | """MCP Request Structure"""
47 | function: str
48 | parameters: Dict[str, Any]
49 | context: MCPContext
50 | resource_type: MCPResourceType
51 |
52 | class MCPResponse(BaseModel):
53 | """MCP Response Structure"""
54 | status: str
55 | data: Optional[Dict[str, Any]] = None
56 | error: Optional[str] = None
57 | context: MCPContext
58 |
59 | class MCPProtocolHandler:
60 | """
61 | Main handler for MCP protocol implementation.
62 | Manages resources, functions, and request processing.
63 | """
64 | def __init__(self, jira_client: JiraClient):
65 | self.jira = jira_client
66 | self.functions: Dict[str, MCPFunction] = {}
67 | self._register_core_functions()
68 |
69 | def _register_core_functions(self):
70 | """Register core MCP functions"""
71 | self.register_function(
72 | MCPFunction(
73 | name="create_issue",
74 | description="Create a new Jira issue",
75 | resource_type=MCPResourceType.ISSUE,
76 | parameters={
77 | "summary": {"type": "string", "required": True},
78 | "description": {"type": "string", "required": True},
79 | "issue_type": {"type": "string", "enum": [t.value for t in IssueType]},
80 | "priority": {"type": "string", "enum": [p.value for p in Priority]},
81 | "story_points": {"type": "number", "required": False},
82 | "assignee": {"type": "string", "required": False}
83 | },
84 | returns={
85 | "issue_key": {"type": "string"}
86 | },
87 | handler="handle_create_issue"
88 | )
89 | )
90 |
91 | def register_function(self, function: MCPFunction):
92 | """Register a new MCP function"""
93 | self.functions[function.name] = function
94 | logger.info(f"Registered MCP function: {function.name}")
95 |
96 | async def process_request(self, request: MCPRequest) -> MCPResponse:
97 | """Process an MCP request"""
98 | try:
99 | if request.function not in self.functions:
100 | return MCPResponse(
101 | status="error",
102 | error=f"Unknown function: {request.function}",
103 | context=request.context
104 | )
105 |
106 | function = self.functions[request.function]
107 | if function.resource_type != request.resource_type:
108 | return MCPResponse(
109 | status="error",
110 | error=f"Invalid resource type for function {request.function}",
111 | context=request.context
112 | )
113 |
114 | handler = getattr(self, function.handler)
115 | if not handler:
116 | return MCPResponse(
117 | status="error",
118 | error=f"Handler not implemented: {function.handler}",
119 | context=request.context
120 | )
121 |
122 | result = await handler(request.parameters, request.context)
123 |
124 | return MCPResponse(
125 | status="success",
126 | data=result,
127 | context=request.context
128 | )
129 |
130 | except Exception as e:
131 | logger.exception(f"Error processing MCP request: {str(e)}")
132 | return MCPResponse(
133 | status="error",
134 | error=str(e),
135 | context=request.context
136 | )
137 |
138 | # Handler implementations
139 | async def handle_create_issue(
140 | self,
141 | parameters: Dict[str, Any],
142 | context: MCPContext
143 | ) -> Dict[str, Any]:
144 | """Handle create_issue function"""
145 | issue_key = await self.jira.create_issue(
146 | summary=parameters["summary"],
147 | description=parameters["description"],
148 | issue_type=IssueType(parameters["issue_type"]),
149 | priority=Priority(parameters["priority"]),
150 | story_points=parameters.get("story_points"),
151 | assignee=parameters.get("assignee")
152 | )
153 | return {"issue_key": issue_key}
154 |
155 | # Resource handlers
156 | async def get_resource(
157 | self,
158 | resource_type: MCPResourceType,
159 | resource_id: str
160 | ) -> Dict[str, Any]:
161 | """Get a resource by type and ID"""
162 | handlers = {
163 | MCPResourceType.ISSUE: self.jira.get_issue,
164 | MCPResourceType.SPRINT: self.jira.get_sprint,
165 | # Add more resource handlers...
166 | }
167 |
168 | handler = handlers.get(resource_type)
169 | if not handler:
170 | raise ValueError(f"Unknown resource type: {resource_type}")
171 |
172 | return await handler(resource_id)
173 |
174 | async def update_resource(
175 | self,
176 | resource_type: MCPResourceType,
177 | resource_id: str,
178 | updates: Dict[str, Any]
179 | ) -> Dict[str, Any]:
180 | """Update a resource"""
181 | # Implement resource update logic
182 | pass
183 |
```
--------------------------------------------------------------------------------
/src/mcp_jira/jira_client.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | JiraClient class implementation for MCP Jira.
3 | Handles all direct interactions with the Jira API.
4 | """
5 |
6 | from typing import List, Optional, Dict, Any
7 | import aiohttp
8 | import logging
9 | from datetime import datetime
10 | from base64 import b64encode
11 |
12 | from .types import (
13 | Issue, Sprint, TeamMember, IssueType,
14 | Priority, IssueStatus, SprintStatus,
15 | JiraError
16 | )
17 | from .config import Settings
18 |
19 | logger = logging.getLogger(__name__)
20 |
21 | class JiraClient:
22 | def __init__(self, settings: Settings):
23 | self.base_url = str(settings.jira_url).rstrip('/')
24 | self.auth_header = self._create_auth_header(
25 | settings.jira_username,
26 | settings.jira_api_token
27 | )
28 | self.project_key = settings.project_key
29 | self.board_id = settings.default_board_id
30 |
31 | async def create_issue(
32 | self,
33 | summary: str,
34 | description: str,
35 | issue_type: IssueType,
36 | priority: Priority,
37 | story_points: Optional[float] = None,
38 | assignee: Optional[str] = None,
39 | labels: Optional[List[str]] = None,
40 | components: Optional[List[str]] = None
41 | ) -> str:
42 | """Create a new Jira issue."""
43 | data = {
44 | "fields": {
45 | "project": {"key": self.project_key},
46 | "summary": summary,
47 | "description": description,
48 | "issuetype": {"name": issue_type.value},
49 | "priority": {"name": priority.value}
50 | }
51 | }
52 |
53 | if story_points:
54 | data["fields"]["customfield_10026"] = story_points # Adjust field ID as needed
55 | if assignee:
56 | data["fields"]["assignee"] = {"name": assignee}
57 | if labels:
58 | data["fields"]["labels"] = labels
59 | if components:
60 | data["fields"]["components"] = [{"name": c} for c in components]
61 |
62 | async with aiohttp.ClientSession() as session:
63 | async with session.post(
64 | f"{self.base_url}/rest/api/2/issue",
65 | headers=self._get_headers(),
66 | json=data
67 | ) as response:
68 | if response.status == 201:
69 | result = await response.json()
70 | return result["key"]
71 | else:
72 | error_data = await response.text()
73 | raise JiraError(f"Failed to create issue: {error_data}")
74 |
75 | async def get_sprint(self, sprint_id: int) -> Sprint:
76 | """Get sprint details by ID."""
77 | async with aiohttp.ClientSession() as session:
78 | async with session.get(
79 | f"{self.base_url}/rest/agile/1.0/sprint/{sprint_id}",
80 | headers=self._get_headers()
81 | ) as response:
82 | if response.status == 200:
83 | data = await response.json()
84 | return self._convert_to_sprint(data)
85 | else:
86 | error_data = await response.text()
87 | raise JiraError(f"Failed to get sprint: {error_data}")
88 |
89 | async def get_active_sprint(self) -> Optional[Sprint]:
90 | """Get the currently active sprint."""
91 | sprints = await self._get_board_sprints(
92 | self.board_id,
93 | state=SprintStatus.ACTIVE
94 | )
95 | return sprints[0] if sprints else None
96 |
97 | async def get_sprint_issues(self, sprint_id: int) -> List[Issue]:
98 | """Get all issues in a sprint."""
99 | async with aiohttp.ClientSession() as session:
100 | async with session.get(
101 | f"{self.base_url}/rest/agile/1.0/sprint/{sprint_id}/issue",
102 | headers=self._get_headers()
103 | ) as response:
104 | if response.status == 200:
105 | data = await response.json()
106 | return [self._convert_to_issue(i) for i in data["issues"]]
107 | else:
108 | error_data = await response.text()
109 | raise JiraError(f"Failed to get sprint issues: {error_data}")
110 |
111 | async def get_backlog_issues(self) -> List[Issue]:
112 | """Get all backlog issues."""
113 | jql = f"project = {self.project_key} AND sprint is EMPTY ORDER BY Rank ASC"
114 | return await self.search_issues(jql)
115 |
116 | async def get_assigned_issues(self, username: str) -> List[Issue]:
117 | """Get issues assigned to a specific user."""
118 | jql = f"assignee = {username} AND resolution = Unresolved"
119 | return await self.search_issues(jql)
120 |
121 | async def search_issues(self, jql: str) -> List[Issue]:
122 | """Search issues using JQL."""
123 | async with aiohttp.ClientSession() as session:
124 | async with session.post(
125 | f"{self.base_url}/rest/api/2/search",
126 | headers=self._get_headers(),
127 | json={
128 | "jql": jql,
129 | "maxResults": 100
130 | }
131 | ) as response:
132 | if response.status == 200:
133 | data = await response.json()
134 | return [self._convert_to_issue(i) for i in data["issues"]]
135 | else:
136 | error_data = await response.text()
137 | raise JiraError(f"Failed to search issues: {error_data}")
138 |
139 | async def get_issue_history(self, issue_key: str) -> List[Dict[str, Any]]:
140 | """Get the change history of an issue."""
141 | async with aiohttp.ClientSession() as session:
142 | async with session.get(
143 | f"{self.base_url}/rest/api/2/issue/{issue_key}/changelog",
144 | headers=self._get_headers()
145 | ) as response:
146 | if response.status == 200:
147 | data = await response.json()
148 | return self._process_changelog(data["values"])
149 | else:
150 | error_data = await response.text()
151 | raise JiraError(f"Failed to get issue history: {error_data}")
152 |
153 | # Helper methods
154 | def _get_headers(self) -> Dict[str, str]:
155 | """Get headers for Jira API requests."""
156 | return {
157 | "Authorization": f"Basic {self.auth_header}",
158 | "Content-Type": "application/json",
159 | "Accept": "application/json"
160 | }
161 |
162 | def _create_auth_header(self, username: str, api_token: str) -> str:
163 | """Create base64 encoded auth header."""
164 | auth_string = f"{username}:{api_token}"
165 | return b64encode(auth_string.encode()).decode()
166 |
167 | def _convert_to_issue(self, data: Dict[str, Any]) -> Issue:
168 | """Convert Jira API response to Issue object."""
169 | fields = data["fields"]
170 | return Issue(
171 | key=data["key"],
172 | summary=fields["summary"],
173 | description=fields.get("description"),
174 | issue_type=IssueType(fields["issuetype"]["name"]),
175 | priority=Priority(fields["priority"]["name"]),
176 | status=IssueStatus(fields["status"]["name"]),
177 | assignee=self._convert_to_team_member(fields.get("assignee")) if fields.get("assignee") else None,
178 | story_points=fields.get("customfield_10026"), # Adjust field ID as needed
179 | labels=fields.get("labels", []),
180 | components=[c["name"] for c in fields.get("components", [])],
181 | created_at=datetime.fromisoformat(fields["created"].rstrip('Z')),
182 | updated_at=datetime.fromisoformat(fields["updated"].rstrip('Z')),
183 | blocked_by=[], # Would need to implement logic to determine blockers
184 | blocks=[]
185 | )
186 |
187 | def _convert_to_sprint(self, data: Dict[str, Any]) -> Sprint:
188 | """Convert Jira API response to Sprint object."""
189 | return Sprint(
190 | id=data["id"],
191 | name=data["name"],
192 | goal=data.get("goal"),
193 | status=SprintStatus(data["state"]),
194 | start_date=datetime.fromisoformat(data["startDate"].rstrip('Z')) if data.get("startDate") else None,
195 | end_date=datetime.fromisoformat(data["endDate"].rstrip('Z')) if data.get("endDate") else None
196 | )
197 |
198 | def _convert_to_team_member(self, data: Dict[str, Any]) -> TeamMember:
199 | """Convert Jira API response to TeamMember object."""
200 | return TeamMember(
201 | username=data["name"],
202 | display_name=data["displayName"],
203 | email=data.get("emailAddress")
204 | )
205 |
206 | def _process_changelog(self, changelog: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
207 | """Process issue changelog into a more usable format."""
208 | history = []
209 | for entry in changelog:
210 | for item in entry["items"]:
211 | if item["field"] == "status":
212 | history.append({
213 | "from_status": item["fromString"],
214 | "to_status": item["toString"],
215 | "from_date": datetime.fromisoformat(entry["created"].rstrip('Z')),
216 | "author": entry["author"]["displayName"]
217 | })
218 | return history
219 |
220 | async def _get_board_sprints(
221 | self,
222 | board_id: int,
223 | state: Optional[SprintStatus] = None
224 | ) -> List[Sprint]:
225 | """Get all sprints for a board."""
226 | params = {"state": state.value} if state else {}
227 | async with aiohttp.ClientSession() as session:
228 | async with session.get(
229 | f"{self.base_url}/rest/agile/1.0/board/{board_id}/sprint",
230 | headers=self._get_headers(),
231 | params=params
232 | ) as response:
233 | if response.status == 200:
234 | data = await response.json()
235 | return [self._convert_to_sprint(s) for s in data["values"]]
236 | else:
237 | error_data = await response.text()
238 | raise JiraError(f"Failed to get board sprints: {error_data}")
239 |
```
--------------------------------------------------------------------------------
/src/mcp_jira/simple_mcp_server.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Simple MCP server for Jira integration.
3 | Implements core project management functions following MCP specification.
4 | """
5 |
6 | import asyncio
7 | import logging
8 | from typing import List, Dict, Any, Optional
9 | from datetime import datetime
10 |
11 | from mcp.server import Server
12 | from mcp.server.stdio import stdio_server
13 | from mcp.types import (
14 | Tool, TextContent, ImageContent, EmbeddedResource
15 | )
16 |
17 | from .jira_client import JiraClient
18 | from .config import get_settings
19 | from .types import IssueType, Priority
20 |
21 | logger = logging.getLogger(__name__)
22 |
23 | # Initialize server
24 | server = Server("mcp-jira")
25 |
26 | # Global client (will be initialized in main)
27 | jira_client: Optional[JiraClient] = None
28 |
29 | @server.list_tools()
30 | async def list_tools() -> List[Tool]:
31 | """List available MCP tools for Jira operations."""
32 | return [
33 | Tool(
34 | name="create_issue",
35 | description="Create a new Jira issue",
36 | inputSchema={
37 | "type": "object",
38 | "properties": {
39 | "summary": {
40 | "type": "string",
41 | "description": "Brief summary of the issue"
42 | },
43 | "description": {
44 | "type": "string",
45 | "description": "Detailed description of the issue"
46 | },
47 | "issue_type": {
48 | "type": "string",
49 | "enum": ["Story", "Bug", "Task", "Epic"],
50 | "description": "Type of issue to create"
51 | },
52 | "priority": {
53 | "type": "string",
54 | "enum": ["Highest", "High", "Medium", "Low", "Lowest"],
55 | "description": "Priority level"
56 | },
57 | "story_points": {
58 | "type": "number",
59 | "description": "Story points estimate (optional)"
60 | },
61 | "assignee": {
62 | "type": "string",
63 | "description": "Username to assign the issue to (optional)"
64 | }
65 | },
66 | "required": ["summary", "description", "issue_type", "priority"]
67 | }
68 | ),
69 | Tool(
70 | name="search_issues",
71 | description="Search for Jira issues using JQL",
72 | inputSchema={
73 | "type": "object",
74 | "properties": {
75 | "jql": {
76 | "type": "string",
77 | "description": "JQL query to search for issues"
78 | },
79 | "max_results": {
80 | "type": "number",
81 | "description": "Maximum number of results to return (default: 20)"
82 | }
83 | },
84 | "required": ["jql"]
85 | }
86 | ),
87 | Tool(
88 | name="get_sprint_status",
89 | description="Get current sprint status and progress",
90 | inputSchema={
91 | "type": "object",
92 | "properties": {
93 | "sprint_id": {
94 | "type": "number",
95 | "description": "Sprint ID to analyze (optional, defaults to active sprint)"
96 | }
97 | }
98 | }
99 | ),
100 | Tool(
101 | name="get_team_workload",
102 | description="Analyze team workload and capacity",
103 | inputSchema={
104 | "type": "object",
105 | "properties": {
106 | "team_members": {
107 | "type": "array",
108 | "items": {"type": "string"},
109 | "description": "List of team member usernames to analyze"
110 | }
111 | },
112 | "required": ["team_members"]
113 | }
114 | ),
115 | Tool(
116 | name="generate_standup_report",
117 | description="Generate daily standup report for the active sprint",
118 | inputSchema={
119 | "type": "object",
120 | "properties": {}
121 | }
122 | )
123 | ]
124 |
125 | @server.call_tool()
126 | async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]:
127 | """Handle tool calls for Jira operations."""
128 | if not jira_client:
129 | return [TextContent(type="text", text="Error: Jira client not initialized")]
130 |
131 | try:
132 | if name == "create_issue":
133 | return await handle_create_issue(arguments)
134 | elif name == "search_issues":
135 | return await handle_search_issues(arguments)
136 | elif name == "get_sprint_status":
137 | return await handle_sprint_status(arguments)
138 | elif name == "get_team_workload":
139 | return await handle_team_workload(arguments)
140 | elif name == "generate_standup_report":
141 | return await handle_standup_report(arguments)
142 | else:
143 | return [TextContent(type="text", text=f"Unknown tool: {name}")]
144 |
145 | except Exception as e:
146 | logger.exception(f"Error executing tool {name}: {str(e)}")
147 | return [TextContent(type="text", text=f"Error: {str(e)}")]
148 |
149 | # Tool handlers
150 | async def handle_create_issue(args: Dict[str, Any]) -> List[TextContent]:
151 | """Handle create_issue tool call."""
152 | issue_key = await jira_client.create_issue(
153 | summary=args["summary"],
154 | description=args["description"],
155 | issue_type=IssueType(args["issue_type"]),
156 | priority=Priority(args["priority"]),
157 | story_points=args.get("story_points"),
158 | assignee=args.get("assignee")
159 | )
160 |
161 | return [TextContent(
162 | type="text",
163 | text=f"✅ Created issue {issue_key}: {args['summary']}"
164 | )]
165 |
166 | async def handle_search_issues(args: Dict[str, Any]) -> List[TextContent]:
167 | """Handle search_issues tool call."""
168 | jql = args["jql"]
169 | max_results = args.get("max_results", 20)
170 |
171 | issues = await jira_client.search_issues(jql)
172 | issues = issues[:max_results] # Limit results
173 |
174 | if not issues:
175 | return [TextContent(type="text", text="No issues found matching the query.")]
176 |
177 | # Format results
178 | result_text = f"Found {len(issues)} issues:\n\n"
179 | for issue in issues:
180 | status_emoji = "✅" if issue.status.value == "Done" else "🔄" if issue.status.value == "In Progress" else "📋"
181 | priority_emoji = "🔴" if issue.priority.value in ["Highest", "High"] else "🟡" if issue.priority.value == "Medium" else "🟢"
182 |
183 | assignee_text = f" (👤 {issue.assignee.display_name})" if issue.assignee else " (Unassigned)"
184 | points_text = f" [{issue.story_points}pts]" if issue.story_points else ""
185 |
186 | result_text += f"{status_emoji} **{issue.key}**: {issue.summary}\n"
187 | result_text += f" {priority_emoji} {issue.priority.value} | {issue.status.value}{assignee_text}{points_text}\n\n"
188 |
189 | return [TextContent(type="text", text=result_text)]
190 |
191 | async def handle_sprint_status(args: Dict[str, Any]) -> List[TextContent]:
192 | """Handle get_sprint_status tool call."""
193 | sprint_id = args.get("sprint_id")
194 |
195 | if sprint_id:
196 | sprint = await jira_client.get_sprint(sprint_id)
197 | else:
198 | sprint = await jira_client.get_active_sprint()
199 | if not sprint:
200 | return [TextContent(type="text", text="No active sprint found.")]
201 |
202 | issues = await jira_client.get_sprint_issues(sprint.id)
203 |
204 | # Calculate metrics
205 | total_points = sum(issue.story_points for issue in issues if issue.story_points)
206 | completed_points = sum(issue.story_points for issue in issues
207 | if issue.story_points and issue.status.value == "Done")
208 | in_progress_count = len([i for i in issues if i.status.value == "In Progress"])
209 | blocked_count = len([i for i in issues if i.status.value == "Blocked"])
210 |
211 | completion_rate = (completed_points / total_points * 100) if total_points > 0 else 0
212 |
213 | # Build report
214 | report = f"## 📊 Sprint Status: {sprint.name}\n\n"
215 | report += f"**Status**: {sprint.status.value}\n"
216 | report += f"**Goal**: {sprint.goal or 'No goal set'}\n"
217 | if sprint.start_date and sprint.end_date:
218 | days_remaining = (sprint.end_date - datetime.now()).days
219 | report += f"**Duration**: {sprint.start_date.strftime('%Y-%m-%d')} to {sprint.end_date.strftime('%Y-%m-%d')}\n"
220 | report += f"**Days Remaining**: {max(0, days_remaining)}\n"
221 |
222 | report += f"\n### 📈 Progress\n"
223 | report += f"- **Completion**: {completion_rate:.1f}% ({completed_points}/{total_points} points)\n"
224 | report += f"- **Total Issues**: {len(issues)}\n"
225 | report += f"- **In Progress**: {in_progress_count}\n"
226 | if blocked_count > 0:
227 | report += f"- **⚠️ Blocked**: {blocked_count}\n"
228 |
229 | return [TextContent(type="text", text=report)]
230 |
231 | async def handle_team_workload(args: Dict[str, Any]) -> List[TextContent]:
232 | """Handle get_team_workload tool call."""
233 | team_members = args["team_members"]
234 |
235 | report = "## 👥 Team Workload Analysis\n\n"
236 |
237 | for member in team_members:
238 | try:
239 | issues = await jira_client.get_assigned_issues(member)
240 | total_points = sum(issue.story_points for issue in issues if issue.story_points)
241 | in_progress_count = len([i for i in issues if i.status.value == "In Progress"])
242 |
243 | workload_emoji = "🔴" if total_points > 15 else "🟡" if total_points > 10 else "🟢"
244 |
245 | report += f"### {workload_emoji} {member}\n"
246 | report += f"- **Total Points**: {total_points}\n"
247 | report += f"- **Active Issues**: {in_progress_count}\n"
248 | report += f"- **Total Issues**: {len(issues)}\n\n"
249 |
250 | except Exception as e:
251 | report += f"### ❌ {member}\n"
252 | report += f"- **Error**: Could not fetch data ({str(e)})\n\n"
253 |
254 | return [TextContent(type="text", text=report)]
255 |
256 | async def handle_standup_report(args: Dict[str, Any]) -> List[TextContent]:
257 | """Handle generate_standup_report tool call."""
258 | active_sprint = await jira_client.get_active_sprint()
259 | if not active_sprint:
260 | return [TextContent(type="text", text="No active sprint found for standup report.")]
261 |
262 | issues = await jira_client.get_sprint_issues(active_sprint.id)
263 |
264 | # Categorize issues
265 | yesterday = datetime.now().date()
266 | completed_yesterday = [i for i in issues if i.status.value == "Done" and i.updated_at.date() == yesterday]
267 | in_progress = [i for i in issues if i.status.value == "In Progress"]
268 | blocked = [i for i in issues if i.status.value == "Blocked"]
269 |
270 | report = f"## 🌅 Daily Standup - {datetime.now().strftime('%Y-%m-%d')}\n\n"
271 | report += f"**Sprint**: {active_sprint.name}\n\n"
272 |
273 | if completed_yesterday:
274 | report += "### ✅ Completed Yesterday\n"
275 | for issue in completed_yesterday:
276 | assignee = issue.assignee.display_name if issue.assignee else "Unassigned"
277 | report += f"- **{issue.key}**: {issue.summary} ({assignee})\n"
278 | report += "\n"
279 |
280 | if in_progress:
281 | report += "### 🔄 In Progress\n"
282 | for issue in in_progress:
283 | assignee = issue.assignee.display_name if issue.assignee else "Unassigned"
284 | points = f" [{issue.story_points}pts]" if issue.story_points else ""
285 | report += f"- **{issue.key}**: {issue.summary} ({assignee}){points}\n"
286 | report += "\n"
287 |
288 | if blocked:
289 | report += "### ⚠️ Blocked Issues\n"
290 | for issue in blocked:
291 | assignee = issue.assignee.display_name if issue.assignee else "Unassigned"
292 | report += f"- **{issue.key}**: {issue.summary} ({assignee})\n"
293 | report += "\n"
294 |
295 | # Add quick metrics
296 | total_points = sum(i.story_points for i in issues if i.story_points)
297 | completed_points = sum(i.story_points for i in issues if i.story_points and i.status.value == "Done")
298 |
299 | report += "### 📊 Sprint Metrics\n"
300 | report += f"- **Progress**: {completed_points}/{total_points} points ({(completed_points/total_points*100):.1f}%)\n"
301 | report += f"- **Active Issues**: {len(in_progress)}\n"
302 | if blocked:
303 | report += f"- **Blocked Issues**: {len(blocked)} ⚠️\n"
304 |
305 | return [TextContent(type="text", text=report)]
306 |
307 | async def main():
308 | """Main entry point for the MCP server."""
309 | global jira_client
310 |
311 | # Initialize settings and Jira client
312 | settings = get_settings()
313 | jira_client = JiraClient(settings)
314 |
315 | logger.info("Starting MCP Jira server...")
316 |
317 | # Run the MCP server
318 | async with stdio_server() as (read_stream, write_stream):
319 | await server.run(read_stream, write_stream, server.create_initialization_options())
320 |
321 | if __name__ == "__main__":
322 | asyncio.run(main())
```