# 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()) ```