├── .gitignore ├── .vscode └── settings.json ├── CLAUDE.md ├── LICENSE ├── README.md ├── download-docs.ps1 ├── pyproject.toml ├── src └── mcp_azure_devops │ ├── __init__.py │ ├── __main__.py │ ├── features │ ├── __init__.py │ ├── projects │ │ ├── __init__.py │ │ ├── common.py │ │ └── tools.py │ ├── teams │ │ ├── __init__.py │ │ ├── common.py │ │ └── tools.py │ └── work_items │ │ ├── __init__.py │ │ ├── common.py │ │ ├── formatting.py │ │ └── tools │ │ ├── __init__.py │ │ ├── comments.py │ │ ├── create.py │ │ ├── process.py │ │ ├── query.py │ │ ├── read.py │ │ ├── templates.py │ │ └── types.py │ ├── server.py │ └── utils │ ├── __init__.py │ ├── azure_client.py │ └── conventions_promp.py ├── start_server.sh ├── tests ├── __init__.py ├── features │ ├── __init__.py │ ├── projects │ │ ├── __init__.py │ │ └── test_tools.py │ ├── teams │ │ ├── __init__.py │ │ └── test_tools.py │ └── work_items │ │ ├── __init__.py │ │ ├── test_create.py │ │ ├── test_process.py │ │ ├── test_templates.py │ │ ├── test_tools.py │ │ └── test_types.py └── test_server.py └── uv.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | .aider* 176 | repomix-output.xml 177 | docs/external 178 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # MCP Azure DevOps Server Guide 2 | 3 | This guide helps AI assistants implement and modify the MCP Azure DevOps server codebase effectively. 4 | 5 | ## 1. Purpose & Overview 6 | 7 | This MCP server enables AI assistants to interact with Azure DevOps by: 8 | - Connecting to Azure DevOps services via REST API and Python SDK 9 | - Exposing Azure DevOps data (work items, repositories, pipelines, PRs) 10 | - Providing tools to create and modify Azure DevOps objects 11 | - Including prompts for common workflows 12 | - Using PAT authentication for secure interactions 13 | 14 | ## 2. Project Structure 15 | 16 | ``` 17 | mcp-azure-devops/ 18 | ├── docs/ # API documentation 19 | ├── src/ # Source code 20 | │ └── mcp_azure_devops/ # Main package 21 | │ ├── features/ # Feature modules 22 | │ │ ├── projects/ # Project management features 23 | │ │ ├── teams/ # Team management features 24 | │ │ └── work_items/ # Work item management features 25 | │ │ ├── tools/ # Work item operation tools 26 | │ │ ├── common.py # Common utilities for work items 27 | │ │ └── formatting.py # Formatting helpers 28 | │ ├── utils/ # Shared utilities 29 | │ ├── __init__.py # Package initialization 30 | │ └── server.py # Main MCP server 31 | ├── tests/ # Test suite 32 | ├── .env # Environment variables (not in repo) 33 | ├── CLAUDE.md # AI assistant guide 34 | ├── LICENSE # MIT License 35 | ├── pyproject.toml # Project configuration 36 | ├── README.md # Project documentation 37 | └── uv.lock # Package dependency locks 38 | ``` 39 | 40 | ## 3. Core Concepts 41 | 42 | ### Azure DevOps & MCP Integration 43 | 44 | This project bridges two systems: 45 | 46 | 1. **Azure DevOps Objects**: 47 | - Work items (bugs, tasks, user stories, epics) 48 | - Repositories and branches 49 | - Pull requests and code reviews 50 | - Pipelines (build and release) 51 | - Projects and teams 52 | 53 | 2. **MCP Components**: 54 | - **Tools**: Action performers that modify data (like POST/PUT/DELETE endpoints) 55 | - **Prompts**: Templates for guiding interactions 56 | 57 | ### Authentication 58 | 59 | The project requires these environment variables: 60 | - `AZURE_DEVOPS_PAT`: Personal Access Token with appropriate permissions 61 | - `AZURE_DEVOPS_ORGANIZATION_URL`: The full URL to your Azure DevOps organization 62 | 63 | ## 4. Implementation Guidelines 64 | 65 | ### Feature Structure 66 | 67 | Each feature in the `features/` directory follows this pattern: 68 | - `__init__.py`: Contains `register()` function to add the feature to the MCP server 69 | - `common.py`: Shared utilities, exceptions, and helper functions 70 | - `tools.py` or `tools/`: Functions or classes for operations (GET, POST, PUT, DELETE) 71 | 72 | ### Tool Implementation Pattern 73 | 74 | When implementing a new tool: 75 | 76 | 1. Define a private implementation function with `_name_impl` that takes explicit client objects: 77 | ```python 78 | def _get_data_impl(client, param1, param2): 79 | # Implementation 80 | return formatted_result 81 | ``` 82 | 83 | 2. Create a public MCP tool function that handles client initialization and error handling: 84 | ```python 85 | @mcp.tool() 86 | def get_data(param1, param2): 87 | """ 88 | Docstring following the standard pattern. 89 | 90 | Use this tool when you need to: 91 | - First use case 92 | - Second use case 93 | 94 | Args: 95 | param1: Description 96 | param2: Description 97 | 98 | Returns: 99 | Description of the return value format 100 | """ 101 | try: 102 | client = get_client() 103 | return _get_data_impl(client, param1, param2) 104 | except AzureDevOpsClientError as e: 105 | return f"Error: {str(e)}" 106 | ``` 107 | 108 | 3. Register the tool in the feature's `__init__.py` or `register_tools()` function 109 | 110 | ### Function Docstring Pattern 111 | 112 | All public tools must have detailed docstrings following this pattern: 113 | 114 | ```python 115 | """ 116 | Brief description of what the tool does. 117 | 118 | Use this tool when you need to: 119 | - First use case with specific example 120 | - Second use case with specific example 121 | - Third use case with specific example 122 | 123 | IMPORTANT: Any special considerations or warnings. 124 | 125 | Args: 126 | param1: Description of first parameter 127 | param2: Description of second parameter 128 | 129 | Returns: 130 | Detailed description of what is returned and in what format 131 | """ 132 | ``` 133 | 134 | ### Error Handling 135 | 136 | The standard error handling pattern is: 137 | 138 | ```python 139 | try: 140 | # Implementation code 141 | except AzureDevOpsClientError as e: 142 | return f"Error: {str(e)}" 143 | except Exception as e: 144 | return f"Error doing operation: {str(e)}" 145 | ``` 146 | 147 | For specific errors, create custom exception classes in the feature's `common.py` file. 148 | 149 | ## 5. Common Code Patterns 150 | 151 | ### Client Initialization 152 | 153 | ```python 154 | from mcp_azure_devops.utils.azure_client import get_connection 155 | 156 | def get_work_item_client(): 157 | """Get the Work Item Tracking client.""" 158 | try: 159 | connection = get_connection() 160 | return connection.clients.get_work_item_tracking_client() 161 | except Exception as e: 162 | raise AzureDevOpsClientError(f"Failed to get Work Item client: {str(e)}") 163 | ``` 164 | 165 | ### Response Formatting 166 | 167 | ```python 168 | def format_result(data): 169 | """Format data for response.""" 170 | formatted_info = [f"# {data.name}"] 171 | 172 | # Add additional fields with null checks 173 | if hasattr(data, "description") and data.description: 174 | formatted_info.append(f"Description: {data.description}") 175 | 176 | return "\n".join(formatted_info) 177 | ``` 178 | 179 | 180 | ## 6. Adding New Features 181 | 182 | To add a new feature: 183 | 184 | 1. Create a new directory under `features/` with the appropriate structure 185 | 2. Implement feature-specific client initialization in `common.py` 186 | 3. Create tools in `tools.py` or a `tools/` directory with specific operations 187 | 4. Register the feature with the server by updating `features/__init__.py` 188 | 5. Write tests for the new feature in the `tests/` directory 189 | 190 | ## 7. Technical Requirements 191 | 192 | ### Code Style 193 | - PEP 8 conventions 194 | - Type hints for all functions 195 | - Google-style docstrings 196 | - Small, focused functions 197 | - Line length: 79 characters 198 | - Import sorting: standard library → third-party → local 199 | 200 | ### Development Tools 201 | - Install: `uv pip install -e ".[dev]"` 202 | - Run server: `mcp dev src/mcp_azure_devops/server.py` 203 | - Run tests: `uv run pytest tests/` 204 | - Format: `uv run ruff format .` 205 | - Type check: `uv run pyright` 206 | 207 | ### Testing 208 | - Write tests for all new functionality 209 | - Test both successful and error cases 210 | - Mock Azure DevOps API responses for deterministic testing 211 | - Place tests in the `tests/` directory with corresponding structure 212 | 213 | ## 8. Examples 214 | 215 | ### Example: Creating a New Tool 216 | 217 | ```python 218 | # In src/mcp_azure_devops/features/repositories/tools.py 219 | 220 | def _list_repositories_impl(git_client, project): 221 | """Implementation of listing repositories.""" 222 | repos = git_client.get_repositories(project=project) 223 | 224 | if not repos: 225 | return f"No repositories found in project {project}." 226 | 227 | formatted_repos = [] 228 | for repo in repos: 229 | formatted_repos.append(_format_repository(repo)) 230 | 231 | return "\n\n".join(formatted_repos) 232 | 233 | def register_tools(mcp): 234 | """Register repository tools with the MCP server.""" 235 | 236 | @mcp.tool() 237 | def list_repositories(project): 238 | """ 239 | Lists all Git repositories in a project. 240 | 241 | Use this tool when you need to: 242 | - View all repositories in a project 243 | - Find a specific repository by name 244 | - Get repository IDs for use in other operations 245 | 246 | Args: 247 | project: Project name or ID 248 | 249 | Returns: 250 | Formatted string listing all repositories with names, IDs, and URLs 251 | """ 252 | try: 253 | git_client = get_git_client() 254 | return _list_repositories_impl(git_client, project) 255 | except AzureDevOpsClientError as e: 256 | return f"Error: {str(e)}" 257 | ``` 258 | 259 | ### Example: Registering a New Feature 260 | 261 | ```python 262 | # In src/mcp_azure_devops/features/repositories/__init__.py 263 | 264 | from mcp_azure_devops.features.repositories import tools 265 | 266 | def register(mcp): 267 | """Register repository components with the MCP server.""" 268 | tools.register_tools(mcp) 269 | 270 | # In src/mcp_azure_devops/features/__init__.py 271 | 272 | from mcp_azure_devops.features import projects, teams, work_items, repositories 273 | 274 | def register_all(mcp): 275 | """Register all features with the MCP server.""" 276 | work_items.register(mcp) 277 | projects.register(mcp) 278 | teams.register(mcp) 279 | repositories.register(mcp) # Add the new feature 280 | ``` 281 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Vortiago 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Azure DevOps Server 2 | 3 | A Model Context Protocol (MCP) server enabling AI assistants to interact with Azure DevOps services. 4 | 5 | ## Overview 6 | 7 | This project implements a Model Context Protocol (MCP) server that allows AI assistants (like Claude) to interact with Azure DevOps, providing a bridge between natural language interactions and the Azure DevOps REST API. 8 | 9 | ## Features 10 | 11 | Currently implemented: 12 | 13 | ### Work Item Management 14 | - **Query Work Items**: Search for work items using WIQL queries 15 | - **Get Work Item Details**: View complete work item information 16 | - **Create Work Items**: Add new tasks, bugs, user stories, and other work item types 17 | - **Update Work Items**: Modify existing work items' fields and properties 18 | - **Add Comments**: Post comments on work items 19 | - **View Comments**: Retrieve the comment history for a work item 20 | - **Parent-Child Relationships**: Establish hierarchy between work items 21 | 22 | ### Project Management 23 | - **Get Projects**: View all accessible projects in the organization 24 | - **Get Teams**: List all teams within the organization 25 | - **Team Members**: View team membership information 26 | - **Team Area Paths**: Retrieve area paths assigned to teams 27 | - **Team Iterations**: Access team iteration/sprint configurations 28 | 29 | Planned features: 30 | - **Pipeline Operations**: Query pipeline status and trigger new pipeline runs 31 | - **Pull Request Handling**: Create, update, and review Pull Requests 32 | - **Sprint Management**: Plan and manage sprints and iterations 33 | - **Branch Policy Administration**: Configure and manage branch policies 34 | 35 | ## Getting Started 36 | 37 | ### Prerequisites 38 | 39 | - Python 3.10+ 40 | - Azure DevOps account with appropriate permissions 41 | - Personal Access Token (PAT) with necessary scopes for Azure DevOps API access 42 | 43 | ### Installation 44 | 45 | ```bash 46 | # Clone the repository 47 | git clone https://github.com/Vortiago/mcp-azure-devops.git 48 | cd mcp-azure-devops 49 | 50 | # Install in development mode 51 | uv pip install -e ".[dev]" 52 | 53 | # Install from PyPi 54 | pip install mcp-azure-devops 55 | ``` 56 | 57 | ### Configuration 58 | 59 | Create a `.env` file in the project root with the following variables: 60 | 61 | ``` 62 | AZURE_DEVOPS_PAT=your_personal_access_token 63 | AZURE_DEVOPS_ORGANIZATION_URL=https://your-organization.visualstudio.com or https://dev.azure.com/your-organisation 64 | ``` 65 | 66 | Note: Make sure to provide the full URL to your Azure DevOps organization. 67 | 68 | ### Running the Server 69 | 70 | ```bash 71 | # Development mode with the MCP Inspector 72 | mcp dev src/mcp_azure_devops/server.py 73 | 74 | # Install in Claude Desktop 75 | mcp install src/mcp_azure_devops/server.py --name "Azure DevOps Assistant" 76 | ``` 77 | 78 | ## Usage Examples 79 | 80 | ### Query Work Items 81 | 82 | ``` 83 | Show me all active bugs assigned to me in the current sprint 84 | ``` 85 | 86 | ### Create a Work Item 87 | 88 | ``` 89 | Create a user story in the ProjectX with the title "Implement user authentication" and assign it to john.doe@example.com 90 | ``` 91 | 92 | ### Update a Work Item 93 | 94 | ``` 95 | Change the status of bug #1234 to "Resolved" and add a comment explaining the fix 96 | ``` 97 | 98 | ### Team Management 99 | 100 | ``` 101 | Show me all the team members in the "Core Development" team in the "ProjectX" project 102 | ``` 103 | 104 | ### View Project Structure 105 | 106 | ``` 107 | List all projects in my organization and show me the iterations for the Development team 108 | ``` 109 | 110 | ## Development 111 | 112 | The project is structured into feature modules, each implementing specific Azure DevOps capabilities: 113 | 114 | - `features/work_items`: Work item management functionality 115 | - `features/projects`: Project management capabilities 116 | - `features/teams`: Team management features 117 | - `utils`: Common utilities and client initialization 118 | 119 | For more information on development, see the [CLAUDE.md](CLAUDE.md) file. 120 | 121 | ## Contributing 122 | 123 | Contributions are welcome! Please feel free to submit a Pull Request. 124 | 125 | ## License 126 | 127 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 128 | 129 | ## Acknowledgments 130 | 131 | - Built with [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) 132 | - Uses [Azure DevOps Python API](https://github.com/microsoft/azure-devops-python-api) 133 | -------------------------------------------------------------------------------- /download-docs.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Downloads external documentation from predefined URLs into the docs/external directory. 4 | 5 | .DESCRIPTION 6 | This script downloads content from a list of URLs and saves them to the docs/external directory. 7 | It's designed to help download reference documentation that can be used as context for LLMs. 8 | #> 9 | 10 | # Define the array of documentation URLs to download with categories 11 | $docsUrls = @( 12 | @{Url = "https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/refs/heads/main/README.md"; Category = "mcp-sdk" }, 13 | @{Url = "https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/refs/heads/main/examples/fastmcp/simple_echo.py"; Category = "mcp-sdk" }, 14 | @{Url = "https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/refs/heads/main/examples/fastmcp/readme-quickstart.py"; Category = "mcp-sdk" }, 15 | @{Url = "https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/refs/heads/main/examples/fastmcp/complex_inputs.py"; Category = "mcp-sdk" }, 16 | @{Url = "https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/refs/heads/main/examples/fastmcp/text_me.py"; Category = "mcp-sdk" }, 17 | @{Url = "https://raw.githubusercontent.com/microsoft/azure-devops-python-api/refs/heads/dev/README.md"; Category = "azdo-sdk" } 18 | # Add more URLs as needed 19 | ) 20 | 21 | # Ensure docs/external directory exists 22 | $externalDocsDir = Join-Path (Get-Location) "docs/external" 23 | if (-not (Test-Path $externalDocsDir -PathType Container)) { 24 | Write-Host "Creating docs/external directory..." 25 | New-Item -Path $externalDocsDir -ItemType Directory | Out-Null 26 | } 27 | 28 | # Function to derive a filename from a URL 29 | function Get-FilenameFromUrl { 30 | param([string]$Url) 31 | 32 | $uri = [System.Uri]$Url 33 | $filename = $uri.Segments[-1] 34 | 35 | # If filename is empty or just a slash, use the host plus path 36 | if ([string]::IsNullOrEmpty($filename) -or $filename -eq "/") { 37 | $filename = ($uri.Host -replace "[\.\:]", "-") + "-" + ($uri.AbsolutePath -replace "[\/\\\?\:]", "-") + ".html" 38 | } 39 | 40 | # Clean up filename 41 | $filename = $filename -replace "[^\w\d\.-]", "-" 42 | 43 | return $filename 44 | } 45 | 46 | # Download each URL 47 | foreach ($item in $docsUrls) { 48 | $url = $item.Url 49 | $category = $item.Category 50 | $filename = Get-FilenameFromUrl -Url $url 51 | 52 | # Create category subdirectory if needed 53 | $categoryDir = Join-Path $externalDocsDir $category 54 | if (-not (Test-Path $categoryDir -PathType Container)) { 55 | Write-Host "Creating category directory: $category" 56 | New-Item -Path $categoryDir -ItemType Directory | Out-Null 57 | } 58 | 59 | $outPath = Join-Path $categoryDir $filename 60 | 61 | Write-Host "Downloading: $url" 62 | Write-Host "Category: $category" 63 | Write-Host "Saving to: $outPath" 64 | 65 | try { 66 | Invoke-WebRequest -Uri $url -OutFile $outPath 67 | Write-Host "Download successful" -ForegroundColor Green 68 | } catch { 69 | Write-Host "Error downloading $url : $_" -ForegroundColor Red 70 | } 71 | 72 | Write-Host "" 73 | } 74 | 75 | Write-Host "All downloads completed." -ForegroundColor Green 76 | 77 | Write-Host "Running Repomix on external repositories" 78 | 79 | repomix --remote https://github.com/microsoft/azure-devops-python-api/ --compress -o docs/external/azdo-sdk/repomix-summary-devopssdk.xml --include "azure-devops/azure/devops/v7_1/**" 80 | repomix --remote https://github.com/microsoft/azure-devops-python-api/ --compress -o docs/external/azdo-sdk/repomix-summary-workitems.xml --include "azure-devops/azure/devops/v7_1/work_item_tracking/**,azure-devops/azure/devops/v7_1/work_item_tracking_process/**,azure-devops/azure/devops/v7_1/work_item_tracking_process_tem/**,azure-devops/azure/devops/v7_1/work/**" 81 | repomix --remote https://github.com/microsoft/azure-devops-python-api/ --compress -o docs/external/azdo-sdk/repomix-summary-repositories.xml --include "azure-devops/azure/devops/v7_1/git/**,azure-devops/azure/devops/v7_1/tfvc/**" 82 | repomix --remote https://github.com/microsoft/azure-devops-python-api/ --compress -o docs/external/azdo-sdk/repomix-summary-builds.xml --include "azure-devops/azure/devops/v7_1/build/**,azure-devops/azure/devops/v7_1/pipelines/**,azure-devops/azure/devops/v7_1/task_agent/**,azure-devops/azure/devops/v7_1/release/**" 83 | repomix --remote https://github.com/microsoft/azure-devops-python-api/ --compress -o docs/external/azdo-sdk/repomix-summary-teams.xml --include "azure-devops/azure/devops/v7_1/core/**,azure-devops/azure/devops/v7_1/identity/**,azure-devops/azure/devops/v7_1/graph/**,azure-devops/azure/devops/v7_1/member_entitlement_management/**" 84 | repomix --remote https://github.com/microsoft/azure-devops-python-api/ --compress -o docs/external/azdo-sdk/repomix-summary-core-utils.xml --include "azure-devops/azure/devops/v7_1/core/**,azure-devops/azure/devops/v7_1/client_trace/**" 85 | repomix --remote https://github.com/modelcontextprotocol/servers --compress -o "docs/external/mcp-sdk/repomix-summary-mcpserverexamples.xml" --include "src/sentry/**,src/fetch/**" --ignore "**/*.lock,**/Dockerfile,**/LICENSE" -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "mcp-azure-devops" 7 | description = "A Model Context Protocol (MCP) server for Azure DevOps" 8 | version = "0.6.0" 9 | authors = [ 10 | {name = "Atle H. Havsø", email = "atle@havso.net"}, 11 | ] 12 | requires-python = ">=3.10" 13 | readme = "README.md" 14 | license = {file = "LICENSE"} 15 | dependencies = [ 16 | "azure-devops>=7.1.0b4", 17 | "mcp>=1.9.1", 18 | ] 19 | 20 | [project.scripts] 21 | mcp-azure-devops = "mcp_azure_devops.server:main" 22 | 23 | [project.urls] 24 | "Homepage" = "https://github.com/Vortiago/mcp-azure-devops" 25 | "Bug Tracker" = "https://github.com/Vortiago/mcp-azure-devops/issues" 26 | 27 | [project.optional-dependencies] 28 | dev = [ 29 | "mcp[cli]>=1.9.1", 30 | "pytest>=7.0.0", 31 | "pytest-asyncio>=0.21.0", 32 | "ruff>=0.0.267", 33 | "anyio>=3.6.2", 34 | "pyright>=1.1.401", 35 | ] 36 | 37 | [tool.setuptools] 38 | package-dir = {"" = "src"} 39 | 40 | [tool.pytest.ini_options] 41 | testpaths = ["tests"] 42 | python_files = "test_*.py" 43 | python_functions = "test_*" 44 | 45 | [tool.ruff] 46 | line-length = 79 47 | target-version = "py310" 48 | extend-exclude = ["docs"] 49 | 50 | [tool.ruff.lint] 51 | select = ["E", "F", "I"] 52 | 53 | [tool.pyright] 54 | exclude = [ 55 | "**/node_modules", 56 | "**/__pycache__", 57 | "**/.*", 58 | "docs/", 59 | "pyproject.toml" 60 | ] 61 | 62 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MCP Azure DevOps Server - A Model Context Protocol server for Azure DevOps 3 | integration. 4 | """ 5 | 6 | import importlib.metadata 7 | 8 | try: 9 | __version__ = importlib.metadata.version("mcp-azure-devops") 10 | except importlib.metadata.PackageNotFoundError: 11 | __version__ = "0.0.0" -------------------------------------------------------------------------------- /src/mcp_azure_devops/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main entry point for running the MCP Azure DevOps server as a module. 3 | 4 | This allows running the server with: 5 | python -m mcp_azure_devops 6 | """ 7 | from mcp_azure_devops.server import main 8 | 9 | if __name__ == "__main__": 10 | main() 11 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/__init__.py: -------------------------------------------------------------------------------- 1 | # Azure DevOps MCP features package 2 | from mcp_azure_devops.features import projects, teams, work_items 3 | 4 | 5 | def register_all(mcp): 6 | """ 7 | Register all features with the MCP server. 8 | 9 | Args: 10 | mcp: The FastMCP server instance 11 | """ 12 | work_items.register(mcp) 13 | projects.register(mcp) 14 | teams.register(mcp) 15 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/projects/__init__.py: -------------------------------------------------------------------------------- 1 | # Projects feature package for Azure DevOps MCP 2 | from mcp_azure_devops.features.projects import tools 3 | 4 | 5 | def register(mcp): 6 | """ 7 | Register all projects components with the MCP server. 8 | 9 | Args: 10 | mcp: The FastMCP server instance 11 | """ 12 | tools.register_tools(mcp) 13 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/projects/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common utilities for Azure DevOps projects features. 3 | 4 | This module provides shared functionality used by both tools and resources. 5 | """ 6 | from azure.devops.v7_1.core import CoreClient 7 | 8 | from mcp_azure_devops.utils.azure_client import get_connection 9 | 10 | 11 | class AzureDevOpsClientError(Exception): 12 | """Exception raised for errors in Azure DevOps client operations.""" 13 | pass 14 | 15 | 16 | def get_core_client() -> CoreClient: 17 | """ 18 | Get the core client for Azure DevOps. 19 | 20 | Returns: 21 | CoreClient instance 22 | 23 | Raises: 24 | AzureDevOpsClientError: If connection or client creation fails 25 | """ 26 | # Get connection to Azure DevOps 27 | connection = get_connection() 28 | 29 | if not connection: 30 | raise AzureDevOpsClientError( 31 | "Azure DevOps PAT or organization URL not found in " 32 | "environment variables." 33 | ) 34 | 35 | # Get the core client 36 | core_client = connection.clients.get_core_client() 37 | 38 | if core_client is None: 39 | raise AzureDevOpsClientError("Failed to get core client.") 40 | 41 | return core_client 42 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/projects/tools.py: -------------------------------------------------------------------------------- 1 | """ 2 | Project tools for Azure DevOps. 3 | 4 | This module provides MCP tools for working with Azure DevOps projects. 5 | """ 6 | from typing import Optional 7 | 8 | from azure.devops.v7_1.core import CoreClient 9 | from azure.devops.v7_1.core.models import TeamProjectReference 10 | 11 | from mcp_azure_devops.features.projects.common import ( 12 | AzureDevOpsClientError, 13 | get_core_client, 14 | ) 15 | 16 | 17 | def _format_project(project: TeamProjectReference) -> str: 18 | """ 19 | Format project information. 20 | 21 | Args: 22 | project: Project object to format 23 | 24 | Returns: 25 | String with project details 26 | """ 27 | # Basic information that should always be available 28 | formatted_info = [f"# Project: {project.name}"] 29 | formatted_info.append(f"ID: {project.id}") 30 | 31 | # Add description if available 32 | if hasattr(project, "description") and project.description: 33 | formatted_info.append(f"Description: {project.description}") 34 | 35 | # Add state if available 36 | if hasattr(project, "state") and project.state: 37 | formatted_info.append(f"State: {project.state}") 38 | 39 | # Add visibility if available 40 | if hasattr(project, "visibility") and project.visibility: 41 | formatted_info.append(f"Visibility: {project.visibility}") 42 | 43 | # Add URL if available 44 | if hasattr(project, "url") and project.url: 45 | formatted_info.append(f"URL: {project.url}") 46 | 47 | # Add last update time if available 48 | if hasattr(project, "last_update_time") and project.last_update_time: 49 | formatted_info.append(f"Last Updated: {project.last_update_time}") 50 | 51 | return "\n".join(formatted_info) 52 | 53 | 54 | def _get_projects_impl( 55 | core_client: CoreClient, 56 | state_filter: Optional[str] = None, 57 | top: Optional[int] = None 58 | ) -> str: 59 | """ 60 | Implementation of projects retrieval. 61 | 62 | Args: 63 | core_client: Core client 64 | state_filter: Filter on team projects in a specific state 65 | top: Maximum number of projects to return 66 | 67 | Returns: 68 | Formatted string containing project information 69 | """ 70 | try: 71 | projects = core_client.get_projects(state_filter=state_filter, top=top) 72 | 73 | if not projects: 74 | return "No projects found." 75 | 76 | formatted_projects = [] 77 | for project in projects: 78 | formatted_projects.append(_format_project(project)) 79 | 80 | return "\n\n".join(formatted_projects) 81 | 82 | except Exception as e: 83 | return f"Error retrieving projects: {str(e)}" 84 | 85 | 86 | def register_tools(mcp) -> None: 87 | """ 88 | Register project tools with the MCP server. 89 | 90 | Args: 91 | mcp: The FastMCP server instance 92 | """ 93 | 94 | @mcp.tool() 95 | def get_projects( 96 | state_filter: Optional[str] = None, 97 | top: Optional[int] = None 98 | ) -> str: 99 | """ 100 | Retrieves all projects accessible to the authenticated user 101 | in the Azure DevOps organization. 102 | 103 | Use this tool when you need to: 104 | - Get an overview of all available projects 105 | - Find project IDs for use in other operations 106 | - Check project states and visibility settings 107 | - Locate specific projects by name 108 | 109 | Args: 110 | state_filter: Filter on team projects in a specific state 111 | (e.g., "WellFormed", "Deleting") 112 | top: Maximum number of projects to return 113 | 114 | Returns: 115 | Formatted string containing project information including names, 116 | IDs, descriptions, states, and visibility settings, formatted as 117 | markdown with each project clearly separated 118 | """ 119 | try: 120 | core_client = get_core_client() 121 | return _get_projects_impl(core_client, state_filter, top) 122 | except AzureDevOpsClientError as e: 123 | return f"Error: {str(e)}" 124 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/teams/__init__.py: -------------------------------------------------------------------------------- 1 | # Teams feature package for Azure DevOps MCP 2 | from mcp_azure_devops.features.teams import tools 3 | 4 | 5 | def register(mcp): 6 | """ 7 | Register all teams components with the MCP server. 8 | 9 | Args: 10 | mcp: The FastMCP server instance 11 | """ 12 | tools.register_tools(mcp) 13 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/teams/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common utilities for Azure DevOps teams features. 3 | 4 | This module provides shared functionality used by both tools and resources. 5 | """ 6 | from azure.devops.v7_1.core import CoreClient 7 | from azure.devops.v7_1.work import WorkClient 8 | 9 | from mcp_azure_devops.utils.azure_client import get_connection 10 | 11 | 12 | class AzureDevOpsClientError(Exception): 13 | """Exception raised for errors in Azure DevOps client operations.""" 14 | pass 15 | 16 | 17 | def get_core_client() -> CoreClient: 18 | """ 19 | Get the core client for Azure DevOps. 20 | 21 | Returns: 22 | CoreClient instance 23 | 24 | Raises: 25 | AzureDevOpsClientError: If connection or client creation fails 26 | """ 27 | # Get connection to Azure DevOps 28 | connection = get_connection() 29 | 30 | if not connection: 31 | raise AzureDevOpsClientError( 32 | "Azure DevOps PAT or organization URL not found in " 33 | "environment variables." 34 | ) 35 | 36 | # Get the core client 37 | core_client = connection.clients.get_core_client() 38 | 39 | if core_client is None: 40 | raise AzureDevOpsClientError("Failed to get core client.") 41 | 42 | return core_client 43 | 44 | 45 | def get_work_client() -> WorkClient: 46 | """ 47 | Get the work client for Azure DevOps. 48 | 49 | Returns: 50 | WorkClient instance 51 | 52 | Raises: 53 | AzureDevOpsClientError: If connection or client creation fails 54 | """ 55 | # Get connection to Azure DevOps 56 | connection = get_connection() 57 | 58 | if not connection: 59 | raise AzureDevOpsClientError( 60 | "Azure DevOps PAT or organization URL not found in " 61 | "environment variables." 62 | ) 63 | 64 | # Get the work client 65 | work_client = connection.clients.get_work_client() 66 | 67 | if work_client is None: 68 | raise AzureDevOpsClientError("Failed to get work client.") 69 | 70 | return work_client 71 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/teams/tools.py: -------------------------------------------------------------------------------- 1 | """ 2 | Team tools for Azure DevOps. 3 | 4 | This module provides MCP tools for working with Azure DevOps teams. 5 | """ 6 | from typing import Optional 7 | 8 | from azure.devops.v7_1.core import CoreClient 9 | from azure.devops.v7_1.core.models import WebApiTeam 10 | from azure.devops.v7_1.work.models import TeamContext 11 | 12 | from mcp_azure_devops.features.teams.common import ( 13 | AzureDevOpsClientError, 14 | get_core_client, 15 | get_work_client, 16 | ) 17 | 18 | 19 | def _format_team(team: WebApiTeam) -> str: 20 | """ 21 | Format team information. 22 | 23 | Args: 24 | team: Team object to format 25 | 26 | Returns: 27 | String with team details 28 | """ 29 | # Basic information that should always be available 30 | formatted_info = [f"# Team: {team.name}"] 31 | formatted_info.append(f"ID: {team.id}") 32 | 33 | # Add description if available 34 | if hasattr(team, "description") and team.description: 35 | formatted_info.append(f"Description: {team.description}") 36 | 37 | # Add project information if available 38 | if hasattr(team, "project_name") and team.project_name: 39 | formatted_info.append(f"Project: {team.project_name}") 40 | 41 | if hasattr(team, "project_id") and team.project_id: 42 | formatted_info.append(f"Project ID: {team.project_id}") 43 | 44 | 45 | return "\n".join(formatted_info) 46 | 47 | 48 | def _format_team_member(team_member) -> str: 49 | """ 50 | Format team member information. 51 | 52 | Args: 53 | team_member: Team member object to format 54 | 55 | Returns: 56 | String with team member details 57 | """ 58 | formatted_info = [] 59 | 60 | # Get identity information 61 | if hasattr(team_member, "identity") and team_member.identity: 62 | identity = team_member.identity 63 | # Use display name if available, otherwise use ID 64 | if hasattr(identity, "display_name") and identity.display_name: 65 | formatted_info.append(f"# Member: {identity.display_name}") 66 | else: 67 | formatted_info.append(f"# Member ID: {identity.id}") 68 | 69 | # Add ID 70 | if hasattr(identity, "id") and identity.id: 71 | formatted_info.append(f"ID: {identity.id}") 72 | 73 | # Add descriptor 74 | if hasattr(identity, "descriptor") and identity.descriptor: 75 | formatted_info.append(f"Descriptor: {identity.descriptor}") 76 | 77 | # Add unique name (email/username) 78 | if hasattr(identity, "unique_name") and identity.unique_name: 79 | formatted_info.append(f"Email/Username: {identity.unique_name}") 80 | else: 81 | formatted_info.append("# Unknown Member") 82 | 83 | # Add team admin status 84 | if hasattr(team_member, "is_team_admin"): 85 | is_admin = "Yes" if team_member.is_team_admin else "No" 86 | formatted_info.append(f"Team Administrator: {is_admin}") 87 | 88 | return "\n".join(formatted_info) 89 | 90 | 91 | def _format_team_area_path(team_field_values) -> str: 92 | """ 93 | Format team area path information. 94 | 95 | Args: 96 | team_field_values: Team field values object to format 97 | 98 | Returns: 99 | String with team area path details 100 | """ 101 | formatted_info = ["# Team Area Paths"] 102 | 103 | # Add default area path 104 | if (hasattr(team_field_values, "default_value") and 105 | team_field_values.default_value): 106 | formatted_info.append( 107 | f"Default Area Path: {team_field_values.default_value}") 108 | 109 | # Add all area paths 110 | if hasattr(team_field_values, "values") and team_field_values.values: 111 | formatted_info.append("\n## All Area Paths:") 112 | for area_path in team_field_values.values: 113 | value_str = f"- {area_path.value}" 114 | if (hasattr(area_path, "include_children") and 115 | area_path.include_children): 116 | value_str += " (Including sub-areas)" 117 | formatted_info.append(value_str) 118 | 119 | return "\n".join(formatted_info) 120 | 121 | 122 | def _format_team_iteration(iteration) -> str: 123 | """ 124 | Format team iteration information. 125 | 126 | Args: 127 | iteration: Team iteration object to format 128 | 129 | Returns: 130 | String with team iteration details 131 | """ 132 | formatted_info = [f"# Iteration: {iteration.name}"] 133 | 134 | # Add ID 135 | if hasattr(iteration, "id") and iteration.id: 136 | formatted_info.append(f"ID: {iteration.id}") 137 | 138 | # Add path 139 | if hasattr(iteration, "path") and iteration.path: 140 | formatted_info.append(f"Path: {iteration.path}") 141 | 142 | # Add attributes if available 143 | if hasattr(iteration, "attributes") and iteration.attributes: 144 | attributes = iteration.attributes 145 | 146 | # Add start date 147 | if hasattr(attributes, "start_date") and attributes.start_date: 148 | formatted_info.append(f"Start Date: {attributes.start_date}") 149 | 150 | # Add finish date 151 | if hasattr(attributes, "finish_date") and attributes.finish_date: 152 | formatted_info.append(f"Finish Date: {attributes.finish_date}") 153 | 154 | # Add time frame 155 | if hasattr(attributes, "time_frame") and attributes.time_frame: 156 | formatted_info.append(f"Time Frame: {attributes.time_frame}") 157 | 158 | return "\n".join(formatted_info) 159 | 160 | 161 | def _get_all_teams_impl( 162 | core_client: CoreClient, 163 | user_is_member_of: Optional[bool] = None, 164 | top: Optional[int] = None, 165 | skip: Optional[int] = None, 166 | expand_identity: Optional[bool] = None 167 | ) -> str: 168 | """ 169 | Implementation of teams retrieval. 170 | 171 | Args: 172 | core_client: Core client 173 | user_is_member_of: If true, then return all teams requesting user is 174 | member. Otherwise return all teams user has read 175 | access. 176 | top: Maximum number of teams to return 177 | skip: Number of teams to skip 178 | 179 | Returns: 180 | Formatted string containing team information 181 | """ 182 | try: 183 | # Call the SDK function - note we're mapping user_is_member_of to mine 184 | # param 185 | teams = core_client.get_all_teams( 186 | mine=user_is_member_of, 187 | top=top, 188 | skip=skip 189 | ) 190 | 191 | if not teams: 192 | return "No teams found." 193 | 194 | formatted_teams = [] 195 | for team in teams: 196 | formatted_teams.append(_format_team(team)) 197 | 198 | return "\n\n".join(formatted_teams) 199 | 200 | except Exception as e: 201 | return f"Error retrieving teams: {str(e)}" 202 | 203 | 204 | def _get_team_members_impl( 205 | core_client: CoreClient, 206 | project_id: str, 207 | team_id: str, 208 | top: Optional[int] = None, 209 | skip: Optional[int] = None 210 | ) -> str: 211 | """ 212 | Implementation of team members retrieval. 213 | 214 | Args: 215 | core_client: Core client 216 | project_id: The name or ID (GUID) of the team project the team 217 | belongs to 218 | team_id: The name or ID (GUID) of the team 219 | top: Maximum number of members to return 220 | skip: Number of members to skip 221 | 222 | Returns: 223 | Formatted string containing team members information 224 | """ 225 | try: 226 | team_members = core_client.get_team_members_with_extended_properties( 227 | project_id=project_id, 228 | team_id=team_id, 229 | top=top, 230 | skip=skip 231 | ) 232 | 233 | if not team_members: 234 | return (f"No members found for team {team_id} in " 235 | f"project {project_id}.") 236 | 237 | formatted_members = [] 238 | for member in team_members: 239 | formatted_members.append(_format_team_member(member)) 240 | 241 | return "\n\n".join(formatted_members) 242 | 243 | except Exception as e: 244 | return f"Error retrieving team members: {str(e)}" 245 | 246 | 247 | def _get_team_area_paths_impl( 248 | work_client, 249 | project_name_or_id: str, 250 | team_name_or_id: str 251 | ) -> str: 252 | """ 253 | Implementation of team area paths retrieval. 254 | 255 | Args: 256 | work_client: Work client 257 | project_name_or_id: The name or ID of the team project 258 | team_name_or_id: The name or ID of the team 259 | 260 | Returns: 261 | Formatted string containing team area path information 262 | """ 263 | try: 264 | # Create a TeamContext object 265 | team_context = TeamContext( 266 | project=project_name_or_id, 267 | team=team_name_or_id 268 | ) 269 | 270 | # Get the team field values 271 | team_field_values = work_client.get_team_field_values(team_context) 272 | 273 | if not team_field_values: 274 | return (f"No area paths found for team {team_name_or_id} " 275 | f"in project {project_name_or_id}.") 276 | 277 | return _format_team_area_path(team_field_values) 278 | 279 | except Exception as e: 280 | return f"Error retrieving team area paths: {str(e)}" 281 | 282 | 283 | def _get_team_iterations_impl( 284 | work_client, 285 | project_name_or_id: str, 286 | team_name_or_id: str, 287 | current: Optional[bool] = None 288 | ) -> str: 289 | """ 290 | Implementation of team iterations retrieval. 291 | 292 | Args: 293 | work_client: Work client 294 | project_name_or_id: The name or ID of the team project 295 | team_name_or_id: The name or ID of the team 296 | current: If True, return only the current iteration 297 | 298 | Returns: 299 | Formatted string containing team iteration information 300 | """ 301 | try: 302 | # Create a TeamContext object 303 | team_context = TeamContext( 304 | project=project_name_or_id, 305 | team=team_name_or_id 306 | ) 307 | 308 | # Set timeframe parameter if current is True 309 | timeframe = "Current" if current else None 310 | 311 | # Get the team iterations 312 | team_iterations = work_client.get_team_iterations( 313 | team_context=team_context, 314 | timeframe=timeframe 315 | ) 316 | 317 | if not team_iterations: 318 | return (f"No iterations found for team {team_name_or_id} " 319 | f"in project {project_name_or_id}.") 320 | 321 | formatted_iterations = [] 322 | for iteration in team_iterations: 323 | formatted_iterations.append(_format_team_iteration(iteration)) 324 | 325 | return "\n\n".join(formatted_iterations) 326 | 327 | except Exception as e: 328 | return f"Error retrieving team iterations: {str(e)}" 329 | 330 | 331 | def register_tools(mcp) -> None: 332 | """ 333 | Register team tools with the MCP server. 334 | 335 | Args: 336 | mcp: The FastMCP server instance 337 | """ 338 | 339 | @mcp.tool() 340 | def get_all_teams( 341 | user_is_member_of: Optional[bool] = None, 342 | top: Optional[int] = None, 343 | skip: Optional[int] = None 344 | ) -> str: 345 | """ 346 | Retrieves all teams in the Azure DevOps organization. 347 | 348 | Use this tool when you need to: 349 | - Get an overview of all teams across projects 350 | - Find team IDs for use in other operations 351 | - Determine which teams exist in the organization 352 | - Locate specific teams by name 353 | 354 | Args: 355 | user_is_member_of: If true, return only teams where the current 356 | user is a member. Otherwise return all teams the user 357 | has read access to. 358 | top: Maximum number of teams to return 359 | skip: Number of teams to skip 360 | 361 | Returns: 362 | Formatted string containing team information including names, 363 | IDs, descriptions, and associated projects, formatted as 364 | markdown with each team clearly separated 365 | """ 366 | try: 367 | core_client = get_core_client() 368 | return _get_all_teams_impl( 369 | core_client, 370 | user_is_member_of, 371 | top, 372 | skip 373 | ) 374 | except AzureDevOpsClientError as e: 375 | return f"Error: {str(e)}" 376 | 377 | @mcp.tool() 378 | def get_team_members( 379 | project_id: str, 380 | team_id: str, 381 | top: Optional[int] = None, 382 | skip: Optional[int] = None 383 | ) -> str: 384 | """ 385 | Retrieves the membership roster for a specific team. 386 | 387 | Use this tool when you need to: 388 | - See who belongs to a particular team 389 | - Find team administrators 390 | - Check user assignments across teams 391 | - Determine team size and composition 392 | 393 | Args: 394 | project_id: The name or ID (GUID) of the team project the team 395 | belongs to 396 | team_id: The name or ID (GUID) of the team 397 | top: Maximum number of members to return 398 | skip: Number of members to skip 399 | 400 | Returns: 401 | Formatted string containing team members information including 402 | display names, emails, IDs, and administrator status, formatted 403 | as markdown with each member clearly separated 404 | """ 405 | try: 406 | core_client = get_core_client() 407 | return _get_team_members_impl( 408 | core_client, 409 | project_id, 410 | team_id, 411 | top, 412 | skip 413 | ) 414 | except AzureDevOpsClientError as e: 415 | return f"Error: {str(e)}" 416 | 417 | @mcp.tool() 418 | def get_team_area_paths( 419 | project_name_or_id: str, 420 | team_name_or_id: str 421 | ) -> str: 422 | """ 423 | Retrieves the area paths assigned to a specific team. 424 | 425 | Use this tool when you need to: 426 | - Understand a team's areas of responsibility 427 | - Check default area path assignments 428 | - Determine how work is classified and routed to teams 429 | - Set up board and backlog configurations 430 | 431 | IMPORTANT: Area paths in Azure DevOps determine which work items appear 432 | on a team's backlogs and boards. The default area path is used when 433 | creating new work items through a team's interface. 434 | 435 | Args: 436 | project_name_or_id: The name or ID of the team project 437 | team_name_or_id: The name or ID of the team 438 | 439 | Returns: 440 | Formatted string containing team area path information including 441 | the default area path and all assigned paths, with indicators for 442 | paths that include sub-areas 443 | """ 444 | try: 445 | work_client = get_work_client() 446 | return _get_team_area_paths_impl( 447 | work_client, 448 | project_name_or_id, 449 | team_name_or_id 450 | ) 451 | except AzureDevOpsClientError as e: 452 | return f"Error: {str(e)}" 453 | 454 | @mcp.tool() 455 | def get_team_iterations( 456 | project_name_or_id: str, 457 | team_name_or_id: str, 458 | current: Optional[bool] = None 459 | ) -> str: 460 | """ 461 | Retrieves the iterations (sprints) assigned to a specific team. 462 | 463 | Use this tool when you need to: 464 | - View a team's sprint schedule 465 | - Find date ranges for iterations 466 | - Determine which iteration is currently active 467 | - Plan work based on team's iteration calendar 468 | 469 | IMPORTANT: Iterations in Azure DevOps define time periods for planning 470 | and tracking work. They determine sprint dates and are used for 471 | capacity planning, burndown charts, and velocity calculations. 472 | 473 | Args: 474 | project_name_or_id: The name or ID of the team project 475 | team_name_or_id: The name or ID of the team 476 | current: If True, return only the current iteration 477 | 478 | Returns: 479 | Formatted string containing team iteration information including 480 | names, date ranges, and time frames (past/current/future), 481 | formatted as markdown 482 | """ 483 | try: 484 | work_client = get_work_client() 485 | return _get_team_iterations_impl( 486 | work_client, 487 | project_name_or_id, 488 | team_name_or_id, 489 | current 490 | ) 491 | except AzureDevOpsClientError as e: 492 | return f"Error: {str(e)}" 493 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/work_items/__init__.py: -------------------------------------------------------------------------------- 1 | # Work items feature package for Azure DevOps MCP 2 | from mcp_azure_devops.features.work_items import tools 3 | 4 | 5 | def register(mcp): 6 | """ 7 | Register all work items components with the MCP server. 8 | 9 | Args: 10 | mcp: The FastMCP server instance 11 | """ 12 | tools.register_tools(mcp) 13 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/work_items/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common utilities for Azure DevOps work item features. 3 | 4 | This module provides shared functionality used by both tools and resources. 5 | """ 6 | from azure.devops.v7_1.work_item_tracking import WorkItemTrackingClient 7 | 8 | from mcp_azure_devops.utils.azure_client import get_connection 9 | 10 | 11 | class AzureDevOpsClientError(Exception): 12 | """Exception raised for errors in Azure DevOps client operations.""" 13 | pass 14 | 15 | 16 | def get_work_item_client() -> WorkItemTrackingClient: 17 | """ 18 | Get the work item tracking client. 19 | 20 | Returns: 21 | WorkItemTrackingClient instance 22 | 23 | Raises: 24 | AzureDevOpsClientError: If connection or client creation fails 25 | """ 26 | # Get connection to Azure DevOps 27 | connection = get_connection() 28 | 29 | if not connection: 30 | raise AzureDevOpsClientError( 31 | "Azure DevOps PAT or organization URL not found in " 32 | "environment variables." 33 | ) 34 | 35 | # Get the work item tracking client 36 | wit_client = connection.clients.get_work_item_tracking_client() 37 | 38 | if wit_client is None: 39 | raise AzureDevOpsClientError( 40 | "Failed to get work item tracking client.") 41 | 42 | return wit_client 43 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/work_items/formatting.py: -------------------------------------------------------------------------------- 1 | """ 2 | Formatting utilities for Azure DevOps work items. 3 | 4 | This module provides functions to format work items for display. 5 | """ 6 | from azure.devops.v7_1.work_item_tracking.models import WorkItem 7 | 8 | 9 | def _format_field_value(field_value) -> str: 10 | """ 11 | Format a field value based on its type. 12 | 13 | Args: 14 | field_value: The value to format 15 | 16 | Returns: 17 | Formatted string representation of the value 18 | """ 19 | if field_value is None: 20 | return "None" 21 | elif isinstance(field_value, dict): 22 | # Handle dictionary fields like people references 23 | if 'displayName' in field_value: 24 | return (f"{field_value.get('displayName')} " 25 | f"({field_value.get('uniqueName', '')})") 26 | else: 27 | # For other dictionaries, format as key-value pairs 28 | return ", ".join([f"{k}: {v}" for k, v in field_value.items()]) 29 | elif (hasattr(field_value, 'display_name') and 30 | hasattr(field_value, 'unique_name')): 31 | # Handle objects with display_name and unique_name 32 | return f"{field_value.display_name} ({field_value.unique_name})" 33 | elif hasattr(field_value, 'display_name'): 34 | # Handle objects with just display_name 35 | return field_value.display_name 36 | else: 37 | # For everything else, use string representation 38 | return str(field_value) 39 | 40 | 41 | def _format_board_info(fields: dict) -> list[str]: 42 | """ 43 | Format board-related information for the work item. 44 | 45 | Args: 46 | fields: Dictionary of work item fields 47 | 48 | Returns: 49 | List of strings with board information 50 | """ 51 | board_info = [] 52 | 53 | # Add board column (if available) 54 | if "System.BoardColumn" in fields: 55 | board_info.append(f"Board Column: {fields['System.BoardColumn']}") 56 | 57 | # Add board column done state (if available) 58 | if "System.BoardColumnDone" in fields: 59 | done_state = ("Done" if fields["System.BoardColumnDone"] 60 | else "Not Done") 61 | board_info.append(f"Column State: {done_state}") 62 | 63 | return board_info 64 | 65 | 66 | def _format_build_info(fields: dict) -> list[str]: 67 | """ 68 | Format build-related information for the work item. 69 | 70 | Args: 71 | fields: Dictionary of work item fields 72 | 73 | Returns: 74 | List of strings with build information 75 | """ 76 | build_info = [] 77 | 78 | # Add found in build (if available) 79 | if "Microsoft.VSTS.Build.FoundIn" in fields: 80 | build_info.append( 81 | f"Found In: {fields['Microsoft.VSTS.Build.FoundIn']}") 82 | 83 | # Add integration build (if available) 84 | if "Microsoft.VSTS.Build.IntegrationBuild" in fields: 85 | build_info.append( 86 | f"Integration Build: " 87 | f"{fields['Microsoft.VSTS.Build.IntegrationBuild']}") 88 | 89 | return build_info 90 | 91 | 92 | def format_work_item(work_item: WorkItem) -> str: 93 | """ 94 | Format work item information for display. 95 | 96 | Args: 97 | work_item: Work item object to format 98 | 99 | Returns: 100 | String with formatted work item details 101 | """ 102 | fields = work_item.fields or {} 103 | details = [f"# Work Item {work_item.id}"] 104 | 105 | # List all fields alphabetically for consistent output 106 | for field_name in sorted(fields.keys()): 107 | field_value = fields[field_name] 108 | formatted_value = _format_field_value(field_value) 109 | details.append(f"- **{field_name}**: {formatted_value}") 110 | 111 | # Add related items if available 112 | if hasattr(work_item, 'relations') and work_item.relations: 113 | details.append("\n## Related Items") 114 | for link in work_item.relations: 115 | details.append(f"- {link.rel} URL: {link.url}") 116 | if hasattr(link, 'attributes') and link.attributes: 117 | details.append(f" :: Attributes: {link.attributes}") 118 | 119 | return "\n".join(details) 120 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/work_items/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Work item tools for Azure DevOps. 3 | """ 4 | from mcp_azure_devops.features.work_items.tools import ( 5 | comments, 6 | create, 7 | process, 8 | query, 9 | read, 10 | templates, 11 | types, 12 | ) 13 | 14 | 15 | def register_tools(mcp) -> None: 16 | """ 17 | Register all work item tools with the MCP server. 18 | 19 | Args: 20 | mcp: The FastMCP server instance 21 | """ 22 | query.register_tools(mcp) 23 | read.register_tools(mcp) 24 | comments.register_tools(mcp) 25 | create.register_tools(mcp) 26 | types.register_tools(mcp) 27 | templates.register_tools(mcp) 28 | process.register_tools(mcp) 29 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/work_items/tools/comments.py: -------------------------------------------------------------------------------- 1 | """ 2 | Comment operations for Azure DevOps work items. 3 | 4 | This module provides MCP tools for retrieving and adding work item comments. 5 | """ 6 | from typing import Optional 7 | 8 | from azure.devops.v7_1.work_item_tracking import WorkItemTrackingClient 9 | from azure.devops.v7_1.work_item_tracking.models import CommentCreate 10 | 11 | from mcp_azure_devops.features.work_items.common import ( 12 | AzureDevOpsClientError, 13 | get_work_item_client, 14 | ) 15 | 16 | 17 | def _format_comment(comment) -> str: 18 | """ 19 | Format a work item comment for display. 20 | 21 | Args: 22 | comment: Comment object to format 23 | 24 | Returns: 25 | Formatted string representation of the comment 26 | """ 27 | # Format the date if available 28 | created_date = "" 29 | if hasattr(comment, 'created_date') and comment.created_date: 30 | created_date = f" on {comment.created_date}" 31 | 32 | # Format the author if available 33 | author = "Unknown" 34 | if hasattr(comment, 'created_by') and comment.created_by: 35 | if (hasattr(comment.created_by, 'display_name') and 36 | comment.created_by.display_name): 37 | author = comment.created_by.display_name 38 | 39 | # Format the comment text 40 | text = "No text" 41 | if hasattr(comment, 'text') and comment.text: 42 | text = comment.text 43 | 44 | return f"## Comment by {author}{created_date}:\n{text}" 45 | 46 | 47 | def _get_project_for_work_item( 48 | item_id: int, 49 | wit_client: WorkItemTrackingClient 50 | ) -> Optional[str]: 51 | """ 52 | Get the project name for a work item. 53 | 54 | Args: 55 | item_id: The work item ID 56 | wit_client: Work item tracking client 57 | 58 | Returns: 59 | Project name or None if not found 60 | """ 61 | try: 62 | work_item = wit_client.get_work_item(item_id) 63 | if work_item and work_item.fields: 64 | return work_item.fields.get("System.TeamProject") 65 | except Exception: 66 | pass 67 | 68 | return None 69 | 70 | 71 | def _get_work_item_comments_impl( 72 | item_id: int, 73 | wit_client: WorkItemTrackingClient, 74 | project: Optional[str] = None 75 | ) -> str: 76 | """ 77 | Implementation of work item comments retrieval. 78 | 79 | Args: 80 | item_id: The work item ID 81 | wit_client: Work item tracking client 82 | project: Optional project name 83 | 84 | Returns: 85 | Formatted string containing work item comments 86 | """ 87 | # If project is not provided, try to get it from the work item 88 | if not project: 89 | project = _get_project_for_work_item(item_id, wit_client) 90 | 91 | if not project: 92 | return f"Error retrieving work item {item_id} to determine project" 93 | 94 | # Get comments using the project if available 95 | comments = wit_client.get_comments(project=project, work_item_id=item_id) 96 | 97 | # Format the comments 98 | formatted_comments = [ 99 | _format_comment(comment) for comment in comments.comments 100 | ] 101 | 102 | if not formatted_comments: 103 | return "No comments found for this work item." 104 | 105 | return "\n\n".join(formatted_comments) 106 | 107 | 108 | def _add_work_item_comment_impl( 109 | item_id: int, 110 | text: str, 111 | wit_client: WorkItemTrackingClient, 112 | project: Optional[str] = None 113 | ) -> str: 114 | """ 115 | Implementation of work item comment addition. 116 | 117 | Args: 118 | item_id: The work item ID 119 | text: Comment text to add 120 | wit_client: Work item tracking client 121 | project: Optional project name 122 | 123 | Returns: 124 | Formatted string containing the added comment 125 | """ 126 | # If project is not provided, try to get it from the work item 127 | if not project: 128 | project = _get_project_for_work_item(item_id, wit_client) 129 | 130 | if not project: 131 | return f"Error retrieving work item {item_id} to determine project" 132 | 133 | # Create comment request 134 | comment_request = CommentCreate(text=text) 135 | 136 | # Add the comment 137 | new_comment = wit_client.add_comment( 138 | request=comment_request, 139 | project=project, 140 | work_item_id=item_id 141 | ) 142 | 143 | return f"Comment added successfully.\n\n{_format_comment(new_comment)}" 144 | 145 | 146 | def register_tools(mcp) -> None: 147 | """ 148 | Register work item comment tools with the MCP server. 149 | 150 | Args: 151 | mcp: The FastMCP server instance 152 | """ 153 | 154 | @mcp.tool() 155 | def get_work_item_comments( 156 | id: int, 157 | project: Optional[str] = None 158 | ) -> str: 159 | """ 160 | Retrieves all comments associated with a specific work item. 161 | 162 | Use this tool when you need to: 163 | - Review discussion history about a work item 164 | - See feedback or notes left by team members 165 | - Check if specific questions have been answered 166 | - Understand the context and evolution of a work item 167 | 168 | Args: 169 | id: The work item ID 170 | project: Optional project name. If not provided, will be 171 | determined from the work item. 172 | 173 | Returns: 174 | Formatted string containing all comments on the work item, 175 | including author names, timestamps, and content, organized 176 | chronologically and formatted as markdown 177 | """ 178 | try: 179 | wit_client = get_work_item_client() 180 | return _get_work_item_comments_impl(id, wit_client, project) 181 | except AzureDevOpsClientError as e: 182 | return f"Error: {str(e)}" 183 | 184 | 185 | @mcp.tool() 186 | def add_work_item_comment( 187 | id: int, 188 | text: str, 189 | project: Optional[str] = None 190 | ) -> str: 191 | """ 192 | Adds a new comment to a work item. 193 | 194 | Use this tool when you need to: 195 | - Provide feedback or clarification on a work item 196 | - Document decisions made about the work 197 | - Add context without changing the work item's fields 198 | - Communicate with team members about specific tasks 199 | 200 | IMPORTANT: Comments in Azure DevOps become part of the permanent work 201 | item history and cannot be edited or deleted after they are added. The 202 | comment will be attributed to the user associated with the Personal 203 | Access Token used for authentication. 204 | 205 | Args: 206 | id: The work item ID 207 | text: The text of the comment (supports markdown formatting) 208 | project: Optional project name. If not provided, will be 209 | determined from the work item. 210 | 211 | Returns: 212 | Formatted string containing confirmation and the added comment with 213 | author information and timestamp 214 | """ 215 | try: 216 | wit_client = get_work_item_client() 217 | return _add_work_item_comment_impl(id, text, wit_client, project) 218 | except AzureDevOpsClientError as e: 219 | return f"Error: {str(e)}" 220 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/work_items/tools/create.py: -------------------------------------------------------------------------------- 1 | """ 2 | Create operations for Azure DevOps work items. 3 | 4 | This module provides MCP tools for creating work items. 5 | """ 6 | import os 7 | from typing import Any, Dict, Optional 8 | 9 | from azure.devops.v7_1.work_item_tracking import WorkItemTrackingClient 10 | from azure.devops.v7_1.work_item_tracking.models import JsonPatchOperation 11 | 12 | from mcp_azure_devops.features.work_items.common import ( 13 | AzureDevOpsClientError, 14 | get_work_item_client, 15 | ) 16 | from mcp_azure_devops.features.work_items.formatting import format_work_item 17 | 18 | 19 | def _build_field_document(fields: Dict[str, Any], 20 | operation: str = "add") -> list: 21 | """ 22 | Build a document of JsonPatchOperations from a dictionary of fields. 23 | 24 | Args: 25 | fields: Dictionary of field name/value pairs 26 | operation: The operation to perform (add or replace) 27 | 28 | Returns: 29 | List of JsonPatchOperation objects 30 | """ 31 | document = [] 32 | 33 | for field_name, field_value in fields.items(): 34 | # Ensure field names are prefixed with /fields/ 35 | path = (field_name if field_name.startswith("/fields/") 36 | else f"/fields/{field_name}") 37 | 38 | document.append( 39 | JsonPatchOperation( 40 | op=operation, 41 | path=path, 42 | value=field_value 43 | ) 44 | ) 45 | 46 | return document 47 | 48 | 49 | def _get_organization_url() -> str: 50 | """ 51 | Get the Azure DevOps organization URL from environment variables. 52 | 53 | Returns: 54 | Organization URL string 55 | """ 56 | return os.environ.get("AZURE_DEVOPS_ORGANIZATION_URL", "").rstrip('/') 57 | 58 | 59 | def _build_link_document(target_id: int, link_type: str, org_url: str) -> list: 60 | """ 61 | Build a document for creating a link between work items. 62 | 63 | Args: 64 | target_id: ID of the target work item to link to 65 | link_type: Type of link to create 66 | org_url: Base organization URL 67 | 68 | Returns: 69 | List of JsonPatchOperation objects 70 | """ 71 | return [ 72 | JsonPatchOperation( 73 | op="add", 74 | path="/relations/-", 75 | value={ 76 | "rel": link_type, 77 | "url": f"{org_url}/_apis/wit/workItems/{target_id}" 78 | } 79 | ) 80 | ] 81 | 82 | 83 | def _create_work_item_impl( 84 | fields: Dict[str, Any], 85 | project: str, 86 | work_item_type: str, 87 | wit_client: WorkItemTrackingClient, 88 | parent_id: Optional[int] = None, 89 | ) -> str: 90 | """ 91 | Implementation of creating a work item. 92 | 93 | Args: 94 | fields: Dictionary of field name/value pairs to set 95 | project: The project name or ID 96 | work_item_type: Type of work item (e.g., "User Story", "Bug", "Task") 97 | wit_client: Work item tracking client 98 | parent_id: Optional ID of parent work item for hierarchy 99 | 100 | Returns: 101 | Formatted string containing the created work item details 102 | """ 103 | document = _build_field_document(fields) 104 | 105 | # Create the work item 106 | new_work_item = wit_client.create_work_item( 107 | document=document, 108 | project=project, 109 | type=work_item_type 110 | ) 111 | 112 | # If parent_id is provided, establish parent-child relationship 113 | if parent_id: 114 | try: 115 | # Get organization URL from environment 116 | org_url = _get_organization_url() 117 | 118 | # Create parent-child relationship 119 | link_document = _build_link_document( 120 | target_id=parent_id, 121 | link_type="System.LinkTypes.Hierarchy-Reverse", 122 | org_url=org_url 123 | ) 124 | 125 | # Update the work item to add the parent link 126 | new_work_item = wit_client.update_work_item( 127 | document=link_document, 128 | id=new_work_item.id, 129 | project=project 130 | ) 131 | except Exception as e: 132 | return (f"Work item created successfully, but failed to establish " 133 | f"parent-child relationship: {str(e)}\n\n" 134 | f"{format_work_item(new_work_item)}") 135 | 136 | # Format and return the created work item 137 | return format_work_item(new_work_item) 138 | 139 | 140 | def _update_work_item_impl( 141 | id: int, 142 | fields: Dict[str, Any], 143 | wit_client: WorkItemTrackingClient, 144 | project: Optional[str] = None, 145 | ) -> str: 146 | """ 147 | Implementation of updating a work item. 148 | 149 | Args: 150 | id: The ID of the work item to update 151 | fields: Dictionary of field name/value pairs to update 152 | wit_client: Work item tracking client 153 | project: Optional project name or ID 154 | 155 | Returns: 156 | Formatted string containing the updated work item details 157 | """ 158 | document = _build_field_document(fields, "replace") 159 | 160 | # Update the work item 161 | updated_work_item = wit_client.update_work_item( 162 | document=document, 163 | id=id, 164 | project=project 165 | ) 166 | 167 | return format_work_item(updated_work_item) 168 | 169 | 170 | def _add_link_to_work_item_impl( 171 | source_id: int, 172 | target_id: int, 173 | link_type: str, 174 | wit_client: WorkItemTrackingClient, 175 | project: Optional[str] = None, 176 | ) -> str: 177 | """ 178 | Implementation of adding a link between work items. 179 | 180 | Args: 181 | source_id: ID of the source work item 182 | target_id: ID of the target work item 183 | link_type: Type of link to create 184 | wit_client: Work item tracking client 185 | project: Optional project name or ID 186 | 187 | Returns: 188 | Formatted string containing the updated work item details 189 | """ 190 | # Get organization URL from environment 191 | org_url = _get_organization_url() 192 | 193 | # Build link document with the full URL 194 | link_document = _build_link_document(target_id, link_type, org_url) 195 | 196 | # Update the work item to add the link 197 | updated_work_item = wit_client.update_work_item( 198 | document=link_document, 199 | id=source_id, 200 | project=project 201 | ) 202 | 203 | return format_work_item(updated_work_item) 204 | 205 | 206 | def _prepare_standard_fields( 207 | title: Optional[str] = None, 208 | description: Optional[str] = None, 209 | state: Optional[str] = None, 210 | assigned_to: Optional[str] = None, 211 | iteration_path: Optional[str] = None, 212 | area_path: Optional[str] = None, 213 | story_points: Optional[float] = None, 214 | priority: Optional[int] = None, 215 | tags: Optional[str] = None, 216 | ) -> Dict[str, Any]: 217 | """ 218 | Prepare fields dictionary with standard fields for work item 219 | creation/update. 220 | 221 | Args: 222 | title: The title of the work item 223 | description: Optional description of the work item 224 | state: Optional initial state for the work item 225 | assigned_to: Optional user email to assign the work item to 226 | iteration_path: Optional iteration path for the work item 227 | area_path: Optional area path for the work item 228 | story_points: Optional story points value 229 | priority: Optional priority value 230 | tags: Optional tags as comma-separated string 231 | 232 | Returns: 233 | Dictionary of field name/value pairs 234 | """ 235 | fields = {} 236 | 237 | if title: 238 | fields["System.Title"] = title 239 | 240 | if description: 241 | fields["System.Description"] = description 242 | 243 | if state: 244 | fields["System.State"] = state 245 | 246 | if assigned_to: 247 | fields["System.AssignedTo"] = assigned_to 248 | 249 | if iteration_path: 250 | fields["System.IterationPath"] = iteration_path 251 | 252 | if area_path: 253 | fields["System.AreaPath"] = area_path 254 | 255 | if story_points is not None: 256 | # Convert float to string to avoid type errors 257 | fields["Microsoft.VSTS.Scheduling.StoryPoints"] = str(story_points) 258 | 259 | if priority is not None: 260 | fields["Microsoft.VSTS.Common.Priority"] = str(priority) 261 | 262 | if tags: 263 | fields["System.Tags"] = tags 264 | 265 | return fields 266 | 267 | 268 | def _ensure_system_prefix(field_name: str) -> str: 269 | """ 270 | Ensure field names have appropriate prefix. 271 | 272 | Args: 273 | field_name: The field name to format 274 | 275 | Returns: 276 | Formatted field name with prefix if needed 277 | """ 278 | if field_name.startswith("System.") or field_name.startswith("Microsoft."): 279 | return field_name 280 | 281 | # Check for commonly used short names 282 | common_fields = { 283 | "title": "System.Title", 284 | "description": "System.Description", 285 | "state": "System.State", 286 | "assignedto": "System.AssignedTo", 287 | "assigned": "System.AssignedTo", 288 | "iterationpath": "System.IterationPath", 289 | "iteration": "System.IterationPath", 290 | "areapath": "System.AreaPath", 291 | "area": "System.AreaPath", 292 | "tags": "System.Tags", 293 | "storypoints": "Microsoft.VSTS.Scheduling.StoryPoints", 294 | "priority": "Microsoft.VSTS.Common.Priority" 295 | } 296 | 297 | # Try to match common field names (case-insensitive) 298 | normalized = field_name.lower().replace("_", "").replace(" ", "") 299 | if normalized in common_fields: 300 | return common_fields[normalized] 301 | 302 | # If not recognized, assume it's a custom field 303 | return field_name 304 | 305 | 306 | def register_tools(mcp) -> None: 307 | """ 308 | Register work item creation tools with the MCP server. 309 | 310 | Args: 311 | mcp: The FastMCP server instance 312 | """ 313 | 314 | @mcp.tool() 315 | def create_work_item( 316 | title: str, 317 | project: str, 318 | work_item_type: str, 319 | fields: Optional[Dict[str, Any]] = None, 320 | description: Optional[str] = None, 321 | state: Optional[str] = None, 322 | assigned_to: Optional[str] = None, 323 | parent_id: Optional[int] = None, 324 | iteration_path: Optional[str] = None, 325 | area_path: Optional[str] = None, 326 | story_points: Optional[float] = None, 327 | priority: Optional[int] = None, 328 | tags: Optional[str] = None, 329 | ) -> str: 330 | """ 331 | Creates a new work item in Azure DevOps. 332 | 333 | Use this tool when you need to: 334 | - Add a new task, user story, bug, or other work item to a project 335 | - Create work items with specific field values and metadata 336 | - Establish work hierarchies by setting parent relationships 337 | - Track new requirements, features, or issues in your project 338 | 339 | IMPORTANT: The work item will be created immediately and visible to all 340 | users with access to the specified project. It will also trigger any 341 | configured notifications or automation rules. 342 | 343 | Args: 344 | title: The title of the work item 345 | project: The project name or ID where the work item will be created 346 | work_item_type: Type of work item (e.g., "User Story", "Bug", 347 | "Task") 348 | fields: Optional dictionary of additional field name/value pairs 349 | to set 350 | description: Optional description of the work item 351 | state: Optional initial state for the work item 352 | assigned_to: Optional user email to assign the work item to 353 | parent_id: Optional ID of parent work item for hierarchy 354 | iteration_path: Optional iteration path for the work item 355 | area_path: Optional area path for the work item 356 | story_points: Optional story points value 357 | priority: Optional priority value 358 | tags: Optional tags as comma-separated string 359 | 360 | Returns: 361 | Formatted string containing the created work item details including 362 | ID, title, type, state, and all other specified fields, formatted 363 | as markdown 364 | """ 365 | try: 366 | wit_client = get_work_item_client() 367 | 368 | # Start with standard fields 369 | all_fields = _prepare_standard_fields( 370 | title, description, state, assigned_to, 371 | iteration_path, area_path, story_points, priority, tags 372 | ) 373 | 374 | # Add custom fields if provided 375 | if fields: 376 | for field_name, field_value in fields.items(): 377 | field_name = _ensure_system_prefix(field_name) 378 | all_fields[field_name] = field_value 379 | 380 | if not all_fields.get("System.Title"): 381 | return "Error: Title is required for work item creation" 382 | 383 | return _create_work_item_impl( 384 | fields=all_fields, 385 | project=project, 386 | work_item_type=work_item_type, 387 | wit_client=wit_client, 388 | parent_id=parent_id 389 | ) 390 | 391 | except AzureDevOpsClientError as e: 392 | return f"Error: {str(e)}" 393 | except Exception as e: 394 | return f"Error creating work item: {str(e)}" 395 | 396 | 397 | @mcp.tool() 398 | def update_work_item( 399 | id: int, 400 | fields: Optional[Dict[str, Any]] = None, 401 | project: Optional[str] = None, 402 | title: Optional[str] = None, 403 | description: Optional[str] = None, 404 | state: Optional[str] = None, 405 | assigned_to: Optional[str] = None, 406 | iteration_path: Optional[str] = None, 407 | area_path: Optional[str] = None, 408 | story_points: Optional[float] = None, 409 | priority: Optional[int] = None, 410 | tags: Optional[str] = None, 411 | ) -> str: 412 | """ 413 | Modifies an existing work item's fields and properties. 414 | 415 | Use this tool when you need to: 416 | - Change the status or state of a work item 417 | - Reassign work to a different team member 418 | - Update the description or details of a requirement 419 | - Modify effort estimates or priority levels 420 | - Add or change classification (area/iteration) 421 | - Update any field supported by the work item type 422 | 423 | IMPORTANT: This tool updates the work item directly in Azure DevOps. 424 | Changes will be immediately visible to all users with access to the 425 | work item and will trigger any configured notifications or workflows. 426 | You must specify at least one field to update. 427 | 428 | Args: 429 | id: The ID of the work item to update 430 | fields: Optional dictionary of field name/value pairs to update 431 | project: Optional project name or ID 432 | title: Optional new title for the work item 433 | description: Optional new description 434 | state: Optional new state 435 | assigned_to: Optional user email to assign to 436 | iteration_path: Optional new iteration path 437 | area_path: Optional new area path 438 | story_points: Optional new story points value 439 | priority: Optional new priority value 440 | tags: Optional new tags as comma-separated string 441 | 442 | Returns: 443 | Formatted string containing the updated work item details with 444 | all current field values, formatted as markdown 445 | """ 446 | try: 447 | wit_client = get_work_item_client() 448 | 449 | # Start with standard fields 450 | all_fields = _prepare_standard_fields( 451 | title, description, state, assigned_to, 452 | iteration_path, area_path, story_points, priority, tags 453 | ) 454 | 455 | # Add custom fields if provided 456 | if fields: 457 | for field_name, field_value in fields.items(): 458 | field_name = _ensure_system_prefix(field_name) 459 | all_fields[field_name] = field_value 460 | 461 | if not all_fields: 462 | return "Error: At least one field must be specified for update" 463 | 464 | return _update_work_item_impl( 465 | id=id, 466 | fields=all_fields, 467 | wit_client=wit_client, 468 | project=project 469 | ) 470 | 471 | except AzureDevOpsClientError as e: 472 | return f"Error: {str(e)}" 473 | except Exception as e: 474 | return f"Error updating work item: {str(e)}" 475 | 476 | 477 | @mcp.tool() 478 | def add_parent_child_link( 479 | parent_id: int, 480 | child_id: int, 481 | project: Optional[str] = None, 482 | ) -> str: 483 | """ 484 | Adds a parent-child relationship between two work items. 485 | 486 | Use this tool when you need to: 487 | - Establish hierarchy between work items 488 | - Organize epics, features, user stories, and tasks 489 | - Create a structured breakdown of work 490 | - Enable rollup of effort and progress tracking 491 | 492 | IMPORTANT: The child work item will immediately appear under the parent 493 | in hierarchical views. This relationship affects how the items are 494 | displayed in backlogs and boards. In Azure DevOps, a work item can have 495 | only one parent but multiple children. 496 | 497 | Args: 498 | parent_id: ID of the parent work item 499 | child_id: ID of the child work item 500 | project: Optional project name or ID 501 | 502 | Returns: 503 | Formatted string containing the updated child work item details 504 | showing the new parent relationship, formatted as markdown 505 | """ 506 | try: 507 | wit_client = get_work_item_client() 508 | 509 | return _add_link_to_work_item_impl( 510 | source_id=child_id, 511 | target_id=parent_id, 512 | link_type="System.LinkTypes.Hierarchy-Reverse", 513 | wit_client=wit_client, 514 | project=project 515 | ) 516 | 517 | except AzureDevOpsClientError as e: 518 | return f"Error: {str(e)}" 519 | except Exception as e: 520 | return f"Error creating parent-child link: {str(e)}" 521 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/work_items/tools/process.py: -------------------------------------------------------------------------------- 1 | """ 2 | Process operations for Azure DevOps. 3 | 4 | This module provides MCP tools for retrieving process information. 5 | """ 6 | from mcp_azure_devops.utils.azure_client import ( 7 | get_core_client, 8 | get_work_item_tracking_process_client, 9 | ) 10 | 11 | 12 | def _format_table(headers, rows): 13 | """Format data as a markdown table.""" 14 | result = [] 15 | result.append("| " + " | ".join(headers) + " |") 16 | result.append("| " + " | ".join(["----"] * len(headers)) + " |") 17 | result.extend(rows) 18 | return "\n".join(result) 19 | 20 | 21 | def _get_project_process_id_impl(project: str) -> str: 22 | """Implementation of project process ID retrieval.""" 23 | try: 24 | # Get project details with process information 25 | core_client = get_core_client() 26 | project_details = core_client.get_project( 27 | project, include_capabilities=True) 28 | process_template = project_details.capabilities.get( 29 | "processTemplate", {}) 30 | 31 | process_id = process_template.get("templateTypeId") 32 | process_name = process_template.get("templateName") 33 | 34 | if not process_id: 35 | return f"Could not determine process ID for project {project}." 36 | 37 | result = [f"# Process for Project: {project_details.name}"] 38 | result.append(f"Process Name: {process_name}") 39 | result.append(f"Process ID: {process_id}") 40 | 41 | return "\n".join(result) 42 | except Exception as e: 43 | return (f"Error retrieving process ID for project '{project}': " 44 | f"{str(e)}") 45 | 46 | 47 | def _get_process_details_impl(process_id: str) -> str: 48 | """Implementation of process details retrieval.""" 49 | try: 50 | process_client = get_work_item_tracking_process_client() 51 | process = process_client.get_process_by_its_id(process_id) 52 | 53 | if not process: 54 | return f"Process with ID '{process_id}' not found." 55 | 56 | result = [f"# Process: {process.name}"] 57 | 58 | if hasattr(process, "description") and process.description: 59 | result.append(f"\nDescription: {process.description}") 60 | 61 | result.append( 62 | f"Reference Name: {getattr(process, 'reference_name', 'N/A')}") 63 | result.append(f"Type ID: {getattr(process, 'type_id', 'N/A')}") 64 | 65 | # Get process properties like isDefault, isEnabled, etc. 66 | properties = getattr(process, "properties", None) 67 | if properties: 68 | result.append("\n## Properties") 69 | for attr in ["is_default", "is_enabled"]: 70 | value = getattr(properties, attr, None) 71 | if value is not None: 72 | attr_name = attr.replace("_", " ").capitalize() 73 | result.append(f"{attr_name}: {value}") 74 | 75 | # Get work item types for this process 76 | wit_types = process_client.get_process_work_item_types(process_id) 77 | if wit_types: 78 | result.append("\n## Work Item Types") 79 | 80 | headers = ["Name", "Reference Name", "Description"] 81 | rows = [ 82 | f"| {wit.name} | {getattr(wit, 'reference_name', 'N/A')} | " + 83 | f"{getattr(wit, 'description', 'N/A')} |" 84 | for wit in wit_types 85 | ] 86 | 87 | result.append(_format_table(headers, rows)) 88 | 89 | return "\n".join(result) 90 | except Exception as e: 91 | return (f"Error retrieving process details for process ID " 92 | f"'{process_id}': {str(e)}") 93 | 94 | 95 | def _list_processes_impl() -> str: 96 | """Implementation of processes list retrieval.""" 97 | try: 98 | process_client = get_work_item_tracking_process_client() 99 | processes = process_client.get_list_of_processes() 100 | 101 | if not processes: 102 | return "No processes found in the organization." 103 | 104 | result = ["# Available Processes"] 105 | 106 | headers = ["Name", "ID", "Reference Name", "Description", "Is Default"] 107 | rows = [] 108 | for process in processes: 109 | is_default = ("Yes" if getattr(process.properties, 110 | 'is_default', False) 111 | else "No") 112 | row = (f"| {process.name} | {process.type_id} | " + 113 | f"{getattr(process, 'reference_name', 'N/A')} | " + 114 | f"{getattr(process, 'description', 'N/A')} | " + 115 | f"{is_default} |") 116 | rows.append(row) 117 | 118 | result.append(_format_table(headers, rows)) 119 | return "\n".join(result) 120 | except Exception as e: 121 | return f"Error retrieving processes: {str(e)}" 122 | 123 | 124 | def register_tools(mcp) -> None: 125 | """ 126 | Register process tools with the MCP server. 127 | 128 | Args: 129 | mcp: The FastMCP server instance 130 | """ 131 | 132 | @mcp.tool() 133 | def get_project_process_id(project: str) -> str: 134 | """ 135 | Gets the process ID associated with a project. 136 | 137 | Use this tool when you need to: 138 | - Find out which process a project is using 139 | - Get the process ID for use in other process-related operations 140 | - Verify process information for a project 141 | 142 | Args: 143 | project: Project ID or project name 144 | 145 | Returns: 146 | Formatted information about the process including name and ID 147 | """ 148 | try: 149 | return _get_project_process_id_impl(project) 150 | except Exception as e: 151 | return f"Error: {str(e)}" 152 | 153 | @mcp.tool() 154 | def get_process_details(process_id: str) -> str: 155 | """ 156 | Gets detailed information about a specific process. 157 | 158 | Use this tool when you need to: 159 | - View process properties and configuration 160 | - Get a list of work item types defined in a process 161 | - Check if a process is the default for the organization 162 | 163 | Args: 164 | process_id: The ID of the process 165 | 166 | Returns: 167 | Detailed information about the process including properties and 168 | available work item types 169 | """ 170 | try: 171 | return _get_process_details_impl(process_id) 172 | except Exception as e: 173 | return f"Error: {str(e)}" 174 | 175 | @mcp.tool() 176 | def list_processes() -> str: 177 | """ 178 | Lists all available processes in the organization. 179 | 180 | Use this tool when you need to: 181 | - See what processes are available in your Azure DevOps organization 182 | - Find process IDs for project creation or configuration 183 | - Check which process is set as the default 184 | 185 | Returns: 186 | A formatted table of all processes with names, IDs, and 187 | descriptions 188 | """ 189 | try: 190 | return _list_processes_impl() 191 | except Exception as e: 192 | return f"Error: {str(e)}" 193 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/work_items/tools/query.py: -------------------------------------------------------------------------------- 1 | """ 2 | Query operations for Azure DevOps work items. 3 | 4 | This module provides MCP tools for querying work items. 5 | """ 6 | from typing import Optional 7 | 8 | from azure.devops.v7_1.work_item_tracking import WorkItemTrackingClient 9 | from azure.devops.v7_1.work_item_tracking.models import Wiql 10 | 11 | from mcp_azure_devops.features.work_items.common import ( 12 | AzureDevOpsClientError, 13 | get_work_item_client, 14 | ) 15 | from mcp_azure_devops.features.work_items.formatting import format_work_item 16 | 17 | 18 | def _query_work_items_impl(query: str, top: int, 19 | wit_client: WorkItemTrackingClient) -> str: 20 | """ 21 | Implementation of query_work_items that operates with a client. 22 | 23 | Args: 24 | query: The WIQL query string 25 | top: Maximum number of results to return 26 | wit_client: Work item tracking client 27 | 28 | Returns: 29 | Formatted string containing work item details 30 | """ 31 | 32 | # Create the WIQL query 33 | wiql = Wiql(query=query) 34 | 35 | # Execute the query 36 | wiql_results = wit_client.query_by_wiql(wiql, top=top).work_items 37 | 38 | if not wiql_results: 39 | return "No work items found matching the query." 40 | 41 | # Get the work items from the results 42 | work_item_ids = [int(res.id) for res in wiql_results] 43 | work_items = wit_client.get_work_items(ids=work_item_ids, 44 | expand="all", 45 | error_policy="omit") 46 | 47 | # Use the standard formatting for all work items 48 | formatted_results = [] 49 | for work_item in work_items: 50 | if work_item: 51 | formatted_results.append(format_work_item(work_item)) 52 | 53 | return "\n\n".join(formatted_results) 54 | 55 | def register_tools(mcp) -> None: 56 | """ 57 | Register work item query tools with the MCP server. 58 | 59 | Args: 60 | mcp: The FastMCP server instance 61 | """ 62 | 63 | @mcp.tool() 64 | def query_work_items(query: str, top: Optional[int] = None) -> str: 65 | """ 66 | Searches for work items using Work Item Query Language (WIQL). 67 | 68 | Use this tool when you need to: 69 | - Find work items matching specific criteria 70 | - Search across projects for related tasks or bugs 71 | - Create dynamic reports based on work item attributes 72 | - Identify work items assigned to specific team members 73 | 74 | IMPORTANT: WIQL syntax is similar to SQL and allows you to query 75 | work items based on their fields. The query must follow Azure DevOps 76 | WIQL syntax rules, with proper SELECT, FROM, and WHERE clauses. 77 | 78 | Args: 79 | query: The WIQL query string (e.g., "SELECT * FROM workitems 80 | WHERE [System.State] = 'Active'") 81 | top: Maximum number of results to return (default: 30) 82 | 83 | Returns: 84 | Formatted string containing detailed information for each matching 85 | work item, with all fields and values formatted as markdown 86 | """ 87 | try: 88 | wit_client = get_work_item_client() 89 | return _query_work_items_impl(query, top or 30, wit_client) 90 | except AzureDevOpsClientError as e: 91 | return f"Error: {str(e)}" 92 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/work_items/tools/read.py: -------------------------------------------------------------------------------- 1 | """ 2 | Read operations for Azure DevOps work items. 3 | 4 | This module provides MCP tools for retrieving work item information. 5 | """ 6 | from azure.devops.v7_1.work_item_tracking import WorkItemTrackingClient 7 | 8 | from mcp_azure_devops.features.work_items.common import ( 9 | AzureDevOpsClientError, 10 | get_work_item_client, 11 | ) 12 | from mcp_azure_devops.features.work_items.formatting import format_work_item 13 | 14 | 15 | def _get_work_item_impl(item_id: int | list[int], 16 | wit_client: WorkItemTrackingClient) -> str: 17 | """ 18 | Implementation of work item retrieval. 19 | 20 | Args: 21 | item_id: The work item ID or list of IDs 22 | wit_client: Work item tracking client 23 | 24 | Returns: 25 | Formatted string containing work item information 26 | """ 27 | try: 28 | if isinstance(item_id, int): 29 | # Handle single work item 30 | work_item = wit_client.get_work_item(item_id, expand="all") 31 | return format_work_item(work_item) 32 | else: 33 | # Handle list of work items 34 | work_items = wit_client.get_work_items(ids=item_id, 35 | error_policy="omit", 36 | expand="all") 37 | 38 | if not work_items: 39 | return "No work items found." 40 | 41 | formatted_results = [] 42 | for work_item in work_items: 43 | if work_item: # Skip None values (failed retrievals) 44 | formatted_results.append(format_work_item(work_item)) 45 | 46 | if not formatted_results: 47 | return "No valid work items found with the provided IDs." 48 | 49 | return "\n\n".join(formatted_results) 50 | except Exception as e: 51 | if isinstance(item_id, int): 52 | return f"Error retrieving work item {item_id}: {str(e)}" 53 | else: 54 | return f"Error retrieving work items {item_id}: {str(e)}" 55 | 56 | def register_tools(mcp) -> None: 57 | """ 58 | Register work item read tools with the MCP server. 59 | 60 | Args: 61 | mcp: The FastMCP server instance 62 | """ 63 | 64 | @mcp.tool() 65 | def get_work_item(id: int | list[int]) -> str: 66 | """ 67 | Retrieves detailed information about one or multiple work items. 68 | 69 | Use this tool when you need to: 70 | - View the complete details of a specific work item 71 | - Examine the current state, assigned user, and other properties 72 | - Get information about multiple work items at once 73 | - Access the full description and custom fields of work items 74 | 75 | Args: 76 | id: The work item ID or a list of work item IDs 77 | 78 | Returns: 79 | Formatted string containing comprehensive information for the 80 | requested work item(s), including all system and custom fields, 81 | formatted as markdown with clear section headings 82 | """ 83 | try: 84 | wit_client = get_work_item_client() 85 | return _get_work_item_impl(id, wit_client) 86 | except AzureDevOpsClientError as e: 87 | return f"Error: {str(e)}" 88 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/work_items/tools/templates.py: -------------------------------------------------------------------------------- 1 | """ 2 | Work item templates operations for Azure DevOps. 3 | 4 | This module provides MCP tools for retrieving work item templates. 5 | """ 6 | from typing import Optional 7 | 8 | from azure.devops.v7_1.work_item_tracking import WorkItemTrackingClient 9 | 10 | from mcp_azure_devops.features.work_items.common import ( 11 | AzureDevOpsClientError, 12 | get_work_item_client, 13 | ) 14 | 15 | 16 | def _format_table(headers, rows): 17 | """Format data as a markdown table.""" 18 | result = [] 19 | result.append("| " + " | ".join(headers) + " |") 20 | result.append("| " + " | ".join(["----"] * len(headers)) + " |") 21 | result.extend(rows) 22 | return "\n".join(result) 23 | 24 | 25 | def _format_work_item_template(template): 26 | """Format work item template data for display.""" 27 | result = [f"# Template: {template.name}"] 28 | 29 | for attr in ["description", "work_item_type_name", "id"]: 30 | value = getattr(template, attr, None) 31 | if value: 32 | result.append(f"{attr.replace('_', ' ').capitalize()}: {value}") 33 | 34 | fields = getattr(template, "fields", None) 35 | if fields: 36 | result.append("\n## Default Field Values") 37 | for field, value in fields.items(): 38 | result.append(f"- {field}: {value}") 39 | 40 | return "\n".join(result) 41 | 42 | 43 | def _create_team_context(team_context_dict): 44 | """Create a TeamContext object from a dictionary.""" 45 | from azure.devops.v7_1.work_item_tracking.models import TeamContext 46 | return TeamContext( 47 | project=team_context_dict.get('project'), 48 | project_id=team_context_dict.get('project_id'), 49 | team=team_context_dict.get('team'), 50 | team_id=team_context_dict.get('team_id') 51 | ) 52 | 53 | 54 | def _get_work_item_templates_impl( 55 | team_context: dict, 56 | work_item_type: Optional[str], 57 | wit_client: WorkItemTrackingClient 58 | ) -> str: 59 | """Implementation of work item templates retrieval.""" 60 | try: 61 | team_ctx = _create_team_context(team_context) 62 | templates = wit_client.get_templates(team_ctx, work_item_type) 63 | 64 | team_display = team_context.get('team') or team_context.get('team_id') 65 | 66 | if not templates: 67 | scope = (f"work item type '{work_item_type}' in " 68 | if work_item_type else "") 69 | return f"No templates found for {scope}team {team_display}." 70 | 71 | # Create header 72 | project_display = (team_context.get('project') or 73 | team_context.get('project_id')) 74 | header = (f"# Work Item Templates for Team: {team_display} " 75 | f"(Project: {project_display})") 76 | if work_item_type: 77 | header += f" (Filtered by type: {work_item_type})" 78 | 79 | headers = ["Name", "Work Item Type", "Description"] 80 | 81 | # Use list comprehension for table rows 82 | rows = [ 83 | f"| {template.name} | " 84 | f"{getattr(template, 'work_item_type_name', 'N/A')} | " + 85 | f"{getattr(template, 'description', 'N/A')} |" 86 | for template in templates 87 | ] 88 | 89 | return f"{header}\n\n" + _format_table(headers, rows) 90 | except Exception as e: 91 | return f"Error retrieving templates: {str(e)}" 92 | 93 | 94 | def _get_work_item_template_impl(team_context: dict, template_id: str, 95 | wit_client: WorkItemTrackingClient) -> str: 96 | """Implementation of work item template detail retrieval.""" 97 | try: 98 | team_ctx = _create_team_context(team_context) 99 | template = wit_client.get_template(team_ctx, template_id) 100 | 101 | if not template: 102 | return f"Template with ID '{template_id}' not found." 103 | 104 | return _format_work_item_template(template) 105 | except Exception as e: 106 | return f"Error retrieving template '{template_id}': {str(e)}" 107 | 108 | 109 | def register_tools(mcp) -> None: 110 | """ 111 | Register work item templates tools with the MCP server. 112 | 113 | Args: 114 | mcp: The FastMCP server instance 115 | """ 116 | 117 | @mcp.tool() 118 | def get_work_item_templates( 119 | team_context: dict, 120 | work_item_type: Optional[str] 121 | ) -> str: 122 | """ 123 | Gets a list of all work item templates for a team. 124 | 125 | Use this tool when you need to: 126 | - Find available templates for creating work items 127 | - Get template IDs for use in other operations 128 | - Filter templates by work item type 129 | 130 | Args: 131 | team_context: Dictionary containing team information with keys: 132 | project: Project name (Optional if project_id is provided) 133 | project_id: Project ID (Optional if project is provided) 134 | team: Team name (Optional if team_id is provided) 135 | team_id: Team ID (Optional if team is provided) 136 | work_item_type: Optional work item type name to filter templates 137 | 138 | Returns: 139 | A formatted table of all templates with names, work item types, 140 | and descriptions 141 | """ 142 | try: 143 | wit_client = get_work_item_client() 144 | return _get_work_item_templates_impl( 145 | team_context, work_item_type, wit_client) 146 | except AzureDevOpsClientError as e: 147 | return f"Error: {str(e)}" 148 | 149 | @mcp.tool() 150 | def get_work_item_template(team_context: dict, template_id: str) -> str: 151 | """ 152 | Gets detailed information about a specific work item template. 153 | 154 | Use this tool when you need to: 155 | - View default field values in a template 156 | - Understand what a template pre-populates in a work item 157 | - Get complete details about a template 158 | 159 | Args: 160 | team_context: Dictionary containing team information with keys: 161 | project: Project name (Optional if project_id is provided) 162 | project_id: Project ID (Optional if project is provided) 163 | team: Team name (Optional if team_id is provided) 164 | team_id: Team ID (Optional if team is provided) 165 | template_id: The ID of the template 166 | 167 | Returns: 168 | Detailed information about the template including default field 169 | values 170 | """ 171 | try: 172 | wit_client = get_work_item_client() 173 | return _get_work_item_template_impl( 174 | team_context, template_id, wit_client) 175 | except AzureDevOpsClientError as e: 176 | return f"Error: {str(e)}" 177 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/features/work_items/tools/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Work item types and fields operations for Azure DevOps. 3 | 4 | This module provides MCP tools for retrieving work item types and fields. 5 | """ 6 | from azure.devops.v7_1.work_item_tracking import WorkItemTrackingClient 7 | 8 | from mcp_azure_devops.features.work_items.common import ( 9 | AzureDevOpsClientError, 10 | get_work_item_client, 11 | ) 12 | from mcp_azure_devops.utils.azure_client import ( 13 | get_core_client, 14 | get_work_item_tracking_process_client, 15 | ) 16 | 17 | 18 | def _format_table(headers, rows): 19 | """Format data as a markdown table.""" 20 | result = [] 21 | result.append("| " + " | ".join(headers) + " |") 22 | result.append("| " + " | ".join(["----"] * len(headers)) + " |") 23 | result.extend(rows) 24 | return "\n".join(result) 25 | 26 | 27 | def _format_work_item_type(wit): 28 | """Format work item type data for display.""" 29 | result = [f"# Work Item Type: {wit.name}"] 30 | 31 | description = getattr(wit, "description", None) 32 | if description: 33 | result.append(f"\nDescription: {description}") 34 | 35 | for attr in ["color", "icon", "reference_name"]: 36 | value = getattr(wit, attr, None) 37 | if value: 38 | result.append(f"{attr.capitalize()}: {value}") 39 | 40 | is_disabled = getattr(wit, "is_disabled", None) 41 | if is_disabled is not None: 42 | result.append(f"Is Disabled: {is_disabled}") 43 | 44 | states = getattr(wit, "states", None) 45 | if states: 46 | result.append("\n## States") 47 | for state in states: 48 | state_info = f"- {state.name} (Category: {state.category}, " \ 49 | f"Color: {state.color})" 50 | order = getattr(state, "order", None) 51 | if order is not None: 52 | state_info += f", Order: {order}" 53 | result.append(state_info) 54 | 55 | return "\n".join(result) 56 | 57 | 58 | def _get_work_item_types_impl( 59 | project: str, 60 | wit_client: WorkItemTrackingClient 61 | ) -> str: 62 | """Implementation of work item types retrieval.""" 63 | work_item_types = wit_client.get_work_item_types(project) 64 | 65 | if not work_item_types: 66 | return f"No work item types found in project {project}." 67 | 68 | headers = ["Name", "Reference Name", "Description"] 69 | 70 | # Use list comprehension for more concise table building 71 | rows = [ 72 | f"| {wit.name} | {getattr(wit, 'reference_name', 'N/A')} | " 73 | f"{getattr(wit, 'description', 'N/A')} |" 74 | for wit in work_item_types 75 | ] 76 | 77 | return (f"# Work Item Types in Project: {project}\n\n" + 78 | _format_table(headers, rows)) 79 | 80 | 81 | def _get_work_item_type_impl(project: str, type_name: str, 82 | wit_client: WorkItemTrackingClient) -> str: 83 | """Implementation of work item type detail retrieval.""" 84 | work_item_type = wit_client.get_work_item_type(project, type_name) 85 | 86 | if not work_item_type: 87 | return f"Work item type '{type_name}' not found in project {project}." 88 | 89 | return _format_work_item_type(work_item_type) 90 | 91 | 92 | def _get_work_item_type_fields_impl(project: str, type_name: str, 93 | wit_client: WorkItemTrackingClient) -> str: 94 | """Implementation of work item type fields retrieval using process API.""" 95 | try: 96 | # Get the work item type to get its reference name 97 | wit = wit_client.get_work_item_type(project, type_name) 98 | if not wit: 99 | return (f"Work item type '{type_name}' not found in " 100 | f"project {project}.") 101 | 102 | wit_ref_name = wit.reference_name 103 | 104 | # Get project process info 105 | core_client = get_core_client() 106 | project_details = core_client.get_project( 107 | project, include_capabilities=True) 108 | process_id = project_details.capabilities.get( 109 | "processTemplate", {}).get("templateTypeId") 110 | 111 | if not process_id: 112 | return f"Could not determine process ID for project {project}" 113 | 114 | # Get process client and fields for this work item type 115 | process_client = get_work_item_tracking_process_client() 116 | fields = process_client.get_all_work_item_type_fields( 117 | process_id, wit_ref_name) 118 | 119 | if not fields: 120 | return (f"No fields found for work item type '{type_name}' " 121 | f"in project {project}.") 122 | 123 | headers = ["Name", "Reference Name", "Type", "Required", "Read Only"] 124 | 125 | # Simple table formatting 126 | rows = [ 127 | f"| {field.name} | {field.reference_name} | " + 128 | f"{getattr(field, 'type', 'N/A')} | " + 129 | f"{'Yes' if getattr(field, 'required', False) else 'No'} | " + 130 | f"{'Yes' if getattr(field, 'read_only', False) else 'No'} | " 131 | for field in fields 132 | ] 133 | 134 | return (f"# Fields for Work Item Type: {type_name}\n\n" + 135 | _format_table(headers, rows)) 136 | except Exception as e: 137 | return (f"Error retrieving fields for work item type '{type_name}' " 138 | f"in project '{project}': {str(e)}") 139 | 140 | 141 | def _get_work_item_type_field_impl( 142 | project: str, 143 | type_name: str, 144 | field_name: str, 145 | wit_client: WorkItemTrackingClient 146 | ) -> str: 147 | """Implementation of work item type field detail retrieval using process 148 | API.""" 149 | try: 150 | # Get the work item type to get its reference name 151 | wit = wit_client.get_work_item_type(project, type_name) 152 | if not wit: 153 | return (f"Work item type '{type_name}' not found in " 154 | f"project {project}.") 155 | 156 | wit_ref_name = wit.reference_name 157 | 158 | # Get project process info 159 | core_client = get_core_client() 160 | project_details = core_client.get_project( 161 | project, include_capabilities=True) 162 | process_id = project_details.capabilities.get( 163 | "processTemplate", {}).get("templateTypeId") 164 | 165 | if not process_id: 166 | return f"Could not determine process ID for project {project}" 167 | 168 | # Get process client and field details 169 | process_client = get_work_item_tracking_process_client() 170 | 171 | # Determine if field_name is a display name or reference name 172 | if "." not in field_name: 173 | # Get all fields to find the reference name 174 | all_fields = process_client.get_all_work_item_type_fields( 175 | process_id, wit_ref_name) 176 | field_ref = next((f.reference_name for f in all_fields 177 | if f.name.lower() == field_name.lower()), None) 178 | if not field_ref: 179 | return (f"Field '{field_name}' not found for work item type " 180 | f"'{type_name}' in project '{project}'.") 181 | field_name = field_ref 182 | 183 | field = process_client.get_work_item_type_field( 184 | process_id, wit_ref_name, field_name) 185 | 186 | if not field: 187 | return (f"Field '{field_name}' not found for work item type " 188 | f"'{type_name}' in project '{project}'.") 189 | 190 | # Format field details 191 | result = [f"# Field: {field.name}"] 192 | result.append(f"Reference Name: {field.reference_name}") 193 | 194 | if hasattr(field, "description") and field.description: 195 | result.append(f"Description: {field.description}") 196 | 197 | if hasattr(field, "type"): 198 | result.append(f"Type: {field.type}") 199 | 200 | is_required = "Yes" if getattr(field, 'required', False) else "No" 201 | result.append(f"Required: {is_required}") 202 | is_read_only = "Yes" if getattr(field, 'read_only', False) else "No" 203 | result.append(f"Read Only: {is_read_only}") 204 | 205 | allowed_values = getattr(field, "allowed_values", None) 206 | if allowed_values and len(allowed_values) > 0: 207 | result.append("\n## Allowed Values") 208 | for value in allowed_values: 209 | result.append(f"- {value}") 210 | 211 | default_value = getattr(field, "default_value", None) 212 | if default_value is not None: 213 | result.append(f"\nDefault Value: {default_value}") 214 | 215 | return "\n".join(result) 216 | except Exception as e: 217 | return (f"Error retrieving field '{field_name}' for work item type " 218 | f"'{type_name}' in project '{project}': {str(e)}") 219 | 220 | 221 | def register_tools(mcp) -> None: 222 | """ 223 | Register work item types and fields tools with the MCP server. 224 | 225 | Args: 226 | mcp: The FastMCP server instance 227 | """ 228 | 229 | @mcp.tool() 230 | def get_work_item_types(project: str) -> str: 231 | """ 232 | Gets a list of all work item types in a project. 233 | 234 | Use this tool when you need to: 235 | - See what work item types are available in a project 236 | - Get reference names for work item types to use in other operations 237 | - Plan work item creation by understanding available types 238 | 239 | Args: 240 | project: Project ID or project name 241 | 242 | Returns: 243 | A formatted table of all work item types with names, reference 244 | names, and descriptions 245 | """ 246 | try: 247 | wit_client = get_work_item_client() 248 | return _get_work_item_types_impl(project, wit_client) 249 | except AzureDevOpsClientError as e: 250 | return f"Error: {str(e)}" 251 | 252 | @mcp.tool() 253 | def get_work_item_type(project: str, type_name: str) -> str: 254 | """ 255 | Gets detailed information about a specific work item type. 256 | 257 | Use this tool when you need to: 258 | - Get complete details about a work item type 259 | - Understand the states and transitions for a work item type 260 | - Learn about the color and icon for a work item type 261 | 262 | Args: 263 | project: Project ID or project name 264 | type_name: The name of the work item type 265 | 266 | Returns: 267 | Detailed information about the work item type including states, 268 | color, icon, and reference name 269 | """ 270 | try: 271 | wit_client = get_work_item_client() 272 | return _get_work_item_type_impl(project, type_name, wit_client) 273 | except AzureDevOpsClientError as e: 274 | return f"Error: {str(e)}" 275 | 276 | @mcp.tool() 277 | def get_work_item_type_fields(project: str, type_name: str) -> str: 278 | """ 279 | Gets a list of all fields for a specific work item type. 280 | 281 | Use this tool when you need to: 282 | - See what fields are available for a work item type 283 | - Find required fields for creating work items of a specific type 284 | - Get reference names for fields to use in queries or updates 285 | 286 | Args: 287 | project: Project ID or project name 288 | type_name: The name of the work item type 289 | 290 | Returns: 291 | A formatted table of all fields with names, reference names, 292 | types, and required/read-only status 293 | """ 294 | try: 295 | wit_client = get_work_item_client() 296 | return _get_work_item_type_fields_impl( 297 | project, type_name, wit_client) 298 | except AzureDevOpsClientError as e: 299 | return f"Error: {str(e)}" 300 | 301 | @mcp.tool() 302 | def get_work_item_type_field( 303 | project: str, 304 | type_name: str, 305 | field_name: str 306 | ) -> str: 307 | """ 308 | Gets detailed information about a specific field in a work item type. 309 | 310 | Use this tool when you need to: 311 | - Get complete details about a work item field 312 | - Check allowed values for a field 313 | - Verify if a field is required or read-only 314 | 315 | Args: 316 | project: Project ID or project name 317 | type_name: The name of the work item type 318 | field_name: The reference name or display name of the field 319 | 320 | Returns: 321 | Detailed information about the field including type, allowed 322 | values, and constraints 323 | """ 324 | try: 325 | wit_client = get_work_item_client() 326 | return _get_work_item_type_field_impl( 327 | project, type_name, field_name, wit_client) 328 | except AzureDevOpsClientError as e: 329 | return f"Error: {str(e)}" 330 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Azure DevOps MCP Server 3 | 4 | A simple MCP server that exposes Azure DevOps capabilities. 5 | """ 6 | import argparse 7 | 8 | from mcp.server.fastmcp import FastMCP 9 | 10 | from mcp_azure_devops.features import register_all 11 | from mcp_azure_devops.utils import register_all_prompts 12 | 13 | # Create a FastMCP server instance with a name 14 | mcp = FastMCP("Azure DevOps") 15 | 16 | # Register all features 17 | register_all(mcp) 18 | register_all_prompts(mcp) 19 | 20 | def main(): 21 | """Entry point for the command-line script.""" 22 | parser = argparse.ArgumentParser( 23 | description="Run the Azure DevOps MCP server") 24 | # Add more command-line arguments as needed 25 | 26 | parser.parse_args() # Store args if needed later 27 | 28 | # Start the server 29 | mcp.run() 30 | 31 | if __name__ == "__main__": 32 | main() 33 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from mcp_azure_devops.utils.conventions_promp import register_prompt 2 | 3 | 4 | def register_all_prompts(mcp): 5 | """ 6 | Register prompts with the MCP server. 7 | 8 | Args: 9 | mcp: The FastMCP server instance 10 | """ 11 | # Register prompts here 12 | register_prompt(mcp) 13 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/utils/azure_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Azure DevOps client utilities. 3 | 4 | This module provides helper functions for connecting to Azure DevOps. 5 | """ 6 | import os 7 | from typing import Optional, Tuple 8 | 9 | from azure.devops.connection import Connection 10 | from azure.devops.v7_1.core import CoreClient 11 | from azure.devops.v7_1.work_item_tracking_process import ( 12 | WorkItemTrackingProcessClient, 13 | ) 14 | from msrest.authentication import BasicAuthentication 15 | 16 | 17 | def get_credentials() -> Tuple[Optional[str], Optional[str]]: 18 | """ 19 | Get Azure DevOps credentials from environment variables. 20 | 21 | Returns: 22 | Tuple containing (pat, organization_url) 23 | """ 24 | pat = os.environ.get("AZURE_DEVOPS_PAT") 25 | organization_url = os.environ.get("AZURE_DEVOPS_ORGANIZATION_URL") 26 | return pat, organization_url 27 | 28 | 29 | def get_connection() -> Optional[Connection]: 30 | """ 31 | Create a connection to Azure DevOps. 32 | 33 | Returns: 34 | Connection object or None if credentials are missing 35 | """ 36 | pat, organization_url = get_credentials() 37 | 38 | if not pat or not organization_url: 39 | return None 40 | 41 | credentials = BasicAuthentication('', pat) 42 | return Connection(base_url=organization_url, creds=credentials) 43 | 44 | 45 | def get_core_client() -> CoreClient: 46 | """ 47 | Get the Core client for Azure DevOps. 48 | 49 | Returns: 50 | CoreClient instance 51 | 52 | Raises: 53 | Exception: If the client cannot be created 54 | """ 55 | connection = get_connection() 56 | 57 | if not connection: 58 | raise Exception( 59 | "Azure DevOps PAT or organization URL not found in " 60 | "environment variables." 61 | ) 62 | 63 | core_client = connection.clients.get_core_client() 64 | 65 | if not core_client: 66 | raise Exception("Failed to get Core client.") 67 | 68 | return core_client 69 | 70 | 71 | def get_work_item_tracking_process_client() -> WorkItemTrackingProcessClient: 72 | """ 73 | Get the Work Item Tracking Process client for Azure DevOps. 74 | 75 | Returns: 76 | WorkItemTrackingProcessClient instance 77 | 78 | Raises: 79 | Exception: If the client cannot be created 80 | """ 81 | connection = get_connection() 82 | 83 | if not connection: 84 | raise Exception( 85 | "Azure DevOps PAT or organization URL not found in " 86 | "environment variables." 87 | ) 88 | 89 | process_client = connection.clients.get_work_item_tracking_process_client() 90 | 91 | if not process_client: 92 | raise Exception("Failed to get Work Item Tracking Process client.") 93 | 94 | return process_client 95 | -------------------------------------------------------------------------------- /src/mcp_azure_devops/utils/conventions_promp.py: -------------------------------------------------------------------------------- 1 | from mcp.server.fastmcp import FastMCP 2 | 3 | 4 | def register_prompt(mcp: FastMCP) -> None: 5 | 6 | @mcp.prompt(name="Create Conventions File", 7 | description="Create a starting conventions file Azure DevOps") 8 | def create_conventions_file() -> str: 9 | """ 10 | Create a starting conventions file for Azure DevOps. 11 | 12 | Use this prompt when you need to: 13 | - Generate a conventions file for Azure DevOps 14 | - Get a template for project conventions 15 | - Start defining project standards and guidelines 16 | 17 | Returns: 18 | A formatted conventions file template 19 | """ 20 | 21 | 22 | return """Create a concise Azure DevOps conventions file to 23 | serve as a quick reference for our environment. 24 | This should capture all important patterns and structures 25 | while remaining compact enough for an LLM context. 26 | 27 | Using the available Azure DevOps tools, please: 28 | 29 | 1. Get an overview of ALL projects (get_projects) 30 | 2. For ALL projects: 31 | - Identify teams (get_all_teams) 32 | - Get area paths and iterations for each team 33 | (get_team_area_paths, get_team_iterations) 34 | 3. Capture work item configuration for EACH project: 35 | - Process ID and details (get_project_process_id, get_process_details) 36 | - Work item types (get_work_item_types) 37 | - For each work item type, get ALL fields 38 | (get_work_item_type_fields) and clearly identify mandatory fields 39 | - Note differences in processes between projects 40 | 41 | Create a concise markdown document with these sections: 42 | 43 | 1. **Projects and Teams**: 44 | List of all projects and their teams 45 | 2. **Work Item Types by Process**: 46 | Work item types grouped by process template, 47 | including ALL fields for each type with mandatory fields clearly marked 48 | 3. **Classification Structure**: 49 | Area paths and iterations for each team, 50 | with team-specific structures and patterns 51 | 4. **Naming Conventions**: 52 | Observed naming patterns across projects, teams, and items 53 | 54 | Focus on identifying and documenting patterns and 55 | variations between projects. 56 | When listing field names or other details, prioritize the most important ones. 57 | The goal is to create a reference that captures key conventions 58 | while staying concise.""" 59 | -------------------------------------------------------------------------------- /start_server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # File used when I'm running the server locally during development 4 | source .venv/bin/activate 5 | 6 | export $(grep -v '^#' .env | xargs) 7 | 8 | python src/mcp_azure_devops/server.py -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test suite for MCP Azure DevOps server. 3 | """ 4 | -------------------------------------------------------------------------------- /tests/features/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for Azure DevOps features. 3 | """ 4 | -------------------------------------------------------------------------------- /tests/features/projects/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for Azure DevOps projects features. 3 | """ 4 | -------------------------------------------------------------------------------- /tests/features/projects/test_tools.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from azure.devops.v7_1.core.models import TeamProjectReference 4 | 5 | from mcp_azure_devops.features.projects.tools import _get_projects_impl 6 | 7 | 8 | def test_get_projects_impl_with_results(): 9 | """Test getting projects with results.""" 10 | mock_client = MagicMock() 11 | 12 | # Mock project results 13 | mock_project1 = MagicMock(spec=TeamProjectReference) 14 | mock_project1.name = "Project 1" 15 | mock_project1.id = "proj-id-1" 16 | mock_project1.description = "This is project 1" 17 | mock_project1.state = "wellFormed" 18 | mock_project1.visibility = "private" 19 | mock_project1.url = "https://dev.azure.com/test/project1" 20 | 21 | mock_project2 = MagicMock(spec=TeamProjectReference) 22 | mock_project2.name = "Project 2" 23 | mock_project2.id = "proj-id-2" 24 | mock_project2.state = "wellFormed" 25 | 26 | mock_client.get_projects.return_value = [mock_project1, mock_project2] 27 | 28 | result = _get_projects_impl(mock_client) 29 | 30 | # Check that the result contains the expected project information 31 | assert "# Project: Project 1" in result 32 | assert "ID: proj-id-1" in result 33 | assert "Description: This is project 1" in result 34 | assert "State: wellFormed" in result 35 | assert "Visibility: private" in result 36 | assert "URL: https://dev.azure.com/test/project1" in result 37 | 38 | assert "# Project: Project 2" in result 39 | assert "ID: proj-id-2" in result 40 | assert "State: wellFormed" in result 41 | 42 | def test_get_projects_impl_no_results(): 43 | """Test getting projects with no results.""" 44 | mock_client = MagicMock() 45 | mock_client.get_projects.return_value = [] 46 | 47 | result = _get_projects_impl(mock_client) 48 | 49 | assert result == "No projects found." 50 | 51 | def test_get_projects_impl_error(): 52 | """Test error handling in get_projects_impl.""" 53 | mock_client = MagicMock() 54 | mock_client.get_projects.side_effect = Exception("Test error") 55 | 56 | result = _get_projects_impl(mock_client) 57 | 58 | assert "Error retrieving projects: Test error" in result 59 | 60 | def test_get_projects_impl_with_filters(): 61 | """Test getting projects with filters applied.""" 62 | mock_client = MagicMock() 63 | 64 | # Mock project results 65 | mock_project = MagicMock(spec=TeamProjectReference) 66 | mock_project.name = "Filtered Project" 67 | mock_project.id = "proj-id-filtered" 68 | 69 | mock_client.get_projects.return_value = [mock_project] 70 | 71 | result = _get_projects_impl(mock_client, state_filter="wellFormed", top=5) 72 | 73 | # Check that the filter parameters were passed to the client 74 | mock_client.get_projects.assert_called_with( 75 | state_filter="wellFormed", top=5) 76 | 77 | # Check result contains the filtered project 78 | assert "# Project: Filtered Project" in result 79 | -------------------------------------------------------------------------------- /tests/features/teams/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for Azure DevOps teams features. 3 | """ 4 | -------------------------------------------------------------------------------- /tests/features/teams/test_tools.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from azure.devops.v7_1.core.models import IdentityRef, TeamMember, WebApiTeam 4 | from azure.devops.v7_1.work.models import ( 5 | TeamFieldValues, 6 | TeamIterationAttributes, 7 | TeamSettingsIteration, 8 | ) 9 | 10 | from mcp_azure_devops.features.teams.tools import ( 11 | _get_all_teams_impl, 12 | _get_team_area_paths_impl, 13 | _get_team_iterations_impl, 14 | _get_team_members_impl, 15 | ) 16 | 17 | 18 | # Tests for _get_all_teams_impl 19 | def test_get_all_teams_impl_with_results(): 20 | """Test getting teams with results.""" 21 | mock_client = MagicMock() 22 | 23 | # Mock team results 24 | mock_team1 = MagicMock(spec=WebApiTeam) 25 | mock_team1.name = "Team 1" 26 | mock_team1.id = "team-id-1" 27 | mock_team1.description = "This is team 1" 28 | mock_team1.project_name = "Project 1" 29 | mock_team1.project_id = "proj-id-1" 30 | 31 | mock_team2 = MagicMock(spec=WebApiTeam) 32 | mock_team2.name = "Team 2" 33 | mock_team2.id = "team-id-2" 34 | mock_team2.project_name = "Project 2" 35 | mock_team2.project_id = "proj-id-2" 36 | 37 | mock_client.get_all_teams.return_value = [mock_team1, mock_team2] 38 | 39 | result = _get_all_teams_impl(mock_client) 40 | 41 | # Check that the result contains the expected team information 42 | assert "# Team: Team 1" in result 43 | assert "ID: team-id-1" in result 44 | assert "Description: This is team 1" in result 45 | assert "Project: Project 1" in result 46 | assert "Project ID: proj-id-1" in result 47 | 48 | assert "# Team: Team 2" in result 49 | assert "ID: team-id-2" in result 50 | assert "Project: Project 2" in result 51 | assert "Project ID: proj-id-2" in result 52 | 53 | def test_get_all_teams_impl_no_results(): 54 | """Test getting teams with no results.""" 55 | mock_client = MagicMock() 56 | mock_client.get_all_teams.return_value = [] 57 | 58 | result = _get_all_teams_impl(mock_client) 59 | 60 | assert result == "No teams found." 61 | 62 | def test_get_all_teams_impl_error(): 63 | """Test error handling in get_all_teams_impl.""" 64 | mock_client = MagicMock() 65 | mock_client.get_all_teams.side_effect = Exception("Test error") 66 | 67 | result = _get_all_teams_impl(mock_client) 68 | 69 | assert "Error retrieving teams: Test error" in result 70 | 71 | def test_get_all_teams_impl_with_filters(): 72 | """Test getting teams with filters applied.""" 73 | mock_client = MagicMock() 74 | 75 | # Mock team results 76 | mock_team = MagicMock(spec=WebApiTeam) 77 | mock_team.name = "Filtered Team" 78 | mock_team.id = "team-id-filtered" 79 | 80 | mock_client.get_all_teams.return_value = [mock_team] 81 | 82 | result = _get_all_teams_impl( 83 | mock_client, user_is_member_of=True, top=5, skip=0) 84 | 85 | # Check that the filter parameters were passed to the client 86 | mock_client.get_all_teams.assert_called_with( 87 | mine=True, top=5, skip=0 88 | ) 89 | 90 | # Check result contains the filtered team 91 | assert "# Team: Filtered Team" in result 92 | 93 | # Tests for _get_team_members_impl 94 | def test_get_team_members_impl_with_results(): 95 | """Test getting team members with results.""" 96 | mock_client = MagicMock() 97 | 98 | # Mock team member results 99 | mock_member1 = MagicMock(spec=TeamMember) 100 | mock_identity1 = MagicMock(spec=IdentityRef) 101 | mock_identity1.display_name = "Member 1" 102 | mock_identity1.unique_name = "member1@example.com" 103 | mock_identity1.id = "member-id-1" 104 | mock_member1.identity = mock_identity1 105 | 106 | mock_member2 = MagicMock(spec=TeamMember) 107 | mock_identity2 = MagicMock(spec=IdentityRef) 108 | mock_identity2.display_name = "Member 2" 109 | mock_identity2.id = "member-id-2" 110 | mock_member2.identity = mock_identity2 111 | 112 | mock_client.get_team_members_with_extended_properties.return_value = [ 113 | mock_member1, mock_member2] 114 | 115 | result = _get_team_members_impl(mock_client, "proj-id-1", "team-id-1") 116 | 117 | # Check that the result contains the expected member information 118 | # using # not ## 119 | assert "# Member: Member 1" in result 120 | assert "ID: member-id-1" in result 121 | assert "Email/Username: member1@example.com" in result 122 | 123 | assert "# Member: Member 2" in result 124 | assert "ID: member-id-2" in result 125 | 126 | def test_get_team_members_impl_no_results(): 127 | """Test getting team members with no results.""" 128 | mock_client = MagicMock() 129 | mock_client.get_team_members_with_extended_properties.return_value = [] 130 | 131 | result = _get_team_members_impl(mock_client, "proj-id-1", "team-id-1") 132 | 133 | # Match exact message from implementation 134 | assert result == ("No members found for team team-id-1 in " 135 | "project proj-id-1.") 136 | 137 | def test_get_team_members_impl_error(): 138 | """Test error handling in get_team_members_impl.""" 139 | mock_client = MagicMock() 140 | mock_client.get_team_members_with_extended_properties.side_effect = ( 141 | Exception("Test error")) 142 | 143 | result = _get_team_members_impl(mock_client, "proj-id-1", "team-id-1") 144 | 145 | assert "Error retrieving team members: Test error" in result 146 | 147 | # Tests for _get_team_area_paths_impl 148 | def test_get_team_area_paths_impl_with_results(): 149 | """Test getting team area paths with results.""" 150 | mock_client = MagicMock() 151 | 152 | # Mock area path results 153 | mock_area_paths = MagicMock(spec=TeamFieldValues) 154 | mock_area_paths.default_value = "Project\\Area" 155 | 156 | # Create TeamFieldValue objects instead of strings 157 | mock_area_path1 = MagicMock() 158 | mock_area_path1.value = "Project\\Area\\SubArea1" 159 | mock_area_path1.include_children = True 160 | 161 | mock_area_path2 = MagicMock() 162 | mock_area_path2.value = "Project\\Area\\SubArea2" 163 | mock_area_path2.include_children = False 164 | 165 | mock_area_paths.values = [mock_area_path1, mock_area_path2] 166 | mock_area_paths.field = {"referenceName": "System.AreaPath"} 167 | 168 | mock_client.get_team_field_values.return_value = mock_area_paths 169 | 170 | result = _get_team_area_paths_impl(mock_client, "Project", "Team") 171 | 172 | # Check that the result contains the expected area path information 173 | assert "# Team Area Paths" in result 174 | assert "Default Area Path: Project\\Area" in result 175 | assert "## All Area Paths:" in result 176 | assert "- Project\\Area\\SubArea1 (Including sub-areas)" in result 177 | assert "- Project\\Area\\SubArea2" in result 178 | 179 | def test_get_team_area_paths_impl_no_results(): 180 | """Test getting team area paths with no results.""" 181 | mock_client = MagicMock() 182 | 183 | # Mock empty area paths - return None instead of an empty object 184 | mock_client.get_team_field_values.return_value = None 185 | 186 | result = _get_team_area_paths_impl(mock_client, "Project", "Team") 187 | 188 | assert "No area paths found for team Team in project Project." in result 189 | 190 | def test_get_team_area_paths_impl_error(): 191 | """Test error handling in get_team_area_paths_impl.""" 192 | mock_client = MagicMock() 193 | mock_client.get_team_field_values.side_effect = Exception("Test error") 194 | 195 | result = _get_team_area_paths_impl(mock_client, "Project", "Team") 196 | 197 | assert "Error retrieving team area paths: Test error" in result 198 | 199 | # Tests for _get_team_iterations_impl 200 | def test_get_team_iterations_impl_with_results(): 201 | """Test getting team iterations with results.""" 202 | mock_client = MagicMock() 203 | 204 | # Mock iteration results 205 | mock_iteration1 = MagicMock(spec=TeamSettingsIteration) 206 | mock_iteration1.name = "Sprint 1" 207 | mock_iteration1.id = "iter-id-1" 208 | mock_iteration1.path = "Project\\Sprint 1" 209 | 210 | # Create attributes with proper structure 211 | mock_attributes = MagicMock(spec=TeamIterationAttributes) 212 | mock_attributes.start_date = "2023-01-01" 213 | mock_attributes.finish_date = "2023-01-15" 214 | mock_iteration1.attributes = mock_attributes 215 | 216 | mock_iteration2 = MagicMock(spec=TeamSettingsIteration) 217 | mock_iteration2.name = "Sprint 2" 218 | mock_iteration2.id = "iter-id-2" 219 | mock_iteration2.path = "Project\\Sprint 2" 220 | mock_iteration2.attributes = MagicMock(spec=TeamIterationAttributes) 221 | 222 | mock_client.get_team_iterations.return_value = [ 223 | mock_iteration1, mock_iteration2] 224 | 225 | result = _get_team_iterations_impl(mock_client, "Project", "Team") 226 | 227 | # Updated to match actual implementation which uses # not ## 228 | assert "# Iteration: Sprint 1" in result 229 | assert "ID: iter-id-1" in result 230 | assert "Path: Project\\Sprint 1" in result 231 | assert "Start Date: 2023-01-01" in result 232 | # Implementation uses Finish Date not End Date 233 | assert "Finish Date: 2023-01-15" in result 234 | 235 | assert "# Iteration: Sprint 2" in result 236 | assert "ID: iter-id-2" in result 237 | assert "Path: Project\\Sprint 2" in result 238 | 239 | def test_get_team_iterations_impl_no_results(): 240 | """Test getting team iterations with no results.""" 241 | mock_client = MagicMock() 242 | mock_client.get_team_iterations.return_value = [] 243 | 244 | result = _get_team_iterations_impl(mock_client, "Project", "Team") 245 | 246 | assert "No iterations found for team Team in project Project." in result 247 | 248 | def test_get_team_iterations_impl_error(): 249 | """Test error handling in get_team_iterations_impl.""" 250 | mock_client = MagicMock() 251 | mock_client.get_team_iterations.side_effect = Exception("Test error") 252 | 253 | result = _get_team_iterations_impl(mock_client, "Project", "Team") 254 | 255 | assert "Error retrieving team iterations: Test error" in result 256 | 257 | def test_get_team_iterations_impl_with_current_parameter(): 258 | """Test getting team iterations with current parameter.""" 259 | mock_client = MagicMock() 260 | 261 | # Mock iteration results 262 | mock_iteration = MagicMock(spec=TeamSettingsIteration) 263 | mock_iteration.name = "Current Sprint" 264 | 265 | mock_client.get_team_iterations.return_value = [mock_iteration] 266 | 267 | result = _get_team_iterations_impl( 268 | mock_client, "Project", "Team", current=True) 269 | 270 | # Using a more flexible approach to check the timeframe parameter 271 | # Store the call arguments 272 | call_args_list = mock_client.get_team_iterations.call_args_list 273 | assert len(call_args_list) > 0 274 | 275 | # Get the most recent call 276 | args, kwargs = call_args_list[-1] 277 | 278 | # Check that the timeframe is 'Current' by any means possible 279 | # Option 1: Check kwargs directly 280 | if 'timeframe' in kwargs: 281 | assert kwargs['timeframe'] == 'Current' 282 | # Option 2: Just verify 'Current' is somewhere in the call arguments 283 | else: 284 | call_str = str(mock_client.get_team_iterations.call_args) 285 | assert 'Current' in call_str 286 | 287 | # Check result contains the current iteration 288 | assert "# Iteration: Current Sprint" in result 289 | -------------------------------------------------------------------------------- /tests/features/work_items/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for Azure DevOps work items features. 3 | """ 4 | -------------------------------------------------------------------------------- /tests/features/work_items/test_create.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from azure.devops.v7_1.work_item_tracking.models import WorkItem 4 | 5 | from mcp_azure_devops.features.work_items.tools.create import ( 6 | _add_link_to_work_item_impl, 7 | _build_field_document, 8 | _build_link_document, 9 | _create_work_item_impl, 10 | _ensure_system_prefix, 11 | _get_organization_url, 12 | _prepare_standard_fields, 13 | _update_work_item_impl, 14 | ) 15 | 16 | 17 | def test_build_field_document(): 18 | """Test building JSON patch document from fields dictionary.""" 19 | # Simple field values 20 | fields = { 21 | "System.Title": "Test Bug", 22 | "System.Description": "This is a test bug", 23 | "System.State": "Active", 24 | } 25 | 26 | document = _build_field_document(fields) 27 | 28 | # Verify document structure 29 | assert len(document) == 3 30 | 31 | # Check first operation 32 | assert document[0].op == "add" 33 | assert document[0].path == "/fields/System.Title" 34 | assert document[0].value == "Test Bug" 35 | 36 | # Test with replace operation 37 | document = _build_field_document(fields, "replace") 38 | assert document[0].op == "replace" 39 | 40 | # Test with field name without /fields/ prefix 41 | fields = {"Title": "Test Bug"} 42 | document = _build_field_document(fields) 43 | assert document[0].path == "/fields/Title" 44 | 45 | 46 | @patch("mcp_azure_devops.features.work_items.tools.create.os") 47 | def test_get_organization_url(mock_os): 48 | """Test retrieving organization URL from environment.""" 49 | # Test with trailing slash 50 | mock_os.environ.get.return_value = "https://dev.azure.com/org/" 51 | url = _get_organization_url() 52 | assert url == "https://dev.azure.com/org" 53 | 54 | # Test without trailing slash 55 | mock_os.environ.get.return_value = "https://dev.azure.com/org" 56 | url = _get_organization_url() 57 | assert url == "https://dev.azure.com/org" 58 | 59 | # Test with empty value 60 | mock_os.environ.get.return_value = "" 61 | url = _get_organization_url() 62 | assert url == "" 63 | 64 | 65 | def test_build_link_document(): 66 | """Test building link document for work item relationships.""" 67 | target_id = 123 68 | link_type = "System.LinkTypes.Hierarchy-Reverse" 69 | org_url = "https://dev.azure.com/org" 70 | 71 | document = _build_link_document(target_id, link_type, org_url) 72 | 73 | # Verify document structure 74 | assert len(document) == 1 75 | assert document[0].op == "add" 76 | assert document[0].path == "/relations/-" 77 | assert document[0].value["rel"] == link_type 78 | assert document[0].value["url"] == "https://dev.azure.com/org/_apis/wit/workItems/123" 79 | 80 | 81 | def test_create_work_item_impl(): 82 | """Test creating a work item.""" 83 | # Arrange 84 | mock_client = MagicMock() 85 | 86 | # Create mock for new work item 87 | mock_work_item = MagicMock(spec=WorkItem) 88 | mock_work_item.id = 123 89 | mock_work_item.fields = { 90 | "System.WorkItemType": "Bug", 91 | "System.Title": "Test Bug", 92 | "System.State": "New", 93 | "System.TeamProject": "Test Project", 94 | } 95 | 96 | mock_client.create_work_item.return_value = mock_work_item 97 | 98 | # Fields to create work item 99 | fields = { 100 | "System.Title": "Test Bug", 101 | "System.Description": "This is a test bug", 102 | } 103 | 104 | # Act 105 | result = _create_work_item_impl( 106 | fields=fields, 107 | project="Test Project", 108 | work_item_type="Bug", 109 | wit_client=mock_client 110 | ) 111 | 112 | # Assert 113 | mock_client.create_work_item.assert_called_once() 114 | assert "# Work Item 123" in result 115 | assert "**System.WorkItemType**: Bug" in result 116 | assert "**System.Title**: Test Bug" in result 117 | assert "**System.State**: New" in result 118 | 119 | # Verify document passed to create_work_item 120 | args, kwargs = mock_client.create_work_item.call_args 121 | document = kwargs.get("document") or args[0] 122 | assert len(document) == 2 # Two fields in our test 123 | assert kwargs.get("project") == "Test Project" 124 | assert kwargs.get("type") == "Bug" 125 | 126 | 127 | @patch("mcp_azure_devops.features.work_items.tools.create._get_organization_url") 128 | def test_create_work_item_impl_with_parent(mock_get_org_url): 129 | """Test creating a work item with parent relationship.""" 130 | # Arrange 131 | mock_client = MagicMock() 132 | 133 | # Create mock for new work item 134 | mock_work_item = MagicMock(spec=WorkItem) 135 | mock_work_item.id = 123 136 | mock_work_item.fields = { 137 | "System.WorkItemType": "Bug", 138 | "System.Title": "Test Bug", 139 | "System.State": "New", 140 | "System.TeamProject": "Test Project", 141 | } 142 | 143 | # Setup organization URL 144 | mock_get_org_url.return_value = "https://dev.azure.com/org" 145 | 146 | # Setup mock returns for create and update 147 | mock_client.create_work_item.return_value = mock_work_item 148 | mock_client.update_work_item.return_value = mock_work_item 149 | 150 | # Fields to create work item 151 | fields = { 152 | "System.Title": "Test Bug", 153 | "System.Description": "This is a test bug", 154 | } 155 | 156 | # Act 157 | result = _create_work_item_impl( 158 | fields=fields, 159 | project="Test Project", 160 | work_item_type="Bug", 161 | wit_client=mock_client, 162 | parent_id=456 163 | ) 164 | 165 | # Assert 166 | mock_client.create_work_item.assert_called_once() 167 | mock_client.update_work_item.assert_called_once() 168 | 169 | # Verify update_work_item was called with link document 170 | args, kwargs = mock_client.update_work_item.call_args 171 | document = kwargs.get("document") or args[0] 172 | assert document[0].path == "/relations/-" 173 | assert document[0].value["rel"] == "System.LinkTypes.Hierarchy-Reverse" 174 | 175 | # Check result formatting 176 | assert "# Work Item 123" in result 177 | assert "**System.Title**: Test Bug" in result 178 | 179 | 180 | def test_update_work_item_impl(): 181 | """Test updating a work item.""" 182 | # Arrange 183 | mock_client = MagicMock() 184 | 185 | # Create mock for updated work item 186 | mock_work_item = MagicMock(spec=WorkItem) 187 | mock_work_item.id = 123 188 | mock_work_item.fields = { 189 | "System.WorkItemType": "Bug", 190 | "System.Title": "Updated Bug", 191 | "System.State": "Active", 192 | "System.TeamProject": "Test Project", 193 | } 194 | 195 | mock_client.update_work_item.return_value = mock_work_item 196 | 197 | # Fields to update 198 | fields = { 199 | "System.Title": "Updated Bug", 200 | "System.State": "Active", 201 | } 202 | 203 | # Act 204 | result = _update_work_item_impl( 205 | id=123, 206 | fields=fields, 207 | wit_client=mock_client, 208 | project="Test Project" 209 | ) 210 | 211 | # Assert 212 | mock_client.update_work_item.assert_called_once() 213 | assert "# Work Item 123" in result 214 | assert "**System.Title**: Updated Bug" in result 215 | assert "**System.WorkItemType**: Bug" in result 216 | assert "**System.State**: Active" in result 217 | 218 | # Verify document passed to update_work_item 219 | args, kwargs = mock_client.update_work_item.call_args 220 | document = kwargs.get("document") or args[0] 221 | assert len(document) == 2 # Two fields in our test 222 | # All operations should be replace 223 | assert all(op.op == "replace" for op in document) 224 | assert kwargs.get("id") == 123 225 | assert kwargs.get("project") == "Test Project" 226 | 227 | 228 | def test_add_link_to_work_item_impl(): 229 | """Test adding a link between work items.""" 230 | # Arrange 231 | mock_client = MagicMock() 232 | 233 | # Create mock for updated work item 234 | mock_work_item = MagicMock(spec=WorkItem) 235 | mock_work_item.id = 123 236 | mock_work_item.fields = { 237 | "System.WorkItemType": "Bug", 238 | "System.Title": "Test Bug", 239 | "System.State": "Active", 240 | "System.TeamProject": "Test Project", 241 | } 242 | 243 | mock_client.update_work_item.return_value = mock_work_item 244 | 245 | # Act with patch for organization URL 246 | with patch( 247 | "mcp_azure_devops.features.work_items.tools.create._get_organization_url", 248 | return_value="https://dev.azure.com/org"): 249 | result = _add_link_to_work_item_impl( 250 | source_id=123, 251 | target_id=456, 252 | link_type="System.LinkTypes.Hierarchy-Reverse", 253 | wit_client=mock_client, 254 | project="Test Project" 255 | ) 256 | 257 | # Assert 258 | mock_client.update_work_item.assert_called_once() 259 | 260 | # Verify document passed to update_work_item 261 | args, kwargs = mock_client.update_work_item.call_args 262 | document = kwargs.get("document") or args[0] 263 | assert document[0].path == "/relations/-" 264 | assert document[0].value["rel"] == "System.LinkTypes.Hierarchy-Reverse" 265 | assert document[0].value["url"] == "https://dev.azure.com/org/_apis/wit/workItems/456" 266 | 267 | # Check result formatting 268 | assert "# Work Item 123" in result 269 | assert "**System.Title**: Test Bug" in result 270 | assert "**System.State**: Active" in result 271 | 272 | 273 | def test_prepare_standard_fields(): 274 | """Test preparing standard fields dictionary.""" 275 | # Test with all fields specified 276 | fields = _prepare_standard_fields( 277 | title="Test Bug", 278 | description="This is a test bug", 279 | state="Active", 280 | assigned_to="user@example.com", 281 | iteration_path="Project\\Sprint 1", 282 | area_path="Project\\Area", 283 | story_points=5.5, 284 | priority=1, 285 | tags="tag1; tag2" 286 | ) 287 | 288 | # Verify fields 289 | assert fields["System.Title"] == "Test Bug" 290 | assert fields["System.Description"] == "This is a test bug" 291 | assert fields["System.State"] == "Active" 292 | assert fields["System.AssignedTo"] == "user@example.com" 293 | assert fields["System.IterationPath"] == "Project\\Sprint 1" 294 | assert fields["System.AreaPath"] == "Project\\Area" 295 | assert fields["Microsoft.VSTS.Scheduling.StoryPoints"] == "5.5" 296 | assert fields["Microsoft.VSTS.Common.Priority"] == "1" 297 | assert fields["System.Tags"] == "tag1; tag2" 298 | 299 | # Test with subset of fields 300 | fields = _prepare_standard_fields( 301 | title="Test Bug", 302 | state="Active" 303 | ) 304 | 305 | assert len(fields) == 2 306 | assert "System.Title" in fields 307 | assert "System.State" in fields 308 | assert "System.Description" not in fields 309 | 310 | 311 | def test_ensure_system_prefix(): 312 | """Test ensuring field names have proper prefix.""" 313 | # Test with already prefixed fields 314 | assert _ensure_system_prefix("System.Title") == "System.Title" 315 | assert (_ensure_system_prefix("Microsoft.VSTS.Common.Priority") == 316 | "Microsoft.VSTS.Common.Priority") 317 | 318 | # Test with common short names 319 | assert _ensure_system_prefix("title") == "System.Title" 320 | assert _ensure_system_prefix("description") == "System.Description" 321 | assert _ensure_system_prefix("assignedTo") == "System.AssignedTo" 322 | assert _ensure_system_prefix("iterationPath") == "System.IterationPath" 323 | assert _ensure_system_prefix("area_path") == "System.AreaPath" 324 | assert (_ensure_system_prefix("storyPoints") == 325 | "Microsoft.VSTS.Scheduling.StoryPoints") 326 | assert (_ensure_system_prefix("priority") == 327 | "Microsoft.VSTS.Common.Priority") 328 | 329 | # Test with unknown field - should return as is 330 | assert _ensure_system_prefix("CustomField") == "CustomField" 331 | -------------------------------------------------------------------------------- /tests/features/work_items/test_process.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from mcp_azure_devops.features.work_items.tools.process import ( 4 | _get_process_details_impl, 5 | _get_project_process_id_impl, 6 | _list_processes_impl, 7 | ) 8 | 9 | 10 | @patch("mcp_azure_devops.features.work_items.tools.process.get_core_client") 11 | def test_get_project_process_id_impl(mock_get_core_client): 12 | """Test retrieving project process ID.""" 13 | # Arrange 14 | mock_core_client = MagicMock() 15 | mock_get_core_client.return_value = mock_core_client 16 | 17 | # Mock project details 18 | mock_project = MagicMock() 19 | mock_project.name = "Test Project" 20 | mock_project.capabilities = { 21 | "processTemplate": { 22 | "templateTypeId": "process-id-123", 23 | "templateName": "Agile" 24 | } 25 | } 26 | 27 | mock_core_client.get_project.return_value = mock_project 28 | 29 | # Act 30 | result = _get_project_process_id_impl("Test Project") 31 | 32 | # Assert 33 | mock_core_client.get_project.assert_called_once_with( 34 | "Test Project", include_capabilities=True) 35 | 36 | # Check result formatting 37 | assert "Process for Project: Test Project" in result 38 | assert "Process Name: Agile" in result 39 | assert "Process ID: process-id-123" in result 40 | 41 | 42 | @patch("mcp_azure_devops.features.work_items.tools.process.get_core_client") 43 | def test_get_project_process_id_impl_no_process(mock_get_core_client): 44 | """Test retrieving project process ID when no process is found.""" 45 | # Arrange 46 | mock_core_client = MagicMock() 47 | mock_get_core_client.return_value = mock_core_client 48 | 49 | # Mock project details with no process 50 | mock_project = MagicMock() 51 | mock_project.name = "Test Project" 52 | mock_project.capabilities = { 53 | "processTemplate": {} # Empty process template 54 | } 55 | 56 | mock_core_client.get_project.return_value = mock_project 57 | 58 | # Act 59 | result = _get_project_process_id_impl("Test Project") 60 | 61 | # Assert 62 | assert "Could not determine process ID for project Test Project" in result 63 | 64 | 65 | @patch("mcp_azure_devops.features.work_items.tools.process.get_core_client") 66 | def test_get_project_process_id_impl_error(mock_get_core_client): 67 | """Test error handling in get_project_process_id_impl.""" 68 | # Arrange 69 | mock_core_client = MagicMock() 70 | mock_get_core_client.return_value = mock_core_client 71 | 72 | # Simulate error 73 | mock_core_client.get_project.side_effect = Exception("Test error") 74 | 75 | # Act 76 | result = _get_project_process_id_impl("Test Project") 77 | 78 | # Assert 79 | assert ("Error retrieving process ID for project 'Test Project': " 80 | "Test error" in result) 81 | 82 | 83 | @patch("mcp_azure_devops.features.work_items.tools.process.get_work_item_tracking_process_client") 84 | def test_get_process_details_impl(mock_get_process_client): 85 | """Test retrieving process details.""" 86 | # Arrange 87 | mock_process_client = MagicMock() 88 | mock_get_process_client.return_value = mock_process_client 89 | 90 | # Mock process 91 | mock_process = MagicMock() 92 | mock_process.name = "Agile" 93 | mock_process.reference_name = "Agile" 94 | mock_process.type_id = "process-id-123" 95 | mock_process.description = "Agile process template" 96 | 97 | # Mock process properties 98 | mock_properties = MagicMock() 99 | mock_properties.is_default = True 100 | mock_properties.is_enabled = True 101 | mock_process.properties = mock_properties 102 | 103 | # Mock work item types 104 | mock_wit_type1 = MagicMock() 105 | mock_wit_type1.name = "Bug" 106 | mock_wit_type1.reference_name = "System.Bug" 107 | mock_wit_type1.description = "Represents a bug or defect" 108 | 109 | mock_wit_type2 = MagicMock() 110 | mock_wit_type2.name = "Task" 111 | mock_wit_type2.reference_name = "System.Task" 112 | mock_wit_type2.description = "Represents a task item" 113 | 114 | mock_process_client.get_process_by_its_id.return_value = mock_process 115 | mock_process_client.get_process_work_item_types.return_value = [ 116 | mock_wit_type1, mock_wit_type2] 117 | 118 | # Act 119 | result = _get_process_details_impl("process-id-123") 120 | 121 | # Assert 122 | mock_process_client.get_process_by_its_id.assert_called_once_with("process-id-123") 123 | mock_process_client.get_process_work_item_types.assert_called_once_with("process-id-123") 124 | 125 | # Check result formatting 126 | assert "Process: Agile" in result 127 | assert "Description: Agile process template" in result 128 | assert "Reference Name: Agile" in result 129 | assert "Type ID: process-id-123" in result 130 | 131 | # Check properties section 132 | assert "Properties" in result 133 | assert "Is default: True" in result 134 | assert "Is enabled: True" in result 135 | 136 | # Check work item types section 137 | assert "Work Item Types" in result 138 | assert "Bug" in result 139 | assert "System.Bug" in result 140 | assert "Represents a bug or defect" in result 141 | assert "Task" in result 142 | assert "System.Task" in result 143 | assert "Represents a task item" in result 144 | 145 | 146 | @patch("mcp_azure_devops.features.work_items.tools.process.get_work_item_tracking_process_client") 147 | def test_get_process_details_impl_not_found(mock_get_process_client): 148 | """Test retrieving process details when process is not found.""" 149 | # Arrange 150 | mock_process_client = MagicMock() 151 | mock_get_process_client.return_value = mock_process_client 152 | 153 | # Process not found 154 | mock_process_client.get_process_by_its_id.return_value = None 155 | 156 | # Act 157 | result = _get_process_details_impl("non-existent-id") 158 | 159 | # Assert 160 | assert "Process with ID 'non-existent-id' not found" in result 161 | 162 | 163 | @patch("mcp_azure_devops.features.work_items.tools.process.get_work_item_tracking_process_client") 164 | def test_get_process_details_impl_error(mock_get_process_client): 165 | """Test error handling in get_process_details_impl.""" 166 | # Arrange 167 | mock_process_client = MagicMock() 168 | mock_get_process_client.return_value = mock_process_client 169 | 170 | # Simulate error 171 | mock_process_client.get_process_by_its_id.side_effect = Exception( 172 | "Test error") 173 | 174 | # Act 175 | result = _get_process_details_impl("process-id-123") 176 | 177 | # Assert 178 | assert ("Error retrieving process details for process ID 'process-id-123':" 179 | " Test error" in result) 180 | 181 | 182 | @patch("mcp_azure_devops.features.work_items.tools.process.get_work_item_tracking_process_client") 183 | def test_list_processes_impl(mock_get_process_client): 184 | """Test listing all processes.""" 185 | # Arrange 186 | mock_process_client = MagicMock() 187 | mock_get_process_client.return_value = mock_process_client 188 | 189 | # Mock processes 190 | mock_process1 = MagicMock() 191 | mock_process1.name = "Agile" 192 | mock_process1.type_id = "process-id-123" 193 | mock_process1.reference_name = "Agile" 194 | mock_process1.description = "Agile process template" 195 | 196 | mock_properties1 = MagicMock() 197 | mock_properties1.is_default = True 198 | mock_process1.properties = mock_properties1 199 | 200 | mock_process2 = MagicMock() 201 | mock_process2.name = "Scrum" 202 | mock_process2.type_id = "process-id-456" 203 | mock_process2.reference_name = "Scrum" 204 | mock_process2.description = "Scrum process template" 205 | 206 | mock_properties2 = MagicMock() 207 | mock_properties2.is_default = False 208 | mock_process2.properties = mock_properties2 209 | 210 | mock_process_client.get_list_of_processes.return_value = [ 211 | mock_process1, mock_process2] 212 | 213 | # Act 214 | result = _list_processes_impl() 215 | 216 | # Assert 217 | mock_process_client.get_list_of_processes.assert_called_once() 218 | 219 | # Check result formatting 220 | assert "Available Processes" in result 221 | assert "Agile" in result 222 | assert "process-id-123" in result 223 | assert "Agile process template" in result 224 | assert "Yes" in result # For is_default=True 225 | assert "Scrum" in result 226 | assert "process-id-456" in result 227 | assert "Scrum process template" in result 228 | assert "No" in result # For is_default=False 229 | 230 | 231 | @patch("mcp_azure_devops.features.work_items.tools.process.get_work_item_tracking_process_client") 232 | def test_list_processes_impl_no_processes(mock_get_process_client): 233 | """Test listing processes when none exist.""" 234 | # Arrange 235 | mock_process_client = MagicMock() 236 | mock_get_process_client.return_value = mock_process_client 237 | 238 | # No processes 239 | mock_process_client.get_list_of_processes.return_value = [] 240 | 241 | # Act 242 | result = _list_processes_impl() 243 | 244 | # Assert 245 | assert "No processes found in the organization" in result 246 | 247 | 248 | @patch("mcp_azure_devops.features.work_items.tools.process.get_work_item_tracking_process_client") 249 | def test_list_processes_impl_error(mock_get_process_client): 250 | """Test error handling in list_processes_impl.""" 251 | # Arrange 252 | mock_process_client = MagicMock() 253 | mock_get_process_client.return_value = mock_process_client 254 | 255 | # Simulate error 256 | mock_process_client.get_list_of_processes.side_effect = Exception( 257 | "Test error") 258 | 259 | # Act 260 | result = _list_processes_impl() 261 | 262 | # Assert 263 | assert "Error retrieving processes: Test error" in result -------------------------------------------------------------------------------- /tests/features/work_items/test_templates.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from azure.devops.v7_1.work_item_tracking.models import WorkItemTemplate 4 | 5 | from mcp_azure_devops.features.work_items.tools.templates import ( 6 | _get_work_item_template_impl, 7 | _get_work_item_templates_impl, 8 | ) 9 | 10 | 11 | def test_get_work_item_templates_impl_with_templates(): 12 | """Test retrieving work item templates.""" 13 | # Arrange 14 | mock_client = MagicMock() 15 | 16 | # Create mock templates 17 | mock_template1 = MagicMock(spec=WorkItemTemplate) 18 | mock_template1.id = "template1" 19 | mock_template1.name = "Bug Template" 20 | mock_template1.description = "Template for bugs" 21 | mock_template1.work_item_type_name = "Bug" 22 | mock_template1.fields = { 23 | "System.Title": "New Bug", 24 | "System.Description": "Bug description" 25 | } 26 | 27 | mock_template2 = MagicMock(spec=WorkItemTemplate) 28 | mock_template2.id = "template2" 29 | mock_template2.name = "Task Template" 30 | mock_template2.description = "Template for tasks" 31 | mock_template2.work_item_type_name = "Task" 32 | mock_template2.fields = { 33 | "System.Title": "New Task", 34 | "System.Description": "Task description" 35 | } 36 | 37 | mock_client.get_templates.return_value = [mock_template1, mock_template2] 38 | 39 | team_context = { 40 | "project": "TestProject", 41 | "team": "TestTeam" 42 | } 43 | 44 | # Act 45 | result = _get_work_item_templates_impl(team_context, "Bug", mock_client) 46 | 47 | # Assert 48 | mock_client.get_templates.assert_called_once() 49 | 50 | # Verify the table contents 51 | assert "Work Item Templates for Team: TestTeam" in result 52 | assert "Filtered by type: Bug" in result 53 | assert "Bug Template" in result 54 | assert "Template for bugs" in result 55 | assert "Bug" in result 56 | assert "Task Template" in result 57 | assert "Task" in result 58 | 59 | 60 | def test_get_work_item_templates_impl_no_templates(): 61 | """Test retrieving work item templates when none exist.""" 62 | # Arrange 63 | mock_client = MagicMock() 64 | mock_client.get_templates.return_value = [] 65 | 66 | team_context = { 67 | "project": "TestProject", 68 | "team": "TestTeam" 69 | } 70 | 71 | # Act 72 | result = _get_work_item_templates_impl(team_context, "Bug", mock_client) 73 | 74 | # Assert 75 | mock_client.get_templates.assert_called_once() 76 | assert "No templates found for work item type 'Bug'" in result 77 | assert "team TestTeam" in result 78 | 79 | 80 | def test_get_work_item_template_impl(): 81 | """Test retrieving a specific work item template.""" 82 | # Arrange 83 | mock_client = MagicMock() 84 | 85 | # Create mock template 86 | mock_template = MagicMock(spec=WorkItemTemplate) 87 | mock_template.id = "template1" 88 | mock_template.name = "Bug Template" 89 | mock_template.description = "Template for bugs" 90 | mock_template.work_item_type_name = "Bug" 91 | mock_template.fields = { 92 | "System.Title": "New Bug", 93 | "System.Description": "Bug description", 94 | "Microsoft.VSTS.Common.Priority": 2 95 | } 96 | 97 | mock_client.get_template.return_value = mock_template 98 | 99 | team_context = { 100 | "project": "TestProject", 101 | "team": "TestTeam" 102 | } 103 | 104 | # Act 105 | result = _get_work_item_template_impl( 106 | team_context, "template1", mock_client) 107 | 108 | # Assert 109 | mock_client.get_template.assert_called_once() 110 | assert "Template: Bug Template" in result 111 | assert "Description: Template for bugs" in result 112 | assert "Work item type name: Bug" in result 113 | assert "Id: template1" in result 114 | assert "Default Field Values" in result 115 | assert "System.Title" in result 116 | assert "New Bug" in result 117 | assert "System.Description" in result 118 | assert "Bug description" in result 119 | assert "Microsoft.VSTS.Common.Priority" in result 120 | assert "2" in result 121 | 122 | 123 | def test_get_work_item_template_impl_not_found(): 124 | """Test retrieving a template that doesn't exist.""" 125 | # Arrange 126 | mock_client = MagicMock() 127 | mock_client.get_template.return_value = None 128 | 129 | team_context = { 130 | "project": "TestProject", 131 | "team": "TestTeam" 132 | } 133 | 134 | # Act 135 | result = _get_work_item_template_impl( 136 | team_context, "non-existent", mock_client) 137 | 138 | # Assert 139 | mock_client.get_template.assert_called_once() 140 | assert "Template with ID 'non-existent' not found" in result 141 | 142 | 143 | def test_get_work_item_template_impl_error_handling(): 144 | """Test error handling in get_work_item_template_impl.""" 145 | # Arrange 146 | mock_client = MagicMock() 147 | mock_client.get_template.side_effect = Exception("Test error") 148 | 149 | team_context = { 150 | "project": "TestProject", 151 | "team": "TestTeam" 152 | } 153 | 154 | # Act 155 | result = _get_work_item_template_impl( 156 | team_context, "template1", mock_client) 157 | 158 | # Assert 159 | assert "Error retrieving template 'template1': Test error" in result -------------------------------------------------------------------------------- /tests/features/work_items/test_tools.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from azure.devops.v7_1.work_item_tracking.models import ( 4 | WorkItem, 5 | WorkItemReference, 6 | ) 7 | 8 | from mcp_azure_devops.features.work_items.tools.comments import ( 9 | _get_work_item_comments_impl, 10 | ) 11 | from mcp_azure_devops.features.work_items.tools.query import ( 12 | _query_work_items_impl, 13 | ) 14 | from mcp_azure_devops.features.work_items.tools.read import _get_work_item_impl 15 | 16 | 17 | # Tests for _query_work_items_impl 18 | def test_query_work_items_impl_no_results(): 19 | """Test query with no results.""" 20 | mock_client = MagicMock() 21 | mock_query_result = MagicMock() 22 | mock_query_result.work_items = [] 23 | mock_client.query_by_wiql.return_value = mock_query_result 24 | 25 | result = _query_work_items_impl("SELECT * FROM WorkItems", 10, mock_client) 26 | assert result == "No work items found matching the query." 27 | 28 | def test_query_work_items_impl_with_results(): 29 | """Test query with results.""" 30 | mock_client = MagicMock() 31 | 32 | # Mock query result 33 | mock_query_result = MagicMock() 34 | mock_work_item_ref1 = MagicMock(spec=WorkItemReference) 35 | mock_work_item_ref1.id = "123" 36 | mock_work_item_ref2 = MagicMock(spec=WorkItemReference) 37 | mock_work_item_ref2.id = "456" 38 | mock_query_result.work_items = [mock_work_item_ref1, mock_work_item_ref2] 39 | mock_client.query_by_wiql.return_value = mock_query_result 40 | 41 | # Mock work items 42 | mock_work_item1 = MagicMock(spec=WorkItem) 43 | mock_work_item1.id = 123 44 | mock_work_item1.fields = { 45 | "System.WorkItemType": "Bug", 46 | "System.Title": "Test Bug", 47 | "System.State": "Active" 48 | } 49 | 50 | mock_work_item2 = MagicMock(spec=WorkItem) 51 | mock_work_item2.id = 456 52 | mock_work_item2.fields = { 53 | "System.WorkItemType": "Task", 54 | "System.Title": "Test Task", 55 | "System.State": "Closed" 56 | } 57 | 58 | mock_client.get_work_items.return_value = [ 59 | mock_work_item1, mock_work_item2] 60 | 61 | result = _query_work_items_impl("SELECT * FROM WorkItems", 10, mock_client) 62 | 63 | # Check that the result contains the expected formatting 64 | # per format_work_item 65 | assert "# Work Item 123" in result 66 | assert "- **System.WorkItemType**: Bug" in result 67 | assert "- **System.Title**: Test Bug" in result 68 | assert "- **System.State**: Active" in result 69 | assert "# Work Item 456" in result 70 | assert "- **System.WorkItemType**: Task" in result 71 | assert "- **System.Title**: Test Task" in result 72 | assert "- **System.State**: Closed" in result 73 | 74 | 75 | # Tests for _get_work_item_impl 76 | def test_get_work_item_impl_basic(): 77 | """Test retrieving basic work item info.""" 78 | mock_client = MagicMock() 79 | 80 | # Mock work item 81 | mock_work_item = MagicMock(spec=WorkItem) 82 | mock_work_item.id = 123 83 | mock_work_item.fields = { 84 | "System.WorkItemType": "Bug", 85 | "System.Title": "Test Bug", 86 | "System.State": "Active", 87 | "System.TeamProject": "Test Project" 88 | } 89 | mock_client.get_work_item.return_value = mock_work_item 90 | 91 | result = _get_work_item_impl(123, mock_client) 92 | 93 | # Check that the result contains expected basic info 94 | assert "# Work Item 123" in result 95 | assert "- **System.WorkItemType**: Bug" in result 96 | assert "- **System.Title**: Test Bug" in result 97 | assert "- **System.State**: Active" in result 98 | assert "- **System.TeamProject**: Test Project" in result 99 | 100 | def test_get_work_item_impl_detailed(): 101 | """Test retrieving detailed work item info.""" 102 | mock_client = MagicMock() 103 | 104 | # Mock work item with more fields for detailed view 105 | mock_work_item = MagicMock(spec=WorkItem) 106 | mock_work_item.id = 123 107 | mock_work_item.fields = { 108 | "System.WorkItemType": "Bug", 109 | "System.Title": "Test Bug", 110 | "System.State": "Active", 111 | "System.TeamProject": "Test Project", 112 | "System.Description": "This is a description", 113 | "System.AssignedTo": { 114 | "displayName": "Test User", 115 | "uniqueName": "test@example.com" 116 | }, 117 | "System.CreatedBy": {"displayName": "Creator User"}, 118 | "System.CreatedDate": "2023-01-01", 119 | "System.IterationPath": "Project\\Sprint 1", 120 | "System.AreaPath": "Project\\Area", 121 | "System.Tags": "tag1; tag2", 122 | } 123 | mock_client.get_work_item.return_value = mock_work_item 124 | 125 | result = _get_work_item_impl(123, mock_client) 126 | 127 | # Check that the result contains both basic and detailed info 128 | assert "# Work Item 123" in result 129 | assert "- **System.WorkItemType**: Bug" in result 130 | assert "- **System.Description**: This is a description" in result 131 | assert "- **System.AssignedTo**: Test User (test@example.com)" in result 132 | assert "- **System.CreatedBy**: Creator User" in result 133 | assert "- **System.IterationPath**: Project\\Sprint 1" in result 134 | assert "- **System.AreaPath**: Project\\Area" in result 135 | assert "- **System.Tags**: tag1; tag2" in result 136 | 137 | def test_get_work_item_impl_error(): 138 | """Test error handling in get_work_item_impl.""" 139 | mock_client = MagicMock() 140 | mock_client.get_work_item.side_effect = Exception("Test error") 141 | 142 | result = _get_work_item_impl(123, mock_client) 143 | 144 | assert "Error retrieving work item 123: Test error" in result 145 | 146 | # Tests for _get_work_item_comments_impl 147 | def test_get_work_item_comments_impl(): 148 | """Test retrieving work item comments.""" 149 | mock_client = MagicMock() 150 | 151 | # Mock work item for project lookup 152 | mock_work_item = MagicMock(spec=WorkItem) 153 | mock_work_item.fields = {"System.TeamProject": "Test Project"} 154 | mock_client.get_work_item.return_value = mock_work_item 155 | 156 | # Mock comments 157 | mock_comment1 = MagicMock() 158 | mock_comment1.text = "This is comment 1" 159 | mock_created_by = MagicMock() 160 | mock_created_by.display_name = "Comment User" 161 | mock_comment1.created_by = mock_created_by 162 | mock_comment1.created_date = "2023-01-02" 163 | 164 | mock_comments = MagicMock() 165 | mock_comments.comments = [mock_comment1] 166 | mock_client.get_comments.return_value = mock_comments 167 | 168 | result = _get_work_item_comments_impl(123, mock_client) 169 | 170 | assert "## Comment by Comment User on 2023-01-02" in result 171 | assert "This is comment 1" in result 172 | 173 | def test_get_work_item_comments_impl_no_comments(): 174 | """Test retrieving work item with no comments.""" 175 | mock_client = MagicMock() 176 | 177 | # Mock work item for project lookup 178 | mock_work_item = MagicMock(spec=WorkItem) 179 | mock_work_item.fields = {"System.TeamProject": "Test Project"} 180 | mock_client.get_work_item.return_value = mock_work_item 181 | 182 | # Mock empty comments 183 | mock_comments = MagicMock() 184 | mock_comments.comments = [] 185 | mock_client.get_comments.return_value = mock_comments 186 | 187 | result = _get_work_item_comments_impl(123, mock_client) 188 | 189 | assert "No comments found for this work item." in result 190 | -------------------------------------------------------------------------------- /tests/features/work_items/test_types.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from azure.devops.v7_1.work_item_tracking.models import WorkItemType 4 | 5 | from mcp_azure_devops.features.work_items.tools.types import ( 6 | _get_work_item_type_field_impl, 7 | _get_work_item_type_fields_impl, 8 | _get_work_item_type_impl, 9 | _get_work_item_types_impl, 10 | ) 11 | 12 | 13 | def test_get_work_item_types_impl(): 14 | """Test retrieving all work item types.""" 15 | # Arrange 16 | mock_client = MagicMock() 17 | 18 | # Create mock work item types 19 | mock_bug_type = MagicMock(spec=WorkItemType) 20 | mock_bug_type.name = "Bug" 21 | mock_bug_type.reference_name = "System.Bug" 22 | mock_bug_type.description = "Represents a bug or defect" 23 | mock_bug_type.color = "FF0000" 24 | mock_bug_type.icon = "bug" 25 | 26 | mock_task_type = MagicMock(spec=WorkItemType) 27 | mock_task_type.name = "Task" 28 | mock_task_type.reference_name = "System.Task" 29 | mock_task_type.description = "Represents a task item" 30 | mock_task_type.color = "00FF00" 31 | mock_task_type.icon = "task" 32 | 33 | mock_client.get_work_item_types.return_value = [ 34 | mock_bug_type, mock_task_type] 35 | 36 | # Act 37 | result = _get_work_item_types_impl("TestProject", mock_client) 38 | 39 | # Assert 40 | mock_client.get_work_item_types.assert_called_once_with("TestProject") 41 | 42 | # Check result content 43 | assert "Work Item Types in Project: TestProject" in result 44 | assert "Bug" in result 45 | assert "System.Bug" in result 46 | assert "Represents a bug or defect" in result 47 | assert "Task" in result 48 | assert "System.Task" in result 49 | assert "Represents a task item" in result 50 | 51 | 52 | def test_get_work_item_types_impl_no_types(): 53 | """Test retrieving work item types when none exist.""" 54 | # Arrange 55 | mock_client = MagicMock() 56 | mock_client.get_work_item_types.return_value = [] 57 | 58 | # Act 59 | result = _get_work_item_types_impl("TestProject", mock_client) 60 | 61 | # Assert 62 | mock_client.get_work_item_types.assert_called_once_with("TestProject") 63 | assert "No work item types found in project TestProject" in result 64 | 65 | 66 | def test_get_work_item_type_impl(): 67 | """Test retrieving a specific work item type.""" 68 | # Arrange 69 | mock_client = MagicMock() 70 | 71 | # Create mock work item type with states 72 | mock_state1 = MagicMock() 73 | mock_state1.name = "New" 74 | mock_state1.color = "0000FF" 75 | mock_state1.category = "Proposed" 76 | 77 | mock_state2 = MagicMock() 78 | mock_state2.name = "Active" 79 | mock_state2.color = "00FF00" 80 | mock_state2.category = "InProgress" 81 | 82 | mock_state3 = MagicMock() 83 | mock_state3.name = "Resolved" 84 | mock_state3.color = "FFFF00" 85 | mock_state3.category = "Resolved" 86 | 87 | mock_state4 = MagicMock() 88 | mock_state4.name = "Closed" 89 | mock_state4.color = "008000" 90 | mock_state4.category = "Completed" 91 | 92 | mock_bug_type = MagicMock(spec=WorkItemType) 93 | mock_bug_type.name = "Bug" 94 | mock_bug_type.reference_name = "System.Bug" 95 | mock_bug_type.description = "Represents a bug or defect" 96 | mock_bug_type.color = "FF0000" 97 | mock_bug_type.icon = "bug" 98 | mock_bug_type.states = [mock_state1, mock_state2, mock_state3, mock_state4] 99 | 100 | mock_client.get_work_item_type.return_value = mock_bug_type 101 | 102 | # Act 103 | result = _get_work_item_type_impl("TestProject", "Bug", mock_client) 104 | 105 | # Assert 106 | mock_client.get_work_item_type.assert_called_once_with( 107 | "TestProject", "Bug") 108 | 109 | # Check result content 110 | assert "Work Item Type: Bug" in result or "# Work Item Type: Bug" in result 111 | assert "Description: Represents a bug or defect" in result 112 | assert "Color: FF0000" in result 113 | assert "Icon: bug" in result 114 | assert "Reference_name: System.Bug" in result 115 | 116 | # Check states section 117 | assert "States" in result 118 | assert "New" in result 119 | assert "Active" in result 120 | assert "Resolved" in result 121 | assert "Closed" in result 122 | assert "Category: Proposed" in result 123 | assert "Color: 0000FF" in result 124 | assert "Category: Completed" in result 125 | assert "Color: 008000" in result 126 | 127 | 128 | def test_get_work_item_type_impl_not_found(): 129 | """Test retrieving a work item type that doesn't exist.""" 130 | # Arrange 131 | mock_client = MagicMock() 132 | mock_client.get_work_item_type.return_value = None 133 | 134 | # Act 135 | result = _get_work_item_type_impl( 136 | "TestProject", "NonExistentType", mock_client) 137 | 138 | # Assert 139 | mock_client.get_work_item_type.assert_called_once_with( 140 | "TestProject", "NonExistentType") 141 | assert ("Work item type 'NonExistentType' not found in project " 142 | "TestProject" in result) 143 | 144 | 145 | @patch("mcp_azure_devops.features.work_items.tools.types.get_core_client") 146 | @patch("mcp_azure_devops.features.work_items.tools.types.get_work_item_tracking_process_client") 147 | def test_get_work_item_type_fields_impl( 148 | mock_get_process_client, mock_get_core_client): 149 | """Test retrieving all fields for a work item type.""" 150 | # Arrange 151 | mock_wit_client = MagicMock() 152 | mock_core_client = MagicMock() 153 | mock_process_client = MagicMock() 154 | 155 | # Setup mock for get_work_item_type 156 | mock_bug_type = MagicMock(spec=WorkItemType) 157 | mock_bug_type.name = "Bug" 158 | mock_bug_type.reference_name = "System.Bug" 159 | mock_wit_client.get_work_item_type.return_value = mock_bug_type 160 | 161 | # Setup mock for get_project from core client 162 | mock_project = MagicMock() 163 | mock_project.capabilities = { 164 | "processTemplate": { 165 | "templateTypeId": "process-id-123" 166 | } 167 | } 168 | mock_core_client.get_project.return_value = mock_project 169 | mock_get_core_client.return_value = mock_core_client 170 | 171 | # Setup mock for get_all_work_item_type_fields 172 | mock_title_field = MagicMock() 173 | mock_title_field.name = "Title" 174 | mock_title_field.reference_name = "System.Title" 175 | mock_title_field.type = "string" 176 | mock_title_field.required = True 177 | mock_title_field.read_only = False 178 | 179 | mock_desc_field = MagicMock() 180 | mock_desc_field.name = "Description" 181 | mock_desc_field.reference_name = "System.Description" 182 | mock_desc_field.type = "html" 183 | mock_desc_field.required = False 184 | mock_desc_field.read_only = False 185 | 186 | mock_priority_field = MagicMock() 187 | mock_priority_field.name = "Priority" 188 | mock_priority_field.reference_name = "Microsoft.VSTS.Common.Priority" 189 | mock_priority_field.type = "integer" 190 | mock_priority_field.required = False 191 | mock_priority_field.read_only = False 192 | 193 | mock_process_client.get_all_work_item_type_fields.return_value = [ 194 | mock_title_field, mock_desc_field, mock_priority_field 195 | ] 196 | mock_get_process_client.return_value = mock_process_client 197 | 198 | # Act 199 | result = _get_work_item_type_fields_impl( 200 | "TestProject", "Bug", mock_wit_client) 201 | 202 | # Assert 203 | mock_wit_client.get_work_item_type.assert_called_once_with( 204 | "TestProject", "Bug") 205 | mock_core_client.get_project.assert_called_once_with( 206 | "TestProject", include_capabilities=True) 207 | mock_process_client.get_all_work_item_type_fields.assert_called_once_with( 208 | "process-id-123", "System.Bug") 209 | 210 | # Check result content 211 | assert "Fields for Work Item Type: Bug" in result 212 | assert "Title" in result 213 | assert "System.Title" in result 214 | assert "string" in result 215 | assert "Yes" in result # For required 216 | assert "No" in result # For read-only 217 | assert "Description" in result 218 | assert "System.Description" in result 219 | assert "html" in result 220 | assert "Priority" in result 221 | assert "Microsoft.VSTS.Common.Priority" in result 222 | assert "integer" in result 223 | 224 | 225 | @patch("mcp_azure_devops.features.work_items.tools.types.get_core_client") 226 | @patch("mcp_azure_devops.features.work_items.tools.types.get_work_item_tracking_process_client") 227 | def test_get_work_item_type_fields_impl_no_fields( 228 | mock_get_process_client, mock_get_core_client): 229 | """Test retrieving fields when none exist.""" 230 | # Arrange 231 | mock_wit_client = MagicMock() 232 | mock_core_client = MagicMock() 233 | mock_process_client = MagicMock() 234 | 235 | # Setup mock for get_work_item_type 236 | mock_bug_type = MagicMock(spec=WorkItemType) 237 | mock_bug_type.name = "Bug" 238 | mock_bug_type.reference_name = "System.Bug" 239 | mock_wit_client.get_work_item_type.return_value = mock_bug_type 240 | 241 | # Setup mock for get_project from core client 242 | mock_project = MagicMock() 243 | mock_project.capabilities = { 244 | "processTemplate": { 245 | "templateTypeId": "process-id-123" 246 | } 247 | } 248 | mock_core_client.get_project.return_value = mock_project 249 | mock_get_core_client.return_value = mock_core_client 250 | 251 | # Setup mock for get_all_work_item_type_fields - empty 252 | mock_process_client.get_all_work_item_type_fields.return_value = [] 253 | mock_get_process_client.return_value = mock_process_client 254 | 255 | # Act 256 | result = _get_work_item_type_fields_impl( 257 | "TestProject", "Bug", mock_wit_client) 258 | 259 | # Assert 260 | assert ("No fields found for work item type 'Bug' in project TestProject" 261 | in result) 262 | 263 | 264 | @patch("mcp_azure_devops.features.work_items.tools.types.get_core_client") 265 | @patch("mcp_azure_devops.features.work_items.tools.types.get_work_item_tracking_process_client") 266 | def test_get_work_item_type_field_impl( 267 | mock_get_process_client, mock_get_core_client): 268 | """Test retrieving a specific field for a work item type.""" 269 | # Arrange 270 | mock_wit_client = MagicMock() 271 | mock_core_client = MagicMock() 272 | mock_process_client = MagicMock() 273 | 274 | # Setup mock for get_work_item_type 275 | mock_bug_type = MagicMock(spec=WorkItemType) 276 | mock_bug_type.name = "Bug" 277 | mock_bug_type.reference_name = "System.Bug" 278 | mock_wit_client.get_work_item_type.return_value = mock_bug_type 279 | 280 | # Setup mock for get_project from core client 281 | mock_project = MagicMock() 282 | mock_project.capabilities = { 283 | "processTemplate": { 284 | "templateTypeId": "process-id-123" 285 | } 286 | } 287 | mock_core_client.get_project.return_value = mock_project 288 | mock_get_core_client.return_value = mock_core_client 289 | 290 | # Setup mock for get_work_item_type_field 291 | mock_priority_field = MagicMock() 292 | mock_priority_field.name = "Priority" 293 | mock_priority_field.reference_name = "Microsoft.VSTS.Common.Priority" 294 | mock_priority_field.type = "integer" 295 | mock_priority_field.required = False 296 | mock_priority_field.read_only = False 297 | mock_priority_field.allowed_values = ["1", "2", "3", "4"] 298 | mock_priority_field.default_value = "3" 299 | 300 | mock_process_client.get_work_item_type_field.return_value = ( 301 | mock_priority_field) 302 | mock_get_process_client.return_value = mock_process_client 303 | 304 | # Use reference name directly for this test 305 | field_name = "Microsoft.VSTS.Common.Priority" 306 | 307 | # Act 308 | result = _get_work_item_type_field_impl( 309 | "TestProject", "Bug", field_name, mock_wit_client) 310 | 311 | # Assert 312 | mock_wit_client.get_work_item_type.assert_called_once_with( 313 | "TestProject", "Bug") 314 | mock_core_client.get_project.assert_called_once_with( 315 | "TestProject", include_capabilities=True) 316 | # Verify the mock method was called in some manner 317 | assert mock_process_client.get_work_item_type_field.call_count > 0 318 | 319 | # Check result content 320 | assert "Field: Priority" in result or "# Field: Priority" in result 321 | assert "Reference Name: Microsoft.VSTS.Common.Priority" in result 322 | assert "Type: integer" in result 323 | assert "Required: No" in result 324 | assert "Read Only: No" in result 325 | assert "Allowed Values" in result 326 | assert "1" in result 327 | assert "2" in result 328 | assert "3" in result 329 | assert "4" in result 330 | assert "Default Value: 3" in result 331 | 332 | 333 | @patch("mcp_azure_devops.features.work_items.tools.types.get_core_client") 334 | @patch("mcp_azure_devops.features.work_items.tools.types.get_work_item_tracking_process_client") 335 | def test_get_work_item_type_field_impl_display_name( 336 | mock_get_process_client, mock_get_core_client): 337 | """Test retrieving a field by display name instead of reference name.""" 338 | # Arrange 339 | mock_wit_client = MagicMock() 340 | mock_core_client = MagicMock() 341 | mock_process_client = MagicMock() 342 | 343 | # Setup mock for get_work_item_type 344 | mock_bug_type = MagicMock(spec=WorkItemType) 345 | mock_bug_type.name = "Bug" 346 | mock_bug_type.reference_name = "System.Bug" 347 | mock_wit_client.get_work_item_type.return_value = mock_bug_type 348 | 349 | # Setup mock for get_project from core client 350 | mock_project = MagicMock() 351 | mock_project.capabilities = { 352 | "processTemplate": { 353 | "templateTypeId": "process-id-123" 354 | } 355 | } 356 | mock_core_client.get_project.return_value = mock_project 357 | mock_get_core_client.return_value = mock_core_client 358 | 359 | # Setup mock for get_all_work_item_type_fields 360 | # (used to find reference name) 361 | mock_priority_field = MagicMock() 362 | mock_priority_field.name = "Priority" 363 | mock_priority_field.reference_name = "Microsoft.VSTS.Common.Priority" 364 | 365 | mock_process_client.get_all_work_item_type_fields.return_value = [ 366 | mock_priority_field] 367 | 368 | # Setup mock for get_work_item_type_field 369 | mock_field_detail = MagicMock() 370 | mock_field_detail.name = "Priority" 371 | mock_field_detail.reference_name = "Microsoft.VSTS.Common.Priority" 372 | mock_field_detail.type = "integer" 373 | mock_field_detail.required = False 374 | mock_field_detail.read_only = False 375 | mock_field_detail.allowed_values = ["1", "2", "3", "4"] 376 | 377 | mock_process_client.get_work_item_type_field.return_value = ( 378 | mock_field_detail) 379 | mock_get_process_client.return_value = mock_process_client 380 | 381 | # Use display name for this test 382 | field_name = "Priority" 383 | 384 | # Act 385 | result = _get_work_item_type_field_impl( 386 | "TestProject", "Bug", field_name, mock_wit_client) 387 | 388 | # Assert 389 | # First verify it looked up all fields to find reference name 390 | mock_process_client.get_all_work_item_type_fields.assert_called_once_with( 391 | "process-id-123", "System.Bug") 392 | 393 | # Then verify it called get_work_item_type_field with the reference name 394 | # Handle the different ways the implementation might call 395 | # the process client 396 | # The test might be expecting a specific method call, 397 | # but the implementation might be different 398 | assert mock_process_client.get_work_item_type_field.call_count > 0 399 | mock_process_client.get_work_item_type_field.assert_any_call( 400 | "process-id-123", "System.Bug", "Microsoft.VSTS.Common.Priority" 401 | ) 402 | 403 | # Check result content 404 | assert "Field: Priority" in result 405 | assert "Reference Name: Microsoft.VSTS.Common.Priority" in result 406 | 407 | 408 | @patch("mcp_azure_devops.features.work_items.tools.types.get_core_client") 409 | @patch("mcp_azure_devops.features.work_items.tools.types.get_work_item_tracking_process_client") 410 | def test_get_work_item_type_field_impl_field_not_found( 411 | mock_get_process_client, mock_get_core_client): 412 | """Test retrieving a field that doesn't exist.""" 413 | # Arrange 414 | mock_wit_client = MagicMock() 415 | mock_core_client = MagicMock() 416 | mock_process_client = MagicMock() 417 | 418 | # Setup mock for get_work_item_type 419 | mock_bug_type = MagicMock(spec=WorkItemType) 420 | mock_bug_type.name = "Bug" 421 | mock_bug_type.reference_name = "System.Bug" 422 | mock_wit_client.get_work_item_type.return_value = mock_bug_type 423 | 424 | # Setup mock for get_project from core client 425 | mock_project = MagicMock() 426 | mock_project.capabilities = { 427 | "processTemplate": { 428 | "templateTypeId": "process-id-123" 429 | } 430 | } 431 | mock_core_client.get_project.return_value = mock_project 432 | mock_get_core_client.return_value = mock_core_client 433 | 434 | # Setup mock for get_all_work_item_type_fields - empty for lookup 435 | mock_process_client.get_all_work_item_type_fields.return_value = [] 436 | mock_get_process_client.return_value = mock_process_client 437 | 438 | # Use a non-existent field name 439 | field_name = "NonExistentField" 440 | 441 | # Act 442 | result = _get_work_item_type_field_impl( 443 | "TestProject", "Bug", field_name, mock_wit_client) 444 | 445 | # Assert 446 | assert (f"Field '{field_name}' not found for work item type " 447 | f"'Bug' in project 'TestProject'" in result) 448 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the Azure DevOps MCP Server. 3 | """ 4 | import pytest 5 | from mcp.shared.memory import ( 6 | create_connected_server_and_client_session as client_session, 7 | ) 8 | 9 | from mcp_azure_devops.server import mcp 10 | 11 | 12 | # Mark all tests with anyio for async testing 13 | @pytest.mark.anyio 14 | async def test_server_initialization(): 15 | """Test that the server initializes correctly and returns capabilities.""" 16 | async with client_session(mcp._mcp_server) as client: 17 | # Initialize the connection 18 | init_result = await client.initialize() 19 | 20 | # Check that initialization was successful 21 | assert init_result is not None 22 | 23 | # Check server name in serverInfo 24 | assert init_result.serverInfo.name == "Azure DevOps" 25 | 26 | # Check that the server has capabilities 27 | capabilities = init_result.capabilities 28 | assert capabilities is not None 29 | 30 | # Check for specific capabilities we expect 31 | assert capabilities.prompts is not None 32 | assert capabilities.resources is not None 33 | assert capabilities.tools is not None --------------------------------------------------------------------------------